diff --git a/README.md b/README.md index d5650cf..34d69d6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。 +交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5) + ## 功能特性 - **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。 @@ -21,6 +23,16 @@ LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + T - **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。 - **部署友好**:默认 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 @@ -204,3 +216,17 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build ## License [MIT](./LICENSE) + + +## Star 趋势 + + + + + + Star History Chart + + + +友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区 + diff --git a/apps/api/internal/app/maildir_ownership_other.go b/apps/api/internal/app/maildir_ownership_other.go new file mode 100644 index 0000000..261ecc4 --- /dev/null +++ b/apps/api/internal/app/maildir_ownership_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package app + +func applyMaildirOwnership(path string) error { + return nil +} diff --git a/apps/api/internal/app/maildir_ownership_unix.go b/apps/api/internal/app/maildir_ownership_unix.go new file mode 100644 index 0000000..3010df2 --- /dev/null +++ b/apps/api/internal/app/maildir_ownership_unix.go @@ -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) +} diff --git a/apps/api/internal/app/maildir_write.go b/apps/api/internal/app/maildir_write.go index c26d08e..4b7fba3 100644 --- a/apps/api/internal/app/maildir_write.go +++ b/apps/api/internal/app/maildir_write.go @@ -120,7 +120,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol if strings.EqualFold(folderName, "Inbox") && !state.IsRead { subdir = "new" } - if err := ensureMaildirFolderDirs(folderBase); err != nil { + if err := ensureMaildirFolderDirs(base, folderBase); err != nil { return err } 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 { return err } + if err := applyMaildirOwnership(tmpPath); err != nil { + _ = os.Remove(tmpPath) + return err + } if err := os.Rename(tmpPath, finalPath); err != nil { _ = os.Remove(tmpPath) 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") folderBase := maildirFolderPath(base, folderName) - if err := ensureMaildirFolderDirs(folderBase); err != nil { + if err := ensureMaildirFolderDirs(base, folderBase); err != nil { return err } subdir := "cur" @@ -225,6 +229,9 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID if err := os.Rename(state.RawPath, targetPath); err != nil { return err } + if err := applyMaildirOwnership(targetPath); err != nil { + return err + } } if state.FolderID != "" && state.FolderID != targetFolderID { 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 { return err } + if err := applyMaildirOwnership(targetPath); err != nil { + return err + } modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID) if err != nil { return err @@ -481,15 +491,33 @@ func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) { 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"} { if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil { return err } } + for _, dir := range maildirOwnershipDirs(base, folderBase) { + if err := applyMaildirOwnership(dir); err != nil { + return err + } + } 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 { base := strings.TrimSpace(headerMessageID) if base == "" { diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index cce0cef..c6fc09d 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -52,6 +52,7 @@ const sectionPermissions: Record = { settings: ["admin.settings.view", "admin.templates.view"], } const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email" +const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5" const projectTag = import.meta.env.VITE_APP_VERSION || "" 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 } @@ -1387,6 +1388,12 @@ function AboutProjectCard() { Issues + diff --git a/docs/screenshots/admin-preview.png b/docs/screenshots/admin-preview.png new file mode 100644 index 0000000..a6a4682 Binary files /dev/null and b/docs/screenshots/admin-preview.png differ diff --git a/docs/screenshots/client-preview.png b/docs/screenshots/client-preview.png new file mode 100644 index 0000000..7e4b807 Binary files /dev/null and b/docs/screenshots/client-preview.png differ diff --git a/docs/screenshots/compose-preview.png b/docs/screenshots/compose-preview.png new file mode 100644 index 0000000..2fd4403 Binary files /dev/null and b/docs/screenshots/compose-preview.png differ diff --git a/docs/screenshots/mail-preview.png b/docs/screenshots/mail-preview.png new file mode 100644 index 0000000..46c781d Binary files /dev/null and b/docs/screenshots/mail-preview.png differ