chore: 合并 Maildir 修复与 README 更新
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||||
|
|
||||||
|
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||||
@@ -21,6 +23,16 @@ LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + T
|
|||||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
|
||||||
|
| 管理后台 · 系统概览 | 第三方客户端配置 |
|
||||||
|
|  |  |
|
||||||
|
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -204,3 +216,17 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
[MIT](./LICENSE)
|
||||||
|
|
||||||
|
|
||||||
|
## Star 趋势
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
func applyMaildirOwnership(path string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
const (
|
||||||
|
maildirOwnerUID = 5000
|
||||||
|
maildirOwnerGID = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyMaildirOwnership(path string) error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.Chown(path, maildirOwnerUID, maildirOwnerGID)
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
|||||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||||
subdir = "new"
|
subdir = "new"
|
||||||
}
|
}
|
||||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
filename := maildirFilename(messageID, state.MessageID)
|
filename := maildirFilename(messageID, state.MessageID)
|
||||||
@@ -130,6 +130,10 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
|||||||
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := applyMaildirOwnership(tmpPath); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
return err
|
return err
|
||||||
@@ -213,7 +217,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
|||||||
}
|
}
|
||||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||||
folderBase := maildirFolderPath(base, folderName)
|
folderBase := maildirFolderPath(base, folderName)
|
||||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
subdir := "cur"
|
subdir := "cur"
|
||||||
@@ -225,6 +229,9 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
|||||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||||
@@ -318,6 +325,9 @@ func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, r
|
|||||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -481,15 +491,33 @@ func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
|||||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureMaildirFolderDirs(folderBase string) error {
|
func ensureMaildirFolderDirs(base, folderBase string) error {
|
||||||
for _, sub := range []string{"tmp", "new", "cur"} {
|
for _, sub := range []string{"tmp", "new", "cur"} {
|
||||||
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, dir := range maildirOwnershipDirs(base, folderBase) {
|
||||||
|
if err := applyMaildirOwnership(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maildirOwnershipDirs(base, folderBase string) []string {
|
||||||
|
dirs := []string{
|
||||||
|
filepath.Dir(filepath.Dir(base)),
|
||||||
|
filepath.Dir(base),
|
||||||
|
base,
|
||||||
|
}
|
||||||
|
if filepath.Clean(folderBase) != filepath.Clean(base) {
|
||||||
|
dirs = append(dirs, folderBase)
|
||||||
|
}
|
||||||
|
dirs = append(dirs, filepath.Join(folderBase, "tmp"), filepath.Join(folderBase, "new"), filepath.Join(folderBase, "cur"))
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
func maildirFilename(messageID, headerMessageID string) string {
|
func maildirFilename(messageID, headerMessageID string) string {
|
||||||
base := strings.TrimSpace(headerMessageID)
|
base := strings.TrimSpace(headerMessageID)
|
||||||
if base == "" {
|
if base == "" {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
|||||||
settings: ["admin.settings.view", "admin.templates.view"],
|
settings: ["admin.settings.view", "admin.templates.view"],
|
||||||
}
|
}
|
||||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||||
|
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||||
@@ -1387,6 +1388,12 @@ function AboutProjectCard() {
|
|||||||
Issues
|
Issues
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||||
|
<a href={projectTelegramUrl} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-5 w-5 text-sky-500" />
|
||||||
|
Telegram 群组
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AboutRow>
|
</AboutRow>
|
||||||
<AboutRow label="支持">
|
<AboutRow label="支持">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in New Issue
Block a user