chore: 合并 Maildir 修复与 README 更新

This commit is contained in:
LanQin
2026-06-24 19:55:43 +08:00
parent 947e3ad248
commit a5f5adf752
9 changed files with 88 additions and 3 deletions
+26
View File
@@ -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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
| --- | --- |
| ![Webmail 邮件阅读与列表](./docs/screenshots/mail-preview.png) | ![写邮件富文本编辑工具栏](./docs/screenshots/compose-preview.png) |
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
| 管理后台 · 系统概览 | 第三方客户端配置 |
| ![管理后台系统概览](./docs/screenshots/admin-preview.png) | ![第三方客户端配置](./docs/screenshots/client-preview.png) |
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 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)
}
+31 -3
View File
@@ -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 == "" {
+7
View File
@@ -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