Compare commits

...

144 Commits

Author SHA1 Message Date
zxyszx cefbee4f7f fix: restore mail folder and export workflows
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 19:07:21 +08:00
zxyszx 06388465a2 fix: default mailbox selector while loading
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:45:49 +08:00
zxyszx e6504ef492 fix: expand all-mailbox selector
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:22:37 +08:00
zxyszx 39ff9ce01d fix: support batched mail imports
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:08:37 +08:00
zxyszx df50f8b3ef fix: reset mailbox view on refresh
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 12:55:21 +08:00
zxyszx 397ce51800 fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 04:14:36 +08:00
zxyszx e789cf9b14 chore: prepare v1.2.6 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 03:44:14 +08:00
zxyszx 7e480795fc feat: add administrator password recovery menu 2026-08-04 03:40:34 +08:00
zxyszx a2eb1fbc52 test: support GNU stat permissions 2026-08-04 02:28:21 +08:00
zxyszx cab71c0539 fix: make snapshot validation shellcheck-safe 2026-08-04 02:26:55 +08:00
zxyszx 6301182cfc feat: harden installer management menu 2026-08-04 02:23:46 +08:00
zxyszx 6d0147b639 docs: add NewSzxcn mailbox guide 2026-08-04 01:44:14 +08:00
zxyszx 497aefb903 fix: finalize v1.2.5 release notes
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 00:13:57 +08:00
zxyszx 1af4b7250e feat: link mailbox self-service setup guidance 2026-08-04 00:03:11 +08:00
zxyszx 1ce75ef241 fix: unify empty mailbox management action 2026-08-03 23:57:10 +08:00
zxyszx 99b8b7dee8 fix: guide admins to mailbox management 2026-08-03 23:52:42 +08:00
zxyszx 697ed236cc feat: add unified installer management menu 2026-08-03 23:29:02 +08:00
zxyszx b55c298ef4 feat: add safe fresh reinstall option 2026-08-03 23:23:41 +08:00
zxyszx 800d482c77 fix: prompt before modifying existing installs 2026-08-03 23:09:59 +08:00
zxyszx ccd5c4efd5 chore: prepare v1.2.4
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 22:44:01 +08:00
zxyszx da888234b9 feat: add guided mail server installer 2026-08-03 22:39:51 +08:00
zxyszx 2f7494e5e6 ui: rebalance system version dialog
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 20:41:50 +08:00
zxyszx 1350611908 ci: authenticate GHCR releases with package token 2026-08-03 20:12:57 +08:00
zxyszx 9a489992ed fix: harden runtime and remove placeholder features 2026-08-03 19:51:27 +08:00
zxyszx 18f8d870e8 feat: use usernames for administrator accounts
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 17:27:29 +08:00
zxyszx 6059954596 fix: honor custom HTTP bind in health checks 2026-08-03 16:49:33 +08:00
zxyszx 65bc16bd92 fix: support proxied deployments during updates 2026-08-03 16:47:29 +08:00
zxyszx 7eac123f0a chore: prepare v1.2.1 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 16:23:54 +08:00
zxyszx 550d40a023 ci: allow manual workflow dispatch 2026-08-03 16:23:17 +08:00
zxyszx 476f1250a3 chore: prepare v1.2.0 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 16:20:54 +08:00
zxyszx ea7d58b610 feat: add managed installation and system updates 2026-08-03 16:19:22 +08:00
云逸 fbbd126ef4 Update README.md 2026-08-03 15:56:27 +08:00
云逸 a1469da670 Update README.md to remove online address and HTML tags
Removed online address and some screenshot HTML tags from the README.
2026-08-03 15:55:58 +08:00
zxyszx 12eb9f7a7f fix: align rules page layout 2026-08-03 00:57:45 +08:00
zxyszx dd82a3b975 fix: compact stats dashboard layout 2026-08-03 00:37:05 +08:00
zxyszx a8170dfe4d fix: align mail statistics dashboard 2026-08-03 00:16:03 +08:00
zxyszx 92ff7dbfaf fix: remove login account placeholder 2026-08-02 23:53:35 +08:00
zxyszx a05c9db83c fix: align profile management UI 2026-08-02 23:19:01 +08:00
zxyszx 8b9250c2a7 fix: align compact bulk actions 2026-08-02 21:02:43 +08:00
zxyszx 969a9cb6c4 fix: rename rule forward targets 2026-08-02 20:49:52 +08:00
zxyszx 9edfee38ad fix: simplify rule forwarding form 2026-08-02 20:46:35 +08:00
zxyszx 72c96765ef feat: add rule forwarding action 2026-08-02 20:32:34 +08:00
zxyszx aa87315e94 fix: align forwarding dialog actions 2026-08-02 19:46:36 +08:00
zxyszx 8f85dc6482 fix: compact forwarding picker dropdown 2026-08-02 19:34:58 +08:00
zxyszx cfa84c39de feat: improve forwarding target picker 2026-08-02 19:10:34 +08:00
zxyszx 25cbd58e1f feat: support multiple forwarding targets 2026-08-02 18:33:25 +08:00
zxyszx b8632feb86 docs: add project screenshots 2026-08-02 17:40:38 +08:00
zxyszx 89e46b5748 feat: add login name migration 2026-08-02 16:56:04 +08:00
zxyszx 0d55200485 feat: support username admin login 2026-08-02 16:43:54 +08:00
zxyszx 9b59629993 feat: align NewSzxcn admin experience 2026-08-02 15:07:16 +08:00
zxyszx 7c6b0838b5 fix: brand webmail as NewSzxcn 2026-08-02 13:09:51 +08:00
zxyszx 2fffb27033 fix: align mailbox switcher dropdown width 2026-08-02 12:51:09 +08:00
zxyszx 704b976812 fix: remove unused mail toolbar icons 2026-08-02 12:43:19 +08:00
zxyszx 39f783d3b0 fix: match seek mail list width 2026-08-02 12:34:44 +08:00
zxyszx c7be61d3f0 fix: prevent stale webmail shell cache 2026-08-02 12:25:21 +08:00
zxyszx a46f375816 fix: lock mail list to seek width 2026-08-02 12:10:37 +08:00
zxyszx 4ab38c6c69 fix: keep desktop mail panes at seek width 2026-08-02 12:06:25 +08:00
zxyszx e9806b4a2a fix: tighten unread states and mail width 2026-08-02 11:42:21 +08:00
zxyszx 50d7c091bf fix: align mailbox unread and bulk actions 2026-08-02 11:23:44 +08:00
zxyszx 21be227d7b fix: match seek mail layout responsiveness 2026-08-02 10:44:29 +08:00
zxyszx 68bcce8091 fix: tighten mail layout responsiveness 2026-08-02 10:03:34 +08:00
zxyszx 83ca0dd7d7 fix: keep forwarding verification links idempotent 2026-08-02 09:36:30 +08:00
zxyszx 9f330fdf57 feat: require verification for forwarding targets 2026-08-02 09:08:20 +08:00
zxyszx 7f05a70f60 feat: add personal mailbox forwarding 2026-08-02 07:37:07 +08:00
zxyszx 77a0fd254c docs: use Simplified Chinese README 2026-08-02 06:03:59 +08:00
zxyszx 5141917d93 feat: customize NodeSeek-style webmail 2026-08-02 05:55:53 +08:00
LanQin_ 47f782a03c feat(api): 升级开放 API 并支持投递回调
- 为 `/api/open/v1` 引入 Token scope、分页游标、幂等发信、发送事件与重试/取消能力。
- 新增投递事件签名回调与状态 webhook outbox,补充相关配置、迁移和测试。
- 同步更新 Web 端 API 类型、个人中心 Token 权限管理,以及中英文文档和 OpenAPI 契约。
2026-07-10 10:47:58 +08:00
LanQin_ 25f54bc42f docs(API): 补充开放 API 文档细节
- 增加 Base URL、HTTP 状态码与错误响应说明。
- 细化鉴权、权限矩阵以及各接口的请求/响应字段。
- 补充域名、邮箱、发信和收信接口的行为约束与分页规则。
2026-07-01 14:09:29 +08:00
LanQin_ 3afbdc4d4a feat(open_api): 统一开放接口命名并记录来源
- 将公共 API 相关处理器、路由与测试重命名为 Open API,统一接口语义。
- 新增发送来源 open_api,并在发送审计与队列中保存该来源。
- 前端发送队列来源标签新增 Open API 显示。
2026-06-29 16:52:07 +08:00
LanQin_ cb07d5d501 fix(api): 修复 API Token 过期校验与更新校验
- 将数据库中的 expires_at 设为必填,并在认证时仅允许未过期的令牌通过
- 增加空过期时间的更新校验,避免写入非法时间值
- 补充过期令牌与空过期时间更新的测试覆盖
- 调整前端日期输入与提交逻辑,避免创建 API Token 时表单异常中断
2026-06-29 16:09:40 +08:00
LanQin_ f87ce5fa4f feat(api): 新增开放 API 与 Token 管理
- 新增 API Token 的创建、查询、更新和撤销接口,并支持 Bearer 认证
- 新增 `/api/open` 域名、邮箱、发信与消息查询接口,补充权限校验
- 更新数据库迁移、路由与测试,并新增开放 API 文档
2026-06-29 15:52:42 +08:00
LanQin_ eb8b999ce5 ci(workflows): 扩展 CI 触发时的忽略路径
- 为 push 和 pull_request 事件补充 docs、Issue 模板、发布配置、AI 评审忽略文件和 LICENSE 等路径的忽略规则。
- 同步为 AI PR Review 工作流增加相同的路径忽略,减少无关变更触发。
2026-06-29 14:51:16 +08:00
LanQin_ 6a30f8ac60 chore(.github/ISSUE_TEMPLATE): 删除过时的 Issue 模板
- 移除 bug 报告和功能建议模板,精简仓库中的 GitHub Issue 配置。
2026-06-29 14:43:03 +08:00
LanQin_ d2901dd51f Update issue templates 2026-06-29 14:24:41 +08:00
LanQin a05a7ee503 ci(.github/workflows): 更新 Docker 发布工作流环境
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 将 Node.js 版本升级到 24。
- 在解析发布版本时补充去除前导点和连字符,避免生成的镜像版本号异常。
2026-06-27 15:50:09 +08:00
LanQin ecc3e8a246 feat(mail): 支持邮件正文 HTML 翻译并切换显示译文
- 后端翻译接口新增返回 translatedHtml,优先翻译邮件 HTML 文本节点并跳过脚本、样式等标签。
- 前端合并翻译结果展示,支持在原文与译文之间切换,并直接渲染翻译后的邮件 HTML。
2026-06-27 15:35:41 +08:00
LanQin ed68abb00d feat(mail): 支持外部 IMAP 邮件翻译
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 新增外部账号邮件翻译接口与路由。
- 前端翻译请求优先走外部账号邮件翻译接口。
2026-06-27 13:30:31 +08:00
LanQin b8d4553036 feat(mail): 增加邮件翻译功能
- 新增邮件翻译接口与响应类型,支持按目标语言翻译当前邮件内容。
- 配置化控制翻译开关与最大字符数,避免超长内容影响请求。
- 在邮件详情页增加翻译提示与译文切换,支持简体中文、繁體中文和英文。
- 补充翻译响应解析与截断逻辑的单元测试。
2026-06-27 13:22:45 +08:00
LanQin 587bd77f6a docs(readme): 补充中文文档并更新英文说明
- 新增 `README.zh-CN.md`,完善简体中文使用说明。
- 重写 `README.md` 为英文版,并补充中英文入口链接。
- 统一整理功能特性、部署说明、环境变量和架构介绍。
2026-06-27 01:38:48 +08:00
LanQin ef7af15010 i18n(web): 新增界面语言切换与自动翻译支持
- 新增语言状态管理、存储同步与 DOM 自动翻译能力,支持简体中文、繁體中文和英文。
- 在邮件页加入语言切换入口,并接入全局语言同步。
- 补充大量邮件界面文案翻译映射,提升多语言可用性。
2026-06-27 01:36:15 +08:00
LanQin bce259ca8a feat(mail): 优化外部邮箱侧边栏交互
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 支持外部邮箱账号展开/收起,并在切换时自动保持当前账号展开。
- 增加外部邮箱显示主标题与副标题,提升账号信息可读性。
- 在外部 IMAP 关闭或账号列表变化时,清理已展开状态。
2026-06-25 22:50:32 +08:00
LanQin 707f11687d feat(external-imap): 新增外部 IMAP 全局开关与后台配置
- 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。
- 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。
- 补充外部 IMAP 默认关闭、配置说明与回调地址文档。
2026-06-25 22:44:32 +08:00
LanQin cc75382869 fix(external_imap): 修复 IMAP 文件夹列表兼容性
- 在测试外部 IMAP 账号时,将文件夹列表失败提示改为更准确的错误信息。
- 为 OAuth XOAUTH2 认证和文件夹列表补充更明确的错误包装,便于定位问题。
- 对不支持 `LIST RETURN (STATUS ...)` 的 Outlook/Exchange 服务降级为普通 `LIST`,再单独查询文件夹状态。
2026-06-25 22:25:30 +08:00
LanQin efcdc691de refactor(app): 优化后台任务退出与外部 IMAP 认证
- 统一用 `WaitGroup` 管理后台 worker,关闭时等待任务结束后再释放数据库连接。
- 将外部 IMAP 的 OAuth 认证切换为 `XOAUTH2` 客户端实现,并补充对应格式测试。
- 调整发送队列的故障恢复逻辑,超时中断后恢复为排队状态以便重新投递。
2026-06-25 21:36:59 +08:00
LanQin 081cfad02d feat(external_imap): 强化 OAuth 外部邮箱授权校验并完善 Outlook 文案
- OAuth 授权改为以服务商返回的真实邮箱为准,补充 ID Token 解析与邮箱一致性校验,避免默认落到本地邮箱。
- 为 Gmail 和 Microsoft 365 / Outlook OAuth 增加必要的 OIDC scope,并补充相关测试覆盖。
- Web 端新增 OAuth 授权弹窗,支持填写外部邮箱与存储模式;同步更新账号展示信息。
- 同步修正 README 与部署说明中的 Outlook 表述。
2026-06-25 21:17:04 +08:00
LanQin_ 39f5249008 feat(mail): 增强外部 IMAP 接入能力。
- 新增 Gmail/Outlook OAuth2 授权接入、附件分片下载、服务端搜索与单文件夹同步历史。
- 扩展外部 IMAP 账户与同步任务数据结构,补充相关路由、配置和前端管理界面。
- 更新部署示例与文档,说明 OAuth 回调地址及后续邮件系统规划。
2026-06-25 20:20:37 +08:00
LanQin_ 11734bf119 feat(mail): 支持外部 IMAP 账号接入。
- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。
- Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。
- 补充相关配置项、环境变量说明和使用文档。
2026-06-25 17:09:46 +08:00
LanQin_ 2558aa96ed refactor(mail): 统一右键菜单按钮样式
- 将邮件页右键菜单中的原生 `button` 替换为 `Button` 组件。
- 抽取通用按钮样式,统一打开、刷新、移动、标签等菜单项表现。
- 保持删除等危险操作的视觉强调一致。
2026-06-25 16:17:56 +08:00
LanQin_ 98e7190512 feat(mail): 增强发送队列筛选与投递追踪能力
- 发送队列支持按 Message-ID、收件人和时间范围筛选,并改为稳定游标分页。
- 邮件详情补充关联的发送队列信息,支持从邮件直接查看投递时间线。
- 前端同步接入新筛选条件,并在发送队列页提供清除筛选入口。
2026-06-25 16:13:44 +08:00
LanQin_ 4ab7815886 feat(mail): 支持删除自定义文件夹并回收邮件
- 后端新增文件夹删除接口,禁止删除系统文件夹,并将文件夹内邮件自动移回收件箱。
- 前端补充删除文件夹的 API 调用、侧边栏菜单入口和确认弹窗。
- 增加测试覆盖删除自定义文件夹后的邮件回收与系统文件夹保护。
2026-06-25 15:56:57 +08:00
LanQin_ 361daf8693 feat(mail): 为侧边栏添加右键菜单操作
- 支持对侧边栏条目打开、刷新和新建文件夹。
- 为自定义文件夹增加置顶、上移、下移、置底操作。
2026-06-25 15:50:39 +08:00
LanQin_ d2a762a426 feat(mail): 支持自定义邮件文件夹排序
- 后端为 `folders` 增加 `sort_order`,并提供文件夹重排接口。
- 前端邮件侧边栏支持自定义文件夹拖拽排序与乐观更新。
- 补充文件夹排序相关测试,并同步更新 API 类型定义。
2026-06-25 15:42:36 +08:00
LanQin_ 7799d5d5b2 feat(mail): 支持创建自定义文件夹与邮件右键操作
- 新增文件夹创建接口与服务端校验,支持创建自定义文件夹并在列表中使用。
- 为邮件列表补充右键菜单,支持打开、回复、转发、标记、归档、移动、打标签和删除。
- 规则编辑中的“移动到”动作支持自定义文件夹名称输入。
- 补充相关测试,覆盖文件夹创建与邮件移动流程。
2026-06-25 14:11:38 +08:00
LanQin_ 07054ca342 refactor(mail): 精简邮件通知与元信息展示
- 点击新邮件提示可直接打开首封新邮件,并兼容键盘回车与空格操作。
- 移除邮件详情中的认证结果展示及相关类型定义,简化消息元信息结构。
2026-06-25 11:42:11 +08:00
LanQin_ 84657f91f9 fix(toast): 修复 Toast 点击交互
- 为可点击的 Toast 添加指针样式,提升交互反馈。
- 阻止关闭按钮点击事件冒泡,避免触发外层点击逻辑。
2026-06-25 11:30:37 +08:00
LanQin a5f5adf752 chore: 合并 Maildir 修复与 README 更新 2026-06-24 19:55:43 +08:00
LanQin_ 947e3ad248 fix(app): 重建仅含 HTML 邮件摘要
- 在迁移流程中新增仅含 HTML 消息摘要重建,修正旧数据中摘要保留样式内容的问题。
- 扩展 `snippetFrom` 的清理逻辑,过滤 `style`、`script`、`head`、`title`、`noscript` 等非内容标签。
- 补充相关测试,覆盖样式内容被剔除后的摘要重建行为。
2026-06-24 17:34:41 +08:00
LanQin_ 2a572bf13a feat(mail): 增强邮件认证与审计能力
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 新增邮件认证结果解析、存储与前端展示,支持查看 SPF、DKIM、DMARC 及原始头信息。
- 扩展邮件规则条件,支持抄送、附件、大小、日期及嵌套条件组合。
- 增加邮箱容量统计与发送前配额校验,超限时返回明确错误。
- 新增管理端发送审计页面与接口,支持按邮箱、事件、Message-ID 和时间筛选。
2026-06-24 17:16:01 +08:00
LanQin_ 2b846ac671 feat(mail): 增加发送队列管理
- 新增发送队列与投递时间线接口,支持查看、筛选、重试和取消发送任务。
- 扩展邮箱页面,加入发送队列入口、状态筛选和时间线弹窗。
- 更新发送队列状态流转,支持 `canceled` 并补充相关测试与前端类型定义。
2026-06-24 16:39:40 +08:00
LanQin_ b3669f189e feat(imap): 补充邮件 IMAP 元数据支持
- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。
- 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。
- 扩展后端与前端类型,并补充相关测试覆盖。
2026-06-24 16:23:45 +08:00
LanQin_ d28ed4adcc feat(admin): 增加 Maildir 同步健康状态
- 新增后端健康检查接口与同步追踪,记录运行状态、最近结果、错误摘要和统计信息。
- 前端管理页在存储设置中展示 Maildir 同步健康卡片,并支持手动刷新。
- 补充相关类型定义与测试,覆盖未配置和同步成功两类场景。
2026-06-24 16:08:48 +08:00
LanQin_ b36adfdac2 fix(maildir): 同步 IMAP 移动、标记与删除状态
- 让 maildir 同步在消息移动到其他文件夹时更新 `folder_id` 和 `raw_path`。
- 根据文件名中的 maildir 标记同步已读、星标状态。
- 增加缺失消息清理,避免数据库残留已被删除的 maildir 邮件。
- 补充回归测试覆盖移动、重复副本、标记更新和删除场景。
2026-06-24 15:46:17 +08:00
LanQin_ 632a8a4896 fix(maildir): 同步邮件已读状态到 maildir 标记
- 读取邮件时同步更新 maildir 的 `S` 标记,避免数据库已读状态与文件名不同步。
- 写入邮件到 `maildir` 时保留已有非托管标记,并确保已读/星标标记正确拼接。
- 补充测试,校验已发送邮件和草稿的原始路径包含已读标记。
2026-06-24 15:33:56 +08:00
LanQin_ f8d058f7e4 fix(maildir): 同步消息标记到 Maildir
- 标记已读、加星时同步重写 `raw_path`,保持数据库与文件系统状态一致。
- 新增 Maildir 标记路径计算与迁移逻辑,支持读/星标变化后调整目录和文件名。
- 补充测试覆盖已读、未读、加星场景的 Maildir 路径变化。
2026-06-24 15:27:30 +08:00
LanQin_ 1788d49a59 feat(maildir): 支持邮件写入与同步联动
- 为已发送邮件、草稿、收件与规则移动增加 maildir 文件写入、重写和删除流程。
- 补充 SQLite 仅有消息的回填逻辑,并在同步时补齐现有 raw_path。
- 调整 Dovecot 共享邮箱行为为订阅方式,避免重复创建文件夹。
- 新增覆盖发送、草稿、移动、删除和回填场景的测试。
2026-06-24 15:18:06 +08:00
LanQin_ adcc822c9a fix(mail): 优化邮件 HTML 与样式保留
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 支持整页邮件文档的清洗与渲染,保留 `<html>`、`<head>`、`<body>` 和 `<style>` 等结构。
- 提取并校验安全的邮件样式块,阻止包含外链、脚本式内容的 CSS。
- 补充 `dompurify` 类型声明以支持 `WHOLE_DOCUMENT` 配置。
2026-06-24 14:19:31 +08:00
LanQin_ 18db36d937 feat(mail): 优化邮件正文渲染与样式保留
- 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。
- 扩展前端清洗配置,保留邮件常见布局样式与表格属性。
- 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。
- 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。
2026-06-24 13:55:09 +08:00
LanQin_ 295a34881d Merge pull request #5 from LanQin996/feature/lanqin-submission
Feature/lanqin submission
2026-06-24 13:37:09 +08:00
LanQin_ 65e4c4f6b5 fix(send_queue): 修复已投递标记恢复逻辑
- 新增投递标记文件持久化,避免重启后重复发送已投递的队列项。
- 在恢复卡住的发送任务时,识别已存在的投递标记并直接修复数据库状态。
- 同步补充回归测试,覆盖陈旧投递标记不应触发重发的场景。
2026-06-24 13:35:37 +08:00
LanQin_ d3bee62acd refactor(submission): 重构发件入库的去重流程。
- 将发件消息插入与附件保存改为事务内执行,避免去重键存在但消息缺失时产生脏数据。
- 抽出可注入的数据库执行器,便于在同一事务中复用消息与附件写入逻辑。
- 补充异常场景测试,覆盖“去重键存在但未命中消息”的失败行为。
2026-06-24 13:26:53 +08:00
LanQin_ d1876691dd fix(submission): 调整 SMTP 提交与发件授权逻辑。
- 启用 SMTP submission 时改为强制使用可读证书文件,并禁止使用容器自带测试证书。
- 收紧发件人校验,拒绝未授权的 From 地址,并支持别名多目标授权。
- 优化发送队列重入与重复消息处理,避免重复记录冲突。
- 让提交证书按需热加载,并稳定 MIME 头序列化顺序。
- 更新部署示例与文档,明确 submission 的证书配置要求。
2026-06-24 13:10:24 +08:00
LanQin_ 2476fe0c19 fix(send_queue): 修复重复消息 ID 的失败重排并清理 MIME
- 投递成功后清空 `mime_base64`,避免已投递队列项保留原始 MIME。
- 当同一 `message_id` 的旧队列项已达到最大失败次数时,允许复用并重新入队。
- 补充测试覆盖已投递项清理与终态失败重排场景。
2026-06-24 11:33:40 +08:00
LanQin_ 7b0717412c feat(app): 引入发送队列与可授权发件人
- Webmail/API 与第三方提交统一先写入已发送,再入发送队列异步投递。
- 新增队列重试、失败审计、陈旧任务恢复与发送状态追踪。
- 支持本人邮箱、启用别名和 `send_as_grants` 显式授权的 send-as。
- 更新 README 与部署文档,说明新的外发流程和排障方式。
2026-06-24 11:27:32 +08:00
LanQin_ 5c9bea075a chore(deploy): 优化 split stack 的 SMTP 配置
- 为 `docker-compose.stack.yml` 的 API 容器默认注入 `LANQIN_SMTP_HOST/PORT`,避免 Webmail 发信配置漂移。
- 补充 `.env.example` 和 `README`,说明 split stack 默认走 `postfix`,以及外部 SMTP 的覆盖方式。
- 新增 `sent_message_dedupe_keys` 表和相关读写逻辑,确保已发送消息去重可回收,并补充对应测试。
2026-06-24 11:03:01 +08:00
LanQin_ fa450c9f9b feat(mail): 支持 SMTP Submission 由 API 接管
- 新增 `465/587` 提交服务与 TLS 配置,支持 StartTLS 和隐式 TLS。
- 提交时校验认证邮箱、权限和 `From` 一致性,先写入 `Sent` 再转发到后端 SMTP。
- 增加 `Message-ID` 去重与失败回滚,避免重复保存发送副本。
- 调整部署与文档,移除 Postfix 的 submission 监听,改由 LanQin API 对外提供提交端口。
2026-06-24 10:47:16 +08:00
LanQin_ f2457c63ee refactor(mail): 统一邮件认证策略请求与返回格式。
- 将 `auth-policy` 调整为读取 `login`、`remote` 等字段,并返回数值型 `status`。
- 支持按邮箱定位用户与邮箱箱体,补充 IMAP/POP3 认证策略校验。
- 更新 Dovecot 与 Postfix 配置,移除原有的发件人 BCC 依赖。
- 补充相关测试并同步部署说明。
2026-06-23 21:52:05 +08:00
LanQin_ 8d434b9ca8 chore(app): 修正 maildir 目录配置读取
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 启动 maildir worker 时改为读取 `a.cfg.MaildirRoot`,避免使用未确认的入参值。
2026-06-23 15:32:12 +08:00
LanQin_ ce9d705df0 chore(deploy): 统一初始化 Dovecot 认证策略随机数
- 在部署入口脚本中生成并持久化 `auth_policy_hash_nonce`,避免每次启动变更。
- 同步更新 Dovecot 配置项为 `auth_policy_request_attributes`,并注入随机数值。
2026-06-23 15:07:15 +08:00
LanQin_ b309368225 feat(admin): 增加版本更新检查
- 在关于页检查 GitHub 最新发布版本。
- 发现新版本时提示并提供跳转查看链接。
2026-06-23 14:07:20 +08:00
LanQin_ 1373479973 chore(dovecot): 优化邮箱别名与默认文件夹创建
- 添加 `recipient_delimiter = +` 以支持加号别名。
- 为 Drafts、Sent、Trash、Archive、Spam 配置自动创建。
2026-06-23 11:32:14 +08:00
LanQin_ 23b04bd343 feat(mail): 增强邮件限流与配额配置
- 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。
- 暴露默认权限限额接口,前端权限组页面改为动态读取。
- 为 Dovecot 配置用户配额、连接数限制和 auth policy。
2026-06-23 11:25:38 +08:00
LanQin_ 36b4c4c60f feat(权限组): 增加账号配额与邮件发送限制
- 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。
- 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。
- 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。
2026-06-23 11:09:36 +08:00
LanQin_ 34d35e8f04 Merge pull request #4 from killerprojecte/main
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
fix: delete label without mailboxId caused 404 error on non-default mailbox
2026-06-22 21:11:23 +08:00
killerprojecte 2d7625a751 Merge branch 'main' of https://github.com/killerprojecte/LanQin-Email 2026-06-22 21:08:33 +08:00
killerprojecte 3eebc1c358 fix: delete label without mailboxId caused 404 error on non-default mailbox 2026-06-22 21:08:11 +08:00
LanQin b320fee622 docs(README): 重写项目说明与部署文档
- 更新技术栈徽标与项目简介,补充 TypeScript、Dovecot 等信息。
- 重构功能特性、目录结构、环境要求、快速开始和首次部署清单。
- 补充关键环境变量、架构说明、邮件流转、开发验证与生产注意事项。
2026-06-22 20:50:06 +08:00
LanQin_ 67ced8b3f8 Merge pull request #3 from killerprojecte/main
feat: better tag system & fix layout problem when width under 640px
2026-06-22 20:32:10 +08:00
AnserJim 0b4b4441c0 chore: use activeMailboxId instead of selectedMailboxId
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-22 20:23:20 +08:00
killerprojecte c8dff253cc chore: optimize code 2026-06-22 20:20:44 +08:00
killerprojecte 77187e76ce fix: wrong code syntax 2026-06-22 20:12:56 +08:00
killerprojecte 84cc47e3cf chore: revert some wrong changes 2026-06-22 20:04:11 +08:00
killerprojecte 8d29dbd126 chore: improve label status handling 2026-06-22 20:04:11 +08:00
killerprojecte 94ab94eceb chore: improve label error handling 2026-06-22 20:04:10 +08:00
killerprojecte 74f456ef7d chore: improve tag delete 2026-06-22 20:04:10 +08:00
killerprojecte 7720b6b404 feat: better tag system & fix layout problem when width under 640px 2026-06-22 20:04:09 +08:00
LanQin_ 0b982a5ff4 feat(mail): 重构邮箱权限体系并细化前台能力控制
- 新增邮箱前台权限与默认普通用户权限迁移,拆分读信、发信、草稿、定时、整理、标签、附件、联系人等能力。
- 收紧后端路由与处理逻辑,按权限限制邮箱前台、个人中心和定时发送相关接口。
- 前端根据权限动态隐藏或禁用邮件、个人中心中的对应功能入口与操作。
- 补充权限迁移与访问控制测试,覆盖普通用户邮箱前台、发信和定时发送的权限校验。
2026-06-22 15:54:15 +08:00
LanQin_ 5571dcc511 fix(app): 清理旧版系统权限组
- 将旧版默认权限组 ID 纳入清理列表,确保初始化时自动删除遗留数据。
- 补充测试验证旧版权限组会在默认权限组修复流程中被移除。
2026-06-22 15:02:25 +08:00
LanQin_ acdf298811 fix(permission): 调整默认权限组与用户权限继承
- 将“普通用户”权限组纳入默认权限继承,创建和更新用户时自动合并其权限。
- 放宽“普通用户”系统组的可编辑性,但仍禁止删除;继续限制超级管理员组被修改。
- 更新管理端权限组操作状态,避免对不可删除或不可编辑项提供错误入口。
- 补充相关测试,覆盖默认权限组编辑、删除与用户权限继承场景。
2026-06-22 14:42:37 +08:00
LanQin_ 8cbcc22a74 refactor(permissions): 精简默认权限组初始化逻辑
- 将固定默认权限组收敛为基础组,并新增对历史默认权限组的清理流程。
- 兼容保留已被用户引用的旧权限组,避免误删现有授权关系。
- 更新测试以覆盖新权限组创建与旧权限组迁移场景。
2026-06-22 14:34:57 +08:00
LanQin_ 31435c1e97 feat(admin): 引入细粒度权限组管理
- 新增权限目录、系统权限组与用户权限组分配,后台路由改为按权限粒度控制。
- 支持创建、编辑、查看和删除自定义权限组,并为用户绑定权限组。
- 前端后台界面按权限动态展示菜单与操作项,补充用户/权限组相关类型与交互。
2026-06-22 14:31:10 +08:00
LanQin_ e35b0985ee ci(.github/workflows): 扩展 AI PR Review 触发方式
- 新增 `issue_comment` 和 `workflow_dispatch` 触发入口,支持通过评论或手动指定 PR 运行审查。
- 调整并发键与权限配置,兼容多事件场景并补充 `issues: write`。
- 收紧执行条件,仅在仓库内 PR、手动触发或授权评论指令下执行,并按事件类型动态注入 `PR_NUMBER` 与 `PR_HEAD_SHA`。
2026-06-22 14:12:48 +08:00
LanQin 002c097def ci(.github): 自动生成并发布 GitHub Release
- 新增 `.github/release.yml`,统一分类发布说明中的变更类型。
- 扩展 Docker 发布工作流,生成基于 tag 的发布说明并创建或更新 GitHub Release。
- 同步补充镜像列表、使用文档和回退逻辑,提升发布信息完整性。
2026-06-21 02:49:06 +08:00
LanQin e7e4f43691 ci(.github/workflows): 拆分 CI 与发布流程。
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
- 新增独立的 CI 工作流,覆盖前端检查、构建和后端测试。
- 调整 Docker 工作流为仅负责标签发布与镜像构建。
- 同步优化管理页 Issues 图标颜色。
2026-06-21 02:30:01 +08:00
LanQin c872ed559d feat(admin): 增加关于页与发布版本信息
- 在系统设置中新增“关于”标签页,提供版本、仓库、Issues、文档和开源协议入口。
- 通过 Docker 构建参数注入版本号与发布链接,并在前端展示当前发布版本。
- 调整 GitHub Actions 与镜像构建流程,仅在 tag 发布时构建并发布带版本标识的镜像。
2026-06-21 02:23:40 +08:00
127 changed files with 30218 additions and 2228 deletions
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
deploy/**/entrypoint.sh text eol=lf
deploy/**/sync-dkim.sh text eol=lf
+48
View File
@@ -0,0 +1,48 @@
name: Bug report / Bug 报告
description: Report something that is not working as expected. / 报告异常或不可用的问题。
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Please write in Chinese or English.
你可以使用中文或英文填写。
Please do not include passwords, tokens, API keys, verification codes, session cookies, private keys, real mailbox lists, or unreleased security vulnerability details.
请不要粘贴密码、Token、API Key、验证码、Session Cookie、私钥、真实邮箱列表,或未公开安全漏洞细节。
- type: textarea
id: details
attributes:
label: Details / 详情
description: What happened? What did you expect instead? / 发生了什么?你原本期望什么?
placeholder: "Example / 示例:发送邮件失败,页面提示 500。"
validations:
required: true
- type: input
id: version
attributes:
label: Version / 版本
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
placeholder: "v1.2.3"
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment / 环境(可选)
description: Add browser, OS, deployment mode, or related config only if helpful. / 如果有帮助,可以补充浏览器、系统、部署方式或相关配置。
placeholder: |
Browser and OS:
Deployment: Docker / local / not sure
- type: textarea
id: logs
attributes:
label: Logs, screenshots, or steps / 日志、截图或复现步骤(可选)
description: Redact secrets and personal data before posting. / 粘贴前请先隐藏敏感信息和个人数据。
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability / 安全漏洞
url: https://github.com/zxyszx/NewSzxcn-Email/security/advisories/new
about: Please report security vulnerabilities privately. / 请通过私密渠道报告安全漏洞,不要公开提交漏洞细节。
- name: Community chat / 社区交流
url: https://t.me/+EhII7MSyi3QwNDQ5
about: For informal discussion and quick usage questions. / 适合非正式交流和快速使用问题。
@@ -0,0 +1,50 @@
name: Deployment or configuration / 部署或配置问题
description: Get help with Docker, domains, DNS, SMTP, IMAP, or runtime configuration. / 获取 Docker、域名、DNS、SMTP、IMAP 或运行配置帮助。
title: "[Deploy]: "
labels: ["question"]
body:
- type: markdown
attributes:
value: |
Please write in Chinese or English.
你可以使用中文或英文填写。
Please do not include real passwords, tokens, API keys, private keys, full mail headers with personal data, or production mailbox lists.
请不要粘贴真实密码、Token、API Key、私钥、包含个人数据的完整邮件头,或生产邮箱列表。
- type: textarea
id: details
attributes:
label: Details / 详情
description: What are you trying to do, and what failed? / 你想完成什么?哪里失败了?
placeholder: "Example / 示例:Docker Compose 启动后 Web 可以打开,但 SMTP 587 连接失败。"
validations:
required: true
- type: input
id: version
attributes:
label: Version / 版本
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
placeholder: "v1.2.3"
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment and config / 环境与配置(可选)
description: Add OS, Docker version, DNS status, or sanitized `.env` lines only if helpful. / 如果有帮助,可以补充系统、Docker 版本、DNS 状态或脱敏后的 `.env` 行。
placeholder: |
OS:
Docker / Compose:
Domain or DNS status:
Related config, redacted:
- type: textarea
id: logs
attributes:
label: Logs or screenshots / 日志或截图(可选)
description: Redact secrets, mailbox addresses, Message-ID values, and IPs when needed. / 请按需隐藏密钥、邮箱地址、Message-ID 和 IP。
@@ -0,0 +1,20 @@
name: Feature request / 功能建议
description: Suggest an improvement for LanQin Email. / 提出一个改进建议。
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Please write in Chinese or English.
你可以使用中文或英文填写。
- type: textarea
id: details
attributes:
label: Details / 详情
description: What do you want to improve, and why? / 你想改进什么?为什么?
placeholder: "Example / 示例:希望管理员可以批量创建邮箱,因为目前一个个添加比较慢。"
validations:
required: true
+31
View File
@@ -0,0 +1,31 @@
name: Question or usage help / 使用问题
description: Ask a usage question that is not clearly a bug or feature request. / 提交不明显属于 Bug 或功能建议的使用问题。
title: "[Question]: "
labels: ["question"]
body:
- type: markdown
attributes:
value: |
Please write in Chinese or English.
你可以使用中文或英文填写。
Please do not include secrets, private mailbox data, or full production logs.
请不要包含密钥、私人邮箱数据或完整生产日志。
- type: textarea
id: details
attributes:
label: Details / 详情
description: What are you trying to do, and where are you stuck? / 你想完成什么?卡在哪里?
placeholder: "Example / 示例:如何给第三方客户端配置 SMTP 和 IMAP"
validations:
required: true
- type: input
id: version
attributes:
label: Version / 版本(可选)
description: Add the LanQin Email version if relevant. / 如相关,可以补充 LanQin Email 版本。
placeholder: "v1.2.3"
+13
View File
@@ -0,0 +1,13 @@
## 本次更新
### 优化邮箱选择器动态宽度
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
+13
View File
@@ -0,0 +1,13 @@
## 本次更新
### 移除“加载邮箱”过渡文案
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
+33
View File
@@ -0,0 +1,33 @@
## 本次更新
### 修复文件夹管理
- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。
- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。
- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。
### 修复导入邮件的存储显示
- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`
- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。
- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。
### 改进邮件下载
- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。
- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。
- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。
### 优化设置与后台切换
- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。
- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。
- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。
### 验证
- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
- 已通过安装脚本语法和自动化回归测试。
- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。
**完整更新日志**[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12)
+33
View File
@@ -0,0 +1,33 @@
## 本次更新
### 一键部署与运维
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
### 邮箱与账号体验
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
### 自助申请邮箱
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
- 管理员可通过“前往设置”直接进入后台邮件设置页。
- 普通用户无法自行申请时会提示联系管理员处理。
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
- 补充简体中文、繁体中文和英文界面文案。
### 兼容与验证
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
**完整更新日志**[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
+36
View File
@@ -0,0 +1,36 @@
## 本次更新
### 邮箱管理跳转
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
### 管理员密码恢复
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
### 一键安装与运维安全
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
### 邮箱指南与快捷命令
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
### 兼容与验证
- 本次更新不删除现有邮件、域名、账号、证书和配置。
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
**完整更新日志**[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
+21
View File
@@ -0,0 +1,21 @@
## 本次更新
### 修复网页更新出现 502
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
- 前端遇到更新期间的 `502``503``504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
### 更新说明
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
-`v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
### 验证
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
+18
View File
@@ -0,0 +1,18 @@
## 本次更新
### 修复邮箱选择器默认状态
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
### 修复邮箱下拉菜单宽度
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
+25
View File
@@ -0,0 +1,25 @@
## 本次更新
### 修复邮件导入 413
- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。
- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。
- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。
### 支持大批量导入
- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。
- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。
- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。
### 保留历史邮件时间
- 导入时继续保留邮件头中的原始 `Date` 时间。
- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。
- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。
### 验证
- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9)
+34
View File
@@ -0,0 +1,34 @@
changelog:
exclude:
labels:
- skip-changelog
- ignore-for-release
categories:
- title: 🚨 破坏性变更
labels:
- breaking-change
- breaking change
- title: 🔒 安全更新
labels:
- security
- title: ✨ 新功能
labels:
- feature
- enhancement
- title: 🐛 问题修复
labels:
- bug
- fix
- title: 📚 文档
labels:
- documentation
- docs
- title: 🧰 工程维护
labels:
- chore
- ci
- dependencies
- refactor
- title: 其他更新
labels:
- "*"
+35 -3
View File
@@ -5,18 +5,50 @@ on:
branches:
- main
types: [opened, synchronize, reopened]
paths-ignore:
- "**/*.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- ".github/release.yml"
- ".ai-reviewignore"
- "LICENSE"
- "deploy/.env.example"
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: ai-pr-review-${{ github.event.pull_request.number }}
group: ai-pr-review-${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ai-review:
name: AI PR Review
if: >
(
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
) ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(
contains(github.event.comment.body, '/ai-review') ||
contains(github.event.comment.body, '@ai-reviewer review')
) &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
)
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -50,8 +82,8 @@ jobs:
OPENAI_RETRIES: ${{ vars.OPENAI_RETRIES || '2' }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
REVIEW_RULES: .github/ai-review.md,AGENTS.md
REVIEW_IGNORE: .ai-reviewignore
REVIEW_SEVERITY_THRESHOLD: P3
+80
View File
@@ -0,0 +1,80 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- "**/*.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- ".github/release.yml"
- ".ai-reviewignore"
- "LICENSE"
- "deploy/.env.example"
pull_request:
branches:
- main
paths-ignore:
- "**/*.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- ".github/release.yml"
- ".ai-reviewignore"
- "LICENSE"
- "deploy/.env.example"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
name: Check web and api
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install web dependencies
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
- name: Check shadcn/ui usage
run: pnpm --dir apps/web run check:shadcn
- name: Build web
run: pnpm --dir apps/web run build
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: apps/api/go.mod
cache-dependency-path: apps/api/go.sum
- name: Test api
working-directory: apps/api
run: go test ./...
+165 -29
View File
@@ -1,25 +1,16 @@
name: CI and Docker
name: Docker Release
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "v*"
paths-ignore:
- "**/*.md"
- "deploy/.env.example"
pull_request:
branches:
- main
paths-ignore:
- "**/*.md"
- "deploy/.env.example"
workflow_dispatch:
permissions:
contents: read
contents: write
packages: write
pull-requests: read
issues: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -27,7 +18,6 @@ concurrency:
env:
REGISTRY: ghcr.io
PLATFORMS: linux/amd64
jobs:
checks:
@@ -37,6 +27,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -45,12 +43,12 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install web dependencies
run: pnpm install --frozen-lockfile --filter lanqin-email-web...
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
- name: Check shadcn/ui usage
run: pnpm --dir apps/web run check:shadcn
@@ -68,11 +66,35 @@ jobs:
working-directory: apps/api
run: go test ./...
release:
name: Resolve release tag
runs-on: ubuntu-latest
needs: checks
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
release_url: ${{ steps.release.outputs.release_url }}
steps:
- name: Resolve tag version
id: release
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
version="${tag#v}"
version="${version#.}"
version="${version#-}"
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}"
{
echo "tag=${tag}"
echo "version=${version}"
echo "release_url=${release_url}"
} >> "$GITHUB_OUTPUT"
echo "::notice::Docker release tag: ${tag}"
docker:
name: Build and publish ${{ matrix.name }}
runs-on: ubuntu-latest
needs: checks
if: github.event_name != 'pull_request'
needs: release
strategy:
fail-fast: false
matrix:
@@ -81,49 +103,60 @@ jobs:
suffix: ""
context: .
file: ./deploy/all-in-one/Dockerfile
platforms: linux/amd64,linux/arm64
- name: api
suffix: -api
context: .
file: ./deploy/api.Dockerfile
platforms: linux/amd64,linux/arm64
- name: web
suffix: -web
context: .
file: ./deploy/web.Dockerfile
platforms: linux/amd64,linux/arm64
- name: postfix
suffix: -postfix
context: ./deploy/postfix
file: ./deploy/postfix/Dockerfile
platforms: linux/amd64
- name: dovecot
suffix: -dovecot
context: ./deploy/dovecot
file: ./deploy/dovecot/Dockerfile
platforms: linux/amd64
- name: rspamd
suffix: -rspamd
context: ./deploy/rspamd
file: ./deploy/rspamd/Dockerfile
platforms: linux/amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
- name: Prepare image name
id: image
shell: bash
run: |
image="${REGISTRY}/${GITHUB_REPOSITORY}${{ matrix.suffix }}"
echo "name=${image,,}" >> "$GITHUB_OUTPUT"
{
echo "name=${image,,}"
echo "tag=${{ needs.release.outputs.tag }}"
echo "version=${{ needs.release.outputs.version }}"
} >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
@@ -131,19 +164,122 @@ jobs:
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=tag
type=raw,value=${{ steps.image.outputs.tag }}
type=raw,value=${{ steps.image.outputs.version }}
type=raw,value=latest
type=sha,prefix=sha-
labels: |
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: ${{ env.PLATFORMS }}
platforms: ${{ matrix.platforms }}
push: true
build-args: |
APP_VERSION=${{ needs.release.outputs.tag }}
APP_COMMIT=${{ github.sha }}
VITE_APP_VERSION=${{ needs.release.outputs.tag }}
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
github-release:
name: Create GitHub release
runs-on: ubuntu-latest
needs:
- release
- docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
version="${{ needs.release.outputs.version }}"
repo="${GITHUB_REPOSITORY}"
repo_url="https://github.com/${repo}"
image_base="${REGISTRY}/${repo}"
image_base="${image_base,,}"
current_commit="$(git rev-list -n 1 "${tag}")"
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
version_notes=".github/release-notes/${tag}.md"
if [[ -f "${version_notes}" ]]; then
cp "${version_notes}" generated-release-notes.md
else
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
fi
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
{
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
echo
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
echo
echo
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
else
echo "- 首个公开版本。"
echo
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
fi
} > generated-release-notes.md
fi
fi
cat > release-notes.md <<EOF
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
## 注意
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
## 使用文档
- [项目文档](${repo_url}#readme)
- [开源协议](${repo_url}/blob/main/LICENSE)
## Docker 镜像
| 组件 | 镜像 |
|------|------|
| All-in-one | \`${image_base}:${tag}\` |
| API | \`${image_base}-api:${tag}\` |
| Web | \`${image_base}-web:${tag}\` |
| Postfix | \`${image_base}-postfix:${tag}\` |
| Dovecot | \`${image_base}-dovecot:${tag}\` |
| Rspamd | \`${image_base}-rspamd:${tag}\` |
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
EOF
{
echo
cat generated-release-notes.md
} >> release-notes.md
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
title="NewSzxcn Email ${tag}"
if gh release view "${tag}" >/dev/null 2>&1; then
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
fi
+66
View File
@@ -0,0 +1,66 @@
# NewSzxcn Email
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md)
## Features
- Webmail with compose, drafts, attachments, search, labels, folders, reminders, import, and export
- Multiple domains and mailboxes, DKIM, DNS checks, verified forwarding, and external IMAP
- Incoming mail rules with conditions, ordering, forwarding, moving, and bulk application
- Administration for users, permission quotas, domains, mailboxes, messages, and send queues
- SMTP, IMAP, POP3, Postfix, Dovecot, Rspamd, and SMTP Submission
- Release checks, admin-only web updates, pre-update database backups, and CLI rollback
## One-command install
Debian and Ubuntu on `amd64` or `arm64` are supported.
```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
## Update
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
CLI update and rollback:
```bash
sudo newszxcn-email update
sudo newszxcn-email rollback
```
Useful commands:
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
## Required ports
Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Public delivery also requires correct MX, SPF, DKIM, and DMARC records.
## Manual source deployment
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## License
[MIT](LICENSE)
+171 -88
View File
@@ -1,107 +1,190 @@
# LanQin Email
# NewSzxcn-Email
![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
![Postfix](https://img.shields.io/badge/Postfix-5E3C2B?logo=maildotru)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
自建邮箱 Webmail 全栈方案。前端 React + shadcn/ui,后端 Go + SQLite,默认单容器集成 Postfix、Dovecot、Rspamd。
[![Release](https://img.shields.io/github/v/release/zxyszx/NewSzxcn-Email?display_name=tag&sort=semver)](https://github.com/zxyszx/NewSzxcn-Email/releases)
[![Docker Release](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
## 特性
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
- **Webmail 客户端** — 文件夹管理、邮件读写、附件、搜索、标签、星标、规则过滤
- **多域名/多邮箱** — 域名管理、DKIM 签名、DNS 记录检测、邮箱别名转发
- **双因素认证** — TOTP 两步登录(兼容 Google Authenticator / Authy
- **管理员面板** — 用户/域名/邮箱/别名/邮件管理、系统设置、邮件模板
- **用户自助** — 开放注册、自助申请邮箱、黑名单、收件规则、联系人
- **单容器部署** — 一个容器跑通 API + Web + Nginx + Postfix + Dovecot + Rspamd
- **本地投递** — 开发环境系统内邮箱互发直接写入 Inbox,无需公网邮件栈
## 主要功能
## 快速开始
| 模块 | 能力 |
| --- | --- |
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
### 开发
## 一键安装
支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
# 后端
cd apps/api
go mod tidy
go test ./...
go run ./cmd/server
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
# 前端(新终端)
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
- 等待 Web 与 API 健康检查通过
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
## 更新与回滚
### 后台页面更新
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新
```bash
sudo newszxcn-email update
```
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash
sudo newszxcn-email rollback
```
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
常用运维命令:
```bash
sudo ns
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口
至少需要以下 DNS 记录:
| 类型 | 示例 | 用途 |
| --- | --- | --- |
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
| MX | `example.com -> mail.example.com` | 接收邮件 |
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
服务器防火墙和云安全组应按需开放:
| 端口 | 协议 | 用途 |
| --- | --- | --- |
| 25 | TCP | SMTP 服务器间收发信 |
| 80 / 443 | TCP | Webmail 与证书签发 |
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
| 993 | TCP | IMAP SSL |
| 995 | TCP | POP3 SSL |
## 数据目录
默认部署目录为 `/opt/newszxcn-email`
```text
/opt/newszxcn-email/
|-- .env # 环境配置与内部更新令牌
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|-- data/ # SQLite、附件和更新前备份
|-- mail/ # Maildir 邮件原文
|-- dkim/ # DKIM 私钥
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
需要自行控制 Compose 配置时:
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy
cp .env.example .env
# 编辑 .env
docker compose pull
docker compose up -d
```
本地源码构建:
```bash
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
## 技术栈
- 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
## 本地开发
```bash
cd apps/api
go run ./cmd/server
```
```bash
cd apps/web
pnpm install
pnpm run dev
```
默认管理员:`admin@lanqin.local`,密码通过 `LANQIN_ADMIN_PASSWORD` 设置(不设置则启动时随机生成并输出到日志)。
### 部署
服务器只需要 Compose 文件和配置,不需要源码构建:
提交前建议运行:
```bash
cd deploy
cp .env.example .env
# 修改 .env 里的域名和管理员密码
docker compose pull
docker compose up -d
cd apps/api && go test ./...
cd apps/web && pnpm run check
```
单容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd。
## 架构
```
┌─────────────────────────────────────────────────┐
│ Docker 容器 │
│ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │
│ │ API │ │ Web │ │Nginx │ │ Postfix │ │
│ │ Go │ │ React │ │反代 │ │ MTA │ │
│ └──┬───┘ └────────┘ └──────┘ └────┬─────┘ │
│ │ SQLite Maildir │ │
│ └──────────────────────────────────┘ │
│ ┌────────┐ ┌──────────┐ │
│ │Dovecot │ │ Rspamd │ │
│ │ IMAP │ │ 反垃圾 │ │
│ └────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
```
数据流:
1. **收件** → Postfix 接收 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示
2. **发件** → Webmail 编辑 → API 构造 MIME → Postfix 投递
3. **反垃圾** → Rspamd 在 Postfix 投递前评分,标记 Spam 文件夹
## 能力
| 模块 | 功能 |
|------|------|
| 认证 | 登录/注册、会话管理、双因素 TOTP、Turnstile 人机验证 |
| 域名 | 多域名管理、DKIM 密钥生成、DNS 记录展示与检测 |
| 邮箱 | 邮箱账号管理、容量配额、密码同步 |
| Webmail | 文件夹、邮件列表、阅读、写信、附件、搜索、已读/未读、星标、移动、删除、标签 |
| 规则 | 收件规则(条件+动作)、发件人黑名单 |
| 联系人 | 个人通讯录管理 |
| 管理 | 用户/域名/邮箱/别名 CRUD、系统设置持久化、邮件模板编辑、SMTP 测试 |
| 清理 | 归档已读、清空回收站/垃圾邮件 |
## 收发说明
- **开发环境**:系统内邮箱互发直接投递到对方 Inbox。未配置 `LANQIN_SMTP_HOST` 时外部收件人不会真正投递。
- **服务器部署**`.env` 默认 `LANQIN_SMTP_HOST=127.0.0.1`,发件交给同容器内 Postfix。
- **收件同步**Postfix/Dovecot 收到 Maildir 后,API 的 Maildir worker 同步到 SQLite 后展示。
- **公网收发**:需要正确配置 MX/SPF/DKIM/DMARC,并确认云厂商开放 25/587/993 端口。
## 要求
- Go 1.22+
- Node.js 20+
- Docker & Docker Compose(部署)
## License
[MIT](./LICENSE)
## 开源协议
[MIT](LICENSE)
+170
View File
@@ -0,0 +1,170 @@
# NewSzxcn-Email
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能
| 模块 | 能力 |
| --- | --- |
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
## 一键安装
支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
- 等待 Web 与 API 健康检查通过
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
## 更新与回滚
### 后台页面更新
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新
```bash
sudo newszxcn-email update
```
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
```bash
sudo newszxcn-email rollback
```
常用运维命令:
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
至少需要以下 DNS 记录:
| 类型 | 示例 | 用途 |
| --- | --- | --- |
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
| MX | `example.com -> mail.example.com` | 接收邮件 |
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
服务器防火墙和云安全组应按需开放:
| 端口 | 协议 | 用途 |
| --- | --- | --- |
| 25 | TCP | SMTP 服务器间收发信 |
| 80 / 443 | TCP | Webmail 与证书签发 |
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
| 993 | TCP | IMAP SSL |
| 995 | TCP | POP3 SSL |
## 数据目录
默认部署目录为 `/opt/newszxcn-email`
```text
/opt/newszxcn-email/
├── .env # 环境配置与内部更新令牌
├── docker-compose.yml # 邮箱主服务与内部更新服务
├── data/ # SQLite、附件和更新前备份
├── mail/ # Maildir 邮件原文
├── dkim/ # DKIM 私钥
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
需要自行控制 Compose 配置时:
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy
cp .env.example .env
# 编辑 .env
docker compose pull
docker compose up -d
```
本地源码构建:
```bash
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
## 技术栈
- 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
## 本地开发
```bash
cd apps/api
go run ./cmd/server
```
```bash
cd apps/web
pnpm install
pnpm run dev
```
提交前建议运行:
```bash
cd apps/api && go test ./...
cd apps/web && pnpm run build
```
## 开源协议
[MIT](LICENSE)
+1
View File
@@ -0,0 +1 @@
1.2.12
+34
View File
@@ -7,9 +7,12 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
smtpserver "github.com/emersion/go-smtp"
"lanqin-email-api/internal/app"
)
@@ -29,6 +32,15 @@ func main() {
Handler: svc.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
submissionServers := &app.SubmissionServers{}
if strings.TrimSpace(cfg.SubmissionAddr) != "" || strings.TrimSpace(cfg.SubmissionTLSAddr) != "" {
tlsConfig, err := app.LoadServerTLSConfig(cfg)
if err != nil {
logger.Error("failed to initialize TLS config", "error", err)
os.Exit(1)
}
submissionServers = svc.NewSubmissionServers(tlsConfig)
}
go func() {
logger.Info("LanQin API listening", "addr", cfg.Addr)
@@ -37,6 +49,24 @@ func main() {
os.Exit(1)
}
}()
if submissionServers.Plain != nil {
go func() {
logger.Info("LanQin SMTP submission listening", "addr", cfg.SubmissionAddr)
if err := submissionServers.Plain.ListenAndServe(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
if submissionServers.TLS != nil {
go func() {
logger.Info("LanQin SMTP implicit TLS submission listening", "addr", cfg.SubmissionTLSAddr)
if err := submissionServers.TLS.ListenAndServeTLS(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp tls submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -48,5 +78,9 @@ func main() {
logger.Error("server shutdown failed", "error", err)
os.Exit(1)
}
if err := submissionServers.Shutdown(shutdownCtx); err != nil {
logger.Error("smtp submission shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
+9 -4
View File
@@ -3,9 +3,14 @@ module lanqin-email-api
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/go-chi/chi/v5 v5.3.0
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.26.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1
)
@@ -13,14 +18,14 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
+49 -8
View File
@@ -2,8 +2,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -22,21 +30,54 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+362 -55
View File
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
ORDER BY u.created_at DESC`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -62,40 +62,66 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var item AdminUser
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users")
return
}
item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled)
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV)
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
}
if err := rows.Close(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
}
for i := range items {
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
return
}
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
actor := currentUser(r)
var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
}
if err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = email
displayName = loginName
}
role := strings.TrimSpace(req.Role)
if role == "" {
@@ -105,8 +131,20 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if role == "admin" && (actor == nil || actor.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
if err != nil {
badRequest(w, err)
return
}
if role == "admin" {
mailboxLimitOverride = nil
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -116,12 +154,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
id := newID("usr")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
}
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user")
return
}
user, err := a.adminUserByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -134,9 +189,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
var req struct {
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
LoginName string `json:"loginName"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -155,21 +213,107 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
disabled := false
existing, err := a.userByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
}
requestedLoginName := strings.TrimSpace(req.LoginName)
loginName := existing.LoginName
if requestedLoginName != "" {
loginName, err = cleanUsername(requestedLoginName)
if err != nil {
badRequest(w, err)
return
}
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return
}
disabled := existing.Disabled
if req.Disabled != nil {
disabled = *req.Disabled
}
if current != nil && current.ID == id && (disabled || role != "admin") {
badRequest(w, errors.New("cannot remove your own admin access"))
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
badRequest(w, errors.New("default administrator must remain an active super administrator"))
return
}
mailboxLimitOverride := existing.MailboxLimitOverride
if req.MailboxLimitOverride != nil {
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
if err != nil {
badRequest(w, err)
return
}
}
if role == "admin" {
mailboxLimitOverride = nil
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
}
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
var permissionGroupIDs []string
if role == "user" {
if req.PermissionGroupIDs != nil {
permissionGroupIDs = *req.PermissionGroupIDs
} else if existing.Role == "user" {
for _, groupID := range existing.PermissionGroupIDs {
if isAssignablePermissionGroupID(groupID) {
permissionGroupIDs = append(permissionGroupIDs, groupID)
}
}
}
}
if current != nil && current.ID == id {
next := *existing
next.Role = role
next.Disabled = disabled
if shouldUpdatePermissionGroups {
if role == "admin" {
next.Permissions = allPermissionKeys()
} else {
permissions, err := a.effectivePermissionsForUserGroups(r.Context(), nil, permissionGroupIDs)
if err != nil {
badRequest(w, err)
return
}
next.Permissions = permissions
}
}
if next.Disabled || !userHasAdminAccess(&next) {
badRequest(w, errors.New("cannot remove your own admin access"))
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
emailIdentity := existing.Email
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
emailIdentity = loginName
}
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("登录名已被使用"))
return
}
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
if shouldUpdatePermissionGroups {
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
badRequest(w, err)
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
@@ -183,6 +327,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if target.Role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
return
}
}
var req struct {
Password string `json:"password"`
}
@@ -190,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -234,6 +388,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("cannot delete your own user"))
return
}
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if a.isDefaultAdminUser(target) {
badRequest(w, errors.New("default administrator cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
badRequest(w, err)
return
@@ -376,14 +540,15 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
var req struct {
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
Role string `json:"role"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -397,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
@@ -409,6 +574,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
@@ -445,15 +617,12 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
} else {
ownerEmail := normalizeEmail(req.OwnerEmail)
if ownerEmail == "" {
ownerEmail = address
}
if !strings.Contains(ownerEmail, "@") {
badRequest(w, errors.New("invalid owner email"))
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
if err != nil {
badRequest(w, err)
return
}
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
@@ -462,11 +631,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
if !strings.EqualFold(ownerLoginName, address) {
ownerDisplayName = ownerLoginName
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
badRequest(w, err)
return
@@ -592,7 +761,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
}
rows.Close()
for _, messageID := range messageIDs {
a.deleteMessageFiles(r.Context(), messageID)
a.deleteMessage(r.Context(), messageID)
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
@@ -634,6 +803,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
user := currentUser(r)
isSystemAdmin := user != nil && user.Role == "admin"
wantsUnregistered := mailboxID == "unregistered" || strings.EqualFold(folder, "Unregistered")
if wantsUnregistered && !isSystemAdmin {
respondError(w, http.StatusForbidden, "system admin required")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
@@ -642,6 +818,9 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
where := []string{"1=1"}
args := []any{}
if !isSystemAdmin {
where = append(where, "m.mailbox_id IS NOT NULL")
}
if mailboxID == "unregistered" {
where = append(where, "m.mailbox_id IS NULL")
} else if mailboxID != "" && mailboxID != "all" {
@@ -663,7 +842,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
@@ -699,6 +878,11 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "message not found")
return
}
user := currentUser(r)
if msg.MailboxID == "" && (user == nil || user.Role != "admin") {
respondError(w, http.StatusForbidden, "system admin required")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'')
FROM messages m
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
@@ -710,6 +894,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
event := strings.TrimSpace(r.URL.Query().Get("event"))
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
if err != nil {
badRequest(w, err)
return
}
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
if err != nil {
badRequest(w, err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
}
limit := 50
where := []string{"1=1"}
args := []any{}
if mailboxID != "" && mailboxID != "all" {
where = append(where, "sae.mailbox_id=?")
args = append(args, mailboxID)
}
if messageID != "" {
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
args = append(args, messageID, messageID, messageID)
}
if event != "" && event != "all" {
if !isSendAuditEvent(event) {
badRequest(w, errors.New("invalid event"))
return
}
where = append(where, "sae.event=?")
args = append(args, event)
}
if from != "" {
where = append(where, "sae.created_at>=?")
args = append(args, from)
}
if to != "" {
where = append(where, "sae.created_at<=?")
args = append(args, to)
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
FROM send_audit_events sae
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
LEFT JOIN messages m ON m.id=sae.sent_message_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
defer rows.Close()
items := []SendAuditEvent{}
for rows.Next() {
var item SendAuditEvent
var recipientsJSON, createdAt string
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
return
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(offset + limit)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC().Format(time.RFC3339Nano), nil
}
if t, err := time.Parse("2006-01-02", value); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Nanosecond)
}
return t.UTC().Format(time.RFC3339Nano), nil
}
return "", errors.New("invalid time filter")
}
func isSendAuditEvent(event string) bool {
switch event {
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
return true
default:
return false
}
}
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
var req struct {
DomainID string `json:"domainId"`
@@ -731,7 +1025,7 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
source = normalizeLocalPart(source) + "@" + domain.Name
}
destination := normalizeEmail(req.Destination)
if source == "" || destination == "" || !strings.Contains(destination, "@") {
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
badRequest(w, errors.New("invalid alias"))
return
}
@@ -776,7 +1070,7 @@ func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
source = normalizeLocalPart(source) + "@" + domain.Name
}
destination := normalizeEmail(req.Destination)
if source == "" || destination == "" || !strings.Contains(destination, "@") {
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
badRequest(w, errors.New("invalid alias"))
return
}
@@ -822,20 +1116,25 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
}
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=?
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at`, id)
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
var item AdminUser
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err
}
item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled)
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV)
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
return nil, err
}
return &item, nil
}
@@ -913,6 +1212,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
}
role := strings.ToLower(folder)
id = newID("fld")
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
sortOrder := 0
if !isSystemFolderName(folder) {
var err error
sortOrder, err = a.nextCustomFolderSortOrder(ctx, mailboxID)
if err != nil {
return "", err
}
}
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, sortOrder, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
return id, err
}
+282
View File
@@ -0,0 +1,282 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const defaultAPITokenTTL = 90 * 24 * time.Hour
var validAPITokenScopes = map[string]bool{
"*": true,
"domains:read": true,
"domains:write": true,
"mailboxes:read": true,
"mailboxes:write": true,
"messages:read": true,
"messages:send": true,
"messages:manage": true,
"aliases:read": true,
"aliases:write": true,
"dns:read": true,
"dns:check": true,
}
func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
return
}
defer rows.Close()
items := []APIToken{}
for rows.Next() {
item, err := scanAPIToken(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan api tokens")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
return
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
Name string `json:"name"`
ExpiresAt string `json:"expiresAt"`
Scopes json.RawMessage `json:"scopes"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
if len([]rune(name)) > 80 {
badRequest(w, errors.New("name cannot exceed 80 characters"))
return
}
expiresAt, err := parseOptionalFutureTime(req.ExpiresAt, a.now().UTC())
if err != nil {
badRequest(w, err)
return
}
if expiresAt == nil {
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
expiresAt = &defaultExpiry
}
var requestedScopes []string
if len(req.Scopes) > 0 {
if string(req.Scopes) == "null" || json.Unmarshal(req.Scopes, &requestedScopes) != nil {
badRequest(w, errors.New("scopes must be an array of strings"))
return
}
} else {
requestedScopes = nil
}
scopes, err := normalizeAPITokenScopes(requestedScopes)
if err != nil {
badRequest(w, err)
return
}
id := newID("apt")
token := "lq_" + randomToken()
now := a.now().UTC().Format(time.RFC3339Nano)
var expiresValue any
if expiresAt != nil {
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
}
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,scopes_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, jsonEncode(scopes), now, now); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create api token")
return
}
item, err := a.apiTokenByID(r.Context(), user.ID, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load api token")
return
}
respondJSON(w, http.StatusCreated, map[string]any{"token": token, "item": item})
}
func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
respondError(w, http.StatusNotFound, "api token not found")
return
}
var req struct {
Name *string `json:"name"`
ExpiresAt *string `json:"expiresAt"`
Disabled *bool `json:"disabled"`
Scopes *[]string `json:"scopes"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
current, err := a.apiTokenByID(r.Context(), user.ID, id)
if err != nil {
respondError(w, http.StatusNotFound, "api token not found")
return
}
name := current.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
if len([]rune(name)) > 80 {
badRequest(w, errors.New("name cannot exceed 80 characters"))
return
}
}
var expiresValue any
if current.ExpiresAt != nil {
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
}
if req.ExpiresAt != nil {
if strings.TrimSpace(*req.ExpiresAt) == "" {
badRequest(w, errors.New("expiresAt must be an RFC3339 timestamp"))
return
}
expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC())
if err != nil {
badRequest(w, err)
return
}
expiresValue = nil
if expiresAt != nil {
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
}
}
disabled := current.Disabled
if req.Disabled != nil {
disabled = *req.Disabled
}
scopes := current.Scopes
if req.Scopes != nil {
scopes, err = normalizeAPITokenScopes(*req.Scopes)
if err != nil {
badRequest(w, err)
return
}
}
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,scopes_json=?,updated_at=? WHERE id=? AND user_id=?`,
name, expiresValue, boolInt(disabled), jsonEncode(scopes), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update api token")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "api token not found")
return
}
item, err := a.apiTokenByID(r.Context(), user.ID, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load api token")
return
}
respondJSON(w, http.StatusOK, item)
}
func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
res, err := a.db.ExecContext(r.Context(), `DELETE FROM api_tokens WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete api token")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "api token not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
FROM api_tokens WHERE id=? AND user_id=?`, id, userID)
return scanAPIToken(row)
}
type apiTokenScanner interface{ Scan(dest ...any) error }
func scanAPIToken(row apiTokenScanner) (APIToken, error) {
var item APIToken
var lastUsed, expires sql.NullString
var disabled int
var scopesJSON, created, updated string
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &scopesJSON, &created, &updated); err != nil {
return item, err
}
item.LastUsedAt = nullableTime(lastUsed)
item.ExpiresAt = nullableTime(expires)
item.Disabled = intBool(disabled)
item.Scopes = jsonDecodeSlice(scopesJSON)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
return item, nil
}
func normalizeAPITokenScopes(scopes []string) ([]string, error) {
if scopes == nil {
return []string{"*"}, nil
}
if len(scopes) == 0 {
return nil, errors.New("at least one api token scope is required")
}
seen := map[string]bool{}
out := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.ToLower(strings.TrimSpace(scope))
if !validAPITokenScopes[scope] {
return nil, fmt.Errorf("invalid api token scope: %s", scope)
}
if !seen[scope] {
seen[scope] = true
out = append(out, scope)
}
}
if seen["*"] && len(out) != 1 {
return nil, errors.New("wildcard scope cannot be combined with other scopes")
}
return out, nil
}
func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return nil, errors.New("expiresAt must be an RFC3339 timestamp")
}
t = t.UTC()
if !t.After(now) {
return nil, errors.New("expiresAt must be in the future")
}
return &t, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+26 -15
View File
@@ -11,6 +11,7 @@ import (
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"`
Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
@@ -49,17 +50,27 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
user, passwordHash, err := a.userByEmail(r.Context(), email)
var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
}
if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
@@ -76,7 +87,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration {
if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册")
return
}
@@ -101,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("邮箱地址无效"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("密码至少需要 6 个字符"))
return
}
displayName := strings.TrimSpace(req.DisplayName)
@@ -127,8 +138,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -165,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
@@ -181,10 +192,10 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -234,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("新密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.NewPassword) {
badRequest(w, errors.New("新密码至少需要 6 个字符"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
+104 -56
View File
@@ -8,67 +8,115 @@ import (
)
type Config struct {
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
Addr string
AppVersion string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminUsername string
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
SubmissionAddr string
SubmissionTLSAddr string
SubmissionMaxMessageMB int
TLSCertFile string
TLSKeyFile string
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
ExternalIMAPEnabled bool
ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool
ExternalIMAPGmailClientID string
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
MailTranslateEnabled bool
MailTranslateMaxChars int
DeliveryWebhookSecret string
StatusWebhookURL string
StatusWebhookSecret string
StatusWebhookAllowPrivateHosts bool
ReleaseAPIURL string
UpdateServiceURL string
UpdateServiceToken string
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
Addr: getenv("LANQIN_ADDR", ":8080"),
AppVersion: getenv("LANQIN_APP_VERSION", BuildVersion),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
ExternalIMAPEnabled: getenvBool("LANQIN_EXTERNAL_IMAP_ENABLED", false),
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""),
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""),
StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""),
StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false),
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
name := strings.TrimSuffix(d.Name, ".")
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry)
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
ok = true
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
"unicode"
)
const forwardingHeaderName = "X-LanQin-Forwarded-By"
func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) {
targets, userID, mailboxAddress, err := a.inboundForwardingTargets(ctx, mailboxID)
if err != nil {
a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err)
return
}
if len(targets) == 0 || userID == "" || mailboxAddress == "" {
return
}
self := normalizeEmail(mailboxAddress)
filteredTargets := make([]string, 0, len(targets))
for _, target := range targets {
if normalizeEmail(target) == self {
continue
}
filteredTargets = append(filteredTargets, target)
}
targets = dedupeEmails(filteredTargets)
if len(targets) == 0 {
return
}
if len(raw) == 0 {
raw, err = a.forwardingRawMessage(ctx, messageID)
if err != nil {
a.log.Warn("failed to load raw message for forwarding", "message", messageID, "error", err)
return
}
}
if hasForwardingHeader(raw) {
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
rfcMessageID = messageID
}
queueID, err := a.enqueueSend(ctx, sendQueueInput{
UserID: userID,
MailboxID: mailboxID,
SentMessageID: messageID,
MessageID: rfcMessageID,
Source: sendSourceForwarding,
MailFrom: mailboxAddress,
HeaderFrom: mailboxAddress,
Recipients: targets,
MIMEBytes: forwarded,
Now: a.now().UTC(),
})
if err != nil {
a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","), "error", err)
return
}
if queueID == "" {
a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
}
}
func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID string, action MailRuleAction) error {
var userID, mailboxAddress string
if err := a.db.QueryRowContext(ctx, `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&userID, &mailboxAddress); err != nil {
return err
}
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
if err != nil {
return err
}
self := normalizeEmail(mailboxAddress)
filteredTargets := make([]string, 0, len(targets))
for _, target := range targets {
if normalizeEmail(target) == self {
continue
}
filteredTargets = append(filteredTargets, target)
}
targets = dedupeEmails(filteredTargets)
if len(targets) == 0 {
return nil
}
raw, err := a.forwardingRawMessage(ctx, messageID)
if err != nil {
return err
}
if hasForwardingHeader(raw) {
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
rfcMessageID = messageID
}
queueID, err := a.enqueueSend(ctx, sendQueueInput{
UserID: userID,
MailboxID: mailboxID,
SentMessageID: messageID,
MessageID: ruleForwardQueueMessageID(rfcMessageID, targets),
Source: sendSourceRuleForwarding,
MailFrom: mailboxAddress,
HeaderFrom: mailboxAddress,
Recipients: targets,
MIMEBytes: forwarded,
Now: a.now().UTC(),
})
if err != nil {
return err
}
if queueID == "" {
a.log.Warn("rule forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
}
return nil
}
func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
FROM mailboxes mb
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id
LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id
WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &mailboxTargetsJSON, &accountTarget, &accountTargetsJSON)
if err != nil {
return nil, "", "", err
}
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
if len(targets) == 0 {
return nil, userID, mailboxAddress, nil
}
verifiedTargets := make([]string, 0, len(targets))
for _, target := range targets {
verified, err := a.forwardingEmailVerified(ctx, userID, target)
if err != nil {
return nil, "", "", err
}
if verified {
verifiedTargets = append(verifiedTargets, target)
}
}
return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
}
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return nil, err
}
if strings.TrimSpace(msg.RawPath) != "" {
if ok, err := a.pathIsUnderMaildirRoot(msg.RawPath); err == nil && ok {
if raw, err := os.ReadFile(msg.RawPath); err == nil {
return raw, nil
}
}
}
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return nil, err
}
return BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
}
func splitRuleForwardTargets(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) || r == ',' || r == '' || r == ';' || r == ''
})
}
func ruleForwardQueueMessageID(messageID string, targets []string) string {
base := strings.TrimSpace(messageID)
if base == "" {
base = newID("ruleforward")
}
sum := sha256.Sum256([]byte(strings.Join(dedupeEmails(targets), ",")))
return base + "#rule-forward-" + hex.EncodeToString(sum[:])[:12]
}
func hasForwardingHeader(raw []byte) bool {
header := raw
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
header = raw[:idx]
} else if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
header = raw[:idx]
}
return strings.Contains(strings.ToLower(string(header)), strings.ToLower(forwardingHeaderName)+":")
}
func addForwardingHeaders(raw []byte, mailboxAddress, hostname string) []byte {
hostname = strings.TrimSpace(hostname)
if hostname == "" {
hostname = "lanqin.local"
}
header := fmt.Sprintf("%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n", forwardingHeaderName, hostname, normalizeEmail(mailboxAddress))
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
out := make([]byte, 0, len(raw)+len(header))
out = append(out, raw[:idx]...)
out = append(out, []byte("\r\n"+header)...)
out = append(out, raw[idx+2:]...)
return out
}
if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
lfHeader := strings.ReplaceAll(header, "\r\n", "\n")
out := make([]byte, 0, len(raw)+len(lfHeader))
out = append(out, raw[:idx]...)
out = append(out, []byte("\n"+lfHeader)...)
out = append(out, raw[idx+1:]...)
return out
}
return append([]byte(header+"\r\n"), raw...)
}
@@ -0,0 +1,634 @@
package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const forwardingVerificationTTL = 24 * time.Hour
type ForwardingVerifiedEmail struct {
ID string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified"`
CreatedAt time.Time `json:"createdAt"`
VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
VerificationSentAt *time.Time `json:"verificationSentAt,omitempty"`
VerificationExpiresAt *time.Time `json:"verificationExpiresAt,omitempty"`
DeliveryStatus string `json:"deliveryStatus,omitempty"`
DeliveryError string `json:"deliveryError,omitempty"`
}
type MailboxForwardingRule struct {
MailboxID string `json:"mailboxId"`
TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
}
type ForwardingSettings struct {
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
AccountTargetEmail string `json:"accountTargetEmail"`
AccountTargetEmails []string `json:"accountTargetEmails"`
MailboxRules []MailboxForwardingRule `json:"mailboxRules"`
}
func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
Email string `json:"email"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
email, ok := a.cleanForwardingVerificationEmail(w, r, user.ID, req.Email)
if !ok {
return
}
id, verified, err := a.forwardingVerifiedEmailState(r.Context(), user.ID, email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "failed to load verified email")
return
}
if verified {
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
return
}
if id == "" {
id = newID("fwd")
}
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, errors.Is(err, sql.ErrNoRows)); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save verified email")
return
}
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusCreated, settings)
}
func (a *App) handleResendForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
respondError(w, http.StatusNotFound, "verified email not found")
return
}
var email string
var verified int
err := a.db.QueryRowContext(r.Context(), `SELECT email,verified FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email, &verified)
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "verified email not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load verified email")
return
}
if intBool(verified) {
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
return
}
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, false); err != nil {
respondError(w, http.StatusInternalServerError, "failed to resend verification email")
return
}
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效")
return
}
var id, email string
var verified int
var expiresRaw sql.NullString
err := a.db.QueryRowContext(r.Context(), `SELECT id,email,verified,verification_expires_at FROM forwarding_verified_emails WHERE verification_token_hash=?`, hashToken(token)).Scan(&id, &email, &verified, &expiresRaw)
if errors.Is(err, sql.ErrNoRows) {
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效或已使用")
return
}
if err != nil {
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, "", "验证失败,请稍后重试")
return
}
if intBool(verified) {
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已经验证完成")
return
}
if expiresRaw.Valid && expiresRaw.String != "" && parseTime(expiresRaw.String).Before(a.now().UTC()) {
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, email, "验证链接已过期,请回到设置页重新发送")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `UPDATE forwarding_verified_emails
SET verified=1,verified_at=?,delivery_status='verified',delivery_error='',updated_at=?
WHERE id=?`, now, now, id)
if err != nil {
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
return
}
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
}
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
respondError(w, http.StatusNotFound, "verified email not found")
return
}
var email string
if err := a.db.QueryRowContext(r.Context(), `SELECT email FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email); err != nil {
respondError(w, http.StatusNotFound, "verified email not found")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := tx.ExecContext(r.Context(), `DELETE FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete verified email")
return
}
if err := a.removeForwardingTargetFromSettings(r.Context(), tx, user.ID, email, now); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save forwarding settings")
return
}
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
if err != nil {
badRequest(w, err)
return
}
target := firstForwardingTarget(targets)
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,target_emails,updated_at)
VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
user.ID, target, jsonEncode(targets), now)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save account forwarding")
return
}
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
if mailboxID == "" {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if ok, err := a.userOwnsMailboxID(r.Context(), user.ID, mailboxID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return
} else if !ok {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
if err != nil {
badRequest(w, err)
return
}
target := firstForwardingTarget(targets)
if len(targets) == 0 {
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, mailboxID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
return
}
} else {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,target_emails,updated_at)
VALUES(?,?,?,?)
ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
mailboxID, target, jsonEncode(targets), now); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
return
}
}
settings, err := a.forwardingSettings(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
return
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) forwardingSettings(ctx context.Context, userID string) (ForwardingSettings, error) {
settings := ForwardingSettings{
VerifiedEmails: []ForwardingVerifiedEmail{},
MailboxRules: []MailboxForwardingRule{},
}
rows, err := a.db.QueryContext(ctx, `SELECT fve.id,fve.email,fve.verified,fve.created_at,
fve.verified_at,fve.verification_sent_at,fve.verification_expires_at,
COALESCE(NULLIF(sq.status,''), fve.delivery_status),
COALESCE(NULLIF(sq.last_error,''), fve.delivery_error)
FROM forwarding_verified_emails fve
LEFT JOIN send_queue sq ON sq.id=fve.delivery_queue_id
WHERE fve.user_id=?
ORDER BY fve.created_at DESC,fve.email`, userID)
if err != nil {
return settings, err
}
defer rows.Close()
for rows.Next() {
var item ForwardingVerifiedEmail
var verified int
var created string
var verifiedAt, sentAt, expiresAt sql.NullString
if err := rows.Scan(&item.ID, &item.Email, &verified, &created, &verifiedAt, &sentAt, &expiresAt, &item.DeliveryStatus, &item.DeliveryError); err != nil {
return settings, err
}
item.Verified = intBool(verified)
item.CreatedAt = parseTime(created)
item.VerifiedAt = nullableTime(verifiedAt)
item.VerificationSentAt = nullableTime(sentAt)
item.VerificationExpiresAt = nullableTime(expiresAt)
settings.VerifiedEmails = append(settings.VerifiedEmails, item)
}
if err := rows.Err(); err != nil {
return settings, err
}
var accountTarget, accountTargetsJSON string
err = a.db.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return settings, err
}
settings.AccountTargetEmails = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
settings.AccountTargetEmail = firstForwardingTarget(settings.AccountTargetEmails)
rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
FROM mailbox_forwarding_settings mfs
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
WHERE mb.user_id=? AND (mfs.target_email<>'' OR mfs.target_emails<>'[]')
ORDER BY mb.address`, userID)
if err != nil {
return settings, err
}
defer rows.Close()
for rows.Next() {
var item MailboxForwardingRule
var target, targetsJSON string
if err := rows.Scan(&item.MailboxID, &target, &targetsJSON); err != nil {
return settings, err
}
item.TargetEmails = forwardingTargetsFromStored(target, targetsJSON)
item.TargetEmail = firstForwardingTarget(item.TargetEmails)
if len(item.TargetEmails) > 0 {
settings.MailboxRules = append(settings.MailboxRules, item)
}
}
return settings, rows.Err()
}
func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email string, insert bool) error {
now := a.now().UTC()
expires := now.Add(forwardingVerificationTTL)
token := randomToken()
nowRaw := now.Format(time.RFC3339Nano)
expiresRaw := expires.Format(time.RFC3339Nano)
if insert {
if _, err := a.db.ExecContext(ctx, `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,verified_at,verification_token_hash,verification_sent_at,verification_expires_at,delivery_queue_id,delivery_status,delivery_error,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, userID, email, 0, nil, hashToken(token), nowRaw, expiresRaw, "", sendQueueStatusQueued, "", nowRaw, nowRaw); err != nil {
return err
}
} else {
if _, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails
SET verified=0,verified_at=NULL,verification_token_hash=?,verification_sent_at=?,verification_expires_at=?,delivery_queue_id='',delivery_status=?,delivery_error='',updated_at=?
WHERE id=? AND user_id=?`,
hashToken(token), nowRaw, expiresRaw, sendQueueStatusQueued, nowRaw, id, userID); err != nil {
return err
}
}
queueID, err := a.sendForwardingVerificationEmail(ctx, userID, email, token, now)
if err != nil {
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_status=?,delivery_error=?,updated_at=? WHERE id=? AND user_id=?`, sendQueueStatusFailed, err.Error(), nowRaw, id, userID)
return nil
}
if queueID != "" {
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_queue_id=?,delivery_status=?,delivery_error='',updated_at=? WHERE id=? AND user_id=?`, queueID, sendQueueStatusQueued, nowRaw, id, userID)
}
return nil
}
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", errors.New("SMTP 未配置,无法发送验证邮件")
}
mb, err := a.primaryMailboxForUser(ctx, userID)
if err != nil {
return "", err
}
fromDomain := domainPart(mb.Address)
from := "noreply@" + fromDomain
link := a.forwardingVerificationURL(token)
text := "邮箱转发验证\n\n您正在将此邮箱添加为邮件转发目标地址。请打开以下链接完成验证:\n" + link + "\n\n此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。"
html := `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;line-height:1.6;padding:32px 24px">
<div style="max-width:640px;margin:0 auto">
<h1 style="font-size:28px;line-height:1.25;margin:0 0 28px;font-weight:700">邮箱转发验证</h1>
<p style="font-size:17px;margin:0 0 28px">您正在将此邮箱添加为邮件转发目标地址。请点击下方按钮完成验证:</p>
<p style="text-align:center;margin:0 0 34px"><a href="` + htmlEscape(link) + `" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:8px;padding:14px 38px;font-size:18px;font-weight:700">确认验证</a></p>
<p style="font-size:15px;color:#6b7280;margin:0 0 12px">如果按钮无法点击,请复制以下链接到浏览器:</p>
<p style="font-size:15px;color:#6b7280;word-break:break-all;margin:0 0 28px">` + htmlEscape(link) + `</p>
<p style="font-size:15px;color:#9ca3af;margin:0">此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。</p>
</div></div>`
messageID := fmt.Sprintf("<%s@%s>", newID("fwdverify"), fromDomain)
mimeBytes, err := BuildMIME(MIMEMessage{From: from, FromName: "noreply", To: []string{targetEmail}, Subject: "邮箱转发验证", Text: text, HTML: html, MessageID: messageID, Date: now})
if err != nil {
return "", err
}
return a.enqueueSend(ctx, sendQueueInput{
UserID: userID,
MailboxID: mb.ID,
MessageID: messageID,
Source: sendSourceForwardingVerification,
MailFrom: from,
HeaderFrom: from,
Recipients: []string{targetEmail},
MIMEBytes: mimeBytes,
Now: now,
})
}
func (a *App) forwardingVerificationURL(token string) string {
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
if base == "" {
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
}
return base + "/api/verify-email?token=" + url.QueryEscape(token)
}
func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int, ok bool, email, message string) {
title := "邮箱转发验证"
heading := "验证失败"
color := "#dc2626"
if ok {
heading = "验证完成"
color = "#2563eb"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
title, heading, htmlEscape(message), htmlEscape(email), color)
}
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("邮箱地址无效"))
return "", false
}
if owns, err := a.userOwnsMailboxAddress(r.Context(), userID, email); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return "", false
} else if owns {
badRequest(w, errors.New("不能把当前账号邮箱作为转发验证邮箱"))
return "", false
}
return email, true
}
func (a *App) forwardingVerifiedEmailState(ctx context.Context, userID, email string) (id string, verified bool, err error) {
var verifiedInt int
err = a.db.QueryRowContext(ctx, `SELECT id,verified FROM forwarding_verified_emails WHERE user_id=? AND email=?`, userID, normalizeEmail(email)).Scan(&id, &verifiedInt)
return id, intBool(verifiedInt), err
}
func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox, error) {
var mb Mailbox
var created string
err := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id LIMIT 1`, userID).
Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &mb.QuotaMB, &mb.Status, &created)
if errors.Is(err, sql.ErrNoRows) {
return mb, errors.New("当前账号没有可用于发送验证邮件的邮箱")
}
mb.CreatedAt = parseTime(created)
return mb, err
}
func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM forwarding_verified_emails WHERE user_id=? AND email=? AND verified=1`, userID, normalizeEmail(email)).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func forwardingTargetsFromRequest(targetEmail string, targetEmails []string) []string {
if len(targetEmails) > 0 {
return targetEmails
}
if strings.TrimSpace(targetEmail) == "" {
return nil
}
return []string{targetEmail}
}
func (a *App) cleanForwardingTargets(ctx context.Context, userID string, values []string) ([]string, error) {
targets := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "none") {
continue
}
target := normalizeEmail(value)
if target == "" || !strings.Contains(target, "@") {
return nil, errors.New("转发邮箱无效")
}
if seen[target] {
continue
}
ok, err := a.forwardingEmailVerified(ctx, userID, target)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("请先完成邮箱验证")
}
seen[target] = true
targets = append(targets, target)
}
return targets, nil
}
func forwardingTargetsFromStored(targetEmail, targetsJSON string) []string {
targets := dedupeEmails(jsonDecodeSlice(targetsJSON))
if len(targets) > 0 {
return targets
}
target := normalizeEmail(targetEmail)
if target == "" {
return nil
}
return []string{target}
}
func firstForwardingTarget(targets []string) string {
if len(targets) == 0 {
return ""
}
return targets[0]
}
func removeForwardingTarget(targets []string, email string) []string {
email = normalizeEmail(email)
next := make([]string, 0, len(targets))
for _, target := range targets {
if normalizeEmail(target) == email {
continue
}
next = append(next, normalizeEmail(target))
}
return dedupeEmails(next)
}
func (a *App) removeForwardingTargetFromSettings(ctx context.Context, tx *sql.Tx, userID, email, now string) error {
var accountTarget, accountTargetsJSON string
if err := tx.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
} else if err == nil {
targets := removeForwardingTarget(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), email)
if _, err := tx.ExecContext(ctx, `UPDATE account_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE user_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, userID); err != nil {
return err
}
}
rows, err := tx.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
FROM mailbox_forwarding_settings mfs
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
WHERE mb.user_id=?`, userID)
if err != nil {
return err
}
type mailboxRow struct {
id string
target string
targetsJSON string
}
var items []mailboxRow
for rows.Next() {
var item mailboxRow
if err := rows.Scan(&item.id, &item.target, &item.targetsJSON); err != nil {
rows.Close()
return err
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
for _, item := range items {
targets := removeForwardingTarget(forwardingTargetsFromStored(item.target, item.targetsJSON), email)
if len(targets) == 0 {
if _, err := tx.ExecContext(ctx, `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, item.id); err != nil {
return err
}
continue
}
if _, err := tx.ExecContext(ctx, `UPDATE mailbox_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE mailbox_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, item.id); err != nil {
return err
}
}
return nil
}
func (a *App) userOwnsMailboxID(ctx context.Context, userID, mailboxID string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (a *App) userOwnsMailboxAddress(ctx context.Context, userID, address string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE user_id=? AND address=? AND status='active'`, userID, normalizeEmail(address)).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
+255
View File
@@ -0,0 +1,255 @@
package app
import (
"context"
"database/sql"
"math"
"time"
)
type imapMetadata struct {
UID int64
ModSeq int64
}
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
return err
}
if err := a.backfillIMAPUIDs(ctx); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
return err
}
func (a *App) migrateFolderSortOrder(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "folders", "sort_order", `ALTER TABLE folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders WHERE lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash') ORDER BY mailbox_id, created_at, name, id`)
if err != nil {
return err
}
var folderIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
folderIDs = append(folderIDs, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
order := customFolderDefaultSortOrderBase + 1
for _, id := range folderIDs {
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET sort_order=? WHERE id=? AND sort_order=0`, order, id); err != nil {
return err
}
order++
}
return nil
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return err
}
if name == column {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, alterSQL)
return err
}
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
if err != nil {
return err
}
var folderIDs []string
for rows.Next() {
var folderID string
if err := rows.Scan(&folderID); err != nil {
rows.Close()
return err
}
folderIDs = append(folderIDs, folderID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, folderID := range folderIDs {
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
return err
}
}
return nil
}
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
if err != nil {
return err
}
var messageIDs []string
for rows.Next() {
var messageID string
if err := rows.Scan(&messageID); err != nil {
rows.Close()
return err
}
messageIDs = append(messageIDs, messageID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
return err
}
}
var maxUID, maxModSeq int64
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
return err
}
func (a *App) newUIDValidity() int64 {
value := a.now().UTC().Unix()
if value <= 0 {
return time.Now().UTC().Unix()
}
return value
}
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
if folderID == "" {
return imapMetadata{}, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return imapMetadata{}, nil
}
var nextUID, highestModSeq int64
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
if err != nil {
return imapMetadata{}, err
}
if nextUID < 1 {
nextUID = 1
}
nextModSeq := highestModSeq + 1
if nextModSeq < 1 {
nextModSeq = 1
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
return imapMetadata{}, err
}
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
}
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
}
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
if folderID == "" {
return 0, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return 0, nil
}
var current int64
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(&current); err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
next := current + 1
if next < 1 || next == math.MaxInt64 {
next = current
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
return 0, err
}
return next, nil
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
return 0, err
}
if !dbFolderID.Valid || dbFolderID.String == "" {
return 0, nil
}
folderID = dbFolderID.String
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return 0, err
}
if modSeq == 0 {
return 0, nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return modSeq, err
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
package app
import (
"archive/zip"
"bufio"
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const maxMailImportBytes int64 = 256 << 20
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
ids, err := a.exportMessageIDs(r)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "mailbox or label not found")
return
}
if errors.Is(err, errSystemAdminRequired) {
respondError(w, http.StatusForbidden, "system admin required")
return
}
badRequest(w, err)
return
}
filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Cache-Control", "no-store")
zw := zip.NewWriter(w)
usedNames := make(map[string]int, len(ids))
for index, id := range ids {
raw, subject, err := a.rawMessageForExport(r.Context(), id)
if err != nil {
_ = zw.Close()
return
}
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
if err != nil {
_ = zw.Close()
return
}
if _, err := entry.Write(raw); err != nil {
_ = zw.Close()
return
}
}
_ = zw.Close()
}
var errSystemAdminRequired = errors.New("system admin required")
func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
user := currentUser(r)
if user == nil {
return nil, errors.New("no user")
}
view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
where := []string{}
args := []any{}
if view == "unknown" {
if user.Role != "admin" {
return nil, errSystemAdminRequired
}
where = append(where, "m.mailbox_id IS NULL")
} else {
where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')")
args = append(args, user.ID)
if mailboxID != "" && !isAllMailboxID(mailboxID) {
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
return nil, err
}
where = append(where, "m.mailbox_id=?")
args = append(args, mailboxID)
}
switch view {
case "", "folder":
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
if folder == "" {
folder = "Inbox"
}
normalized, err := normalizeFolderNameForUser(folder)
if err != nil {
return nil, err
}
where = append(where, "f.name=?")
args = append(args, normalized)
case "starred":
where = append(where, "m.is_starred=1")
case "label":
labelID := strings.TrimSpace(r.URL.Query().Get("labelId"))
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
args = append(args, labelID)
default:
return nil, errors.New("unsupported mail view")
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
msg, err := a.storedMessageByID(ctx, id)
if err != nil {
return nil, "", err
}
if msg.RawPath != "" {
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
return raw, msg.Subject, nil
}
}
}
attachments, err := a.attachmentInputsForMessage(ctx, id)
if err != nil {
return nil, "", err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
Date: messageDate(msg), Attachments: attachments,
})
return raw, msg.Subject, err
}
func exportMessageFilename(subject, id string, index int) string {
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
name = strings.Trim(name, ".-_")
if name == "" {
name = "message"
}
if len(name) > 80 {
name = name[:80]
}
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
}
func uniqueExportFilename(name string, used map[string]int) string {
used[name]++
if used[name] == 1 {
return name
}
base := strings.TrimSuffix(name, filepath.Ext(name))
return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name))
}
func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes)
if err := r.ParseMultipartForm(maxMailImportBytes); err != nil {
respondError(w, http.StatusRequestEntityTooLarge, "import is too large")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId"))
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderName := strings.TrimSpace(r.FormValue("folder"))
if folderName == "" {
folderName = "Inbox"
}
folderName, err = normalizeFolderNameForUser(folderName)
if err != nil {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
files = r.MultipartForm.File["file"]
}
if len(files) == 0 {
badRequest(w, errors.New("at least one EML or MBOX file is required"))
return
}
imported, skipped := 0, 0
problems := []string{}
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
for _, header := range files {
messages, fileErr := readImportFile(header, maxMessageBytes)
if fileErr != nil {
skipped++
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr))
continue
}
for _, raw := range messages {
if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil {
skipped++
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err))
continue
}
imported++
}
}
if imported == 0 && len(problems) > 0 {
badRequest(w, errors.New(problems[0]))
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems})
}
func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) {
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".eml" && ext != ".mbox" {
return nil, errors.New("only .eml and .mbox files are supported")
}
file, err := header.Open()
if err != nil {
return nil, err
}
defer file.Close()
if ext == ".eml" {
raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > maxMessageBytes {
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil, errors.New("message is empty")
}
return [][]byte{raw}, nil
}
return parseMBOX(file, maxMessageBytes)
}
func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) {
scanner := bufio.NewScanner(reader)
bufferSize := int(maxMessageBytes + 1024)
if bufferSize < 64*1024 {
bufferSize = 64 * 1024
}
scanner.Buffer(make([]byte, 64*1024), bufferSize)
var current bytes.Buffer
messages := [][]byte{}
seenSeparator := false
flush := func() error {
raw := bytes.TrimSpace(current.Bytes())
current.Reset()
if len(raw) == 0 {
return nil
}
if int64(len(raw)) > maxMessageBytes {
return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
messages = append(messages, append([]byte(nil), raw...))
return nil
}
for scanner.Scan() {
line := scanner.Bytes()
if bytes.HasPrefix(line, []byte("From ")) {
if seenSeparator {
if err := flush(); err != nil {
return nil, err
}
}
seenSeparator = true
continue
}
if bytes.HasPrefix(line, []byte(">From ")) {
line = line[1:]
}
current.Write(line)
current.WriteString("\r\n")
if int64(current.Len()) > maxMessageBytes {
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if err := flush(); err != nil {
return nil, err
}
if len(messages) == 0 {
return nil, errors.New("MBOX contains no messages")
}
return messages, nil
}
func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error {
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
if err != nil {
return fmt.Errorf("invalid message: %w", err)
}
msg.MailboxID = mb.ID
msg.FolderID = folderID
msg.RecipientAddr = mb.Address
msg.RawPath = ""
id, err := a.insertMessage(ctx, msg, attachments)
if err != nil {
return err
}
if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil {
a.deleteMessage(ctx, id)
return err
}
return nil
}
func appendImportProblem(items []string, problem string) []string {
if len(items) >= 5 {
return items
}
return append(items, problem)
}
@@ -0,0 +1,175 @@
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseMBOXMultipleMessages(t *testing.T) {
raw := strings.Join([]string{
"From sender@example.com Mon Jan 1 00:00:00 2024",
"From: sender@example.com",
"To: first@example.com",
"Subject: first",
"",
"first body",
">From escaped body line",
"From sender@example.com Tue Jan 2 00:00:00 2024",
"From: sender@example.com",
"To: second@example.com",
"Subject: second",
"",
"second body",
}, "\n")
messages, err := parseMBOX(strings.NewReader(raw), 1<<20)
if err != nil {
t.Fatal(err)
}
if len(messages) != 2 {
t.Fatalf("messages=%d", len(messages))
}
if !bytes.Contains(messages[0], []byte("Subject: first")) || !bytes.Contains(messages[0], []byte("From escaped body line")) {
t.Fatalf("first message=%q", messages[0])
}
if !bytes.Contains(messages[1], []byte("Subject: second")) {
t.Fatalf("second message=%q", messages[1])
}
}
func TestMailImportExportAndOwnership(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login=%d", code)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-owner", "Transfer Owner", "Password123!", nil)
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-other", "Transfer Other", "Password123!", nil)
owner := &testClient{t: t, server: ts}
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("owner login=%d", code)
}
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
var imported struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
t.Fatalf("import code=%d response=%+v", code, imported)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "imported message" || list.Items[1].Subject != "older imported message" {
t.Fatalf("list code=%d items=%+v", code, list.Items)
}
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
if status != http.StatusOK {
t.Fatalf("export status=%d body=%q", status, archive)
}
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 2 {
t.Fatalf("zip entries=%d", len(zr.File))
}
entry, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
exported, err := io.ReadAll(entry)
entry.Close()
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
t.Fatalf("exported message err=%v raw=%q", err, exported)
}
var denied map[string]any
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
t.Fatalf("cross-mailbox import code=%d", code)
}
status, _ = getMailExport(t, owner, "/api/mail/export?view=unknown")
if status != http.StatusForbidden {
t.Fatalf("unknown export status=%d", status)
}
}
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("mailboxId", mailboxID)
_ = writer.WriteField("folder", folder)
for name, content := range files {
part, err := writer.CreateFormFile("files", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, client.server.URL+"/api/mail/import", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if client.cookie != nil {
req.AddCookie(client.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
t.Fatalf("decode import response: %v", err)
}
}
return resp.StatusCode
}
func getMailExport(t *testing.T, client *testClient, path string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, client.server.URL+path, nil)
if err != nil {
t.Fatal(err)
}
if client.cookie != nil {
req.AddCookie(client.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, body
}
+297
View File
@@ -0,0 +1,297 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"golang.org/x/net/html"
)
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
type translateMailMessageRequest struct {
TargetLanguage string `json:"targetLanguage"`
}
type translateMailMessageResponse struct {
TranslatedText string `json:"translatedText"`
TranslatedHTML string `json:"translatedHtml,omitempty"`
SourceLanguage string `json:"sourceLanguage,omitempty"`
TargetLanguage string `json:"targetLanguage"`
Truncated bool `json:"truncated"`
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
var req translateMailMessageRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
target := normalizeTranslateTarget(req.TargetLanguage)
if target == "" {
respondError(w, http.StatusBadRequest, "unsupported target language")
return
}
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
text := strings.TrimSpace(msg.BodyText)
if text == "" {
text = strings.TrimSpace(msg.Snippet)
}
if text == "" {
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
translatedHTML := ""
if strings.TrimSpace(msg.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
var req translateMailMessageRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
target := normalizeTranslateTarget(req.TargetLanguage)
if target == "" {
respondError(w, http.StatusBadRequest, "unsupported target language")
return
}
account, ok := a.externalIMAPAccountForMailRequest(w, r)
if !ok {
return
}
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
if !ok {
return
}
client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
if err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
defer client.Close()
raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to load remote message")
return
}
stored, _, err := a.parseMaildirMessage(raw, account.Username)
text := ""
if err == nil {
text = strings.TrimSpace(stored.BodyText)
if text == "" {
text = strings.TrimSpace(stored.Snippet)
}
}
if text == "" {
text = strings.TrimSpace(remote.Snippet)
}
if text == "" {
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
translatedHTML := ""
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
if err != nil {
return "", err
}
remaining := maxChars
var translateNode func(*html.Node) error
translateNode = func(n *html.Node) error {
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
return nil
}
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
limited, _ := truncateRunes(text, remaining)
remaining -= utf8.RuneCountInString(limited)
translated, _, err := googleFreeTranslate(ctx, limited, target)
if err != nil {
return err
}
n.Data = strings.Replace(n.Data, text, translated, 1)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if err := translateNode(c); err != nil {
return err
}
}
return nil
}
for _, n := range nodes {
if err := translateNode(n); err != nil {
return "", err
}
}
var b bytes.Buffer
for _, n := range nodes {
if err := html.Render(&b, n); err != nil {
return "", err
}
}
if policy != nil {
return policy.Sanitize(b.String()), nil
}
return b.String(), nil
}
func shouldSkipHTMLTranslationElement(tag string) bool {
switch strings.ToLower(tag) {
case "script", "style", "code", "pre", "textarea":
return true
default:
return false
}
}
func containsTranslatableLetter(value string) bool {
for _, r := range value {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '\u4e00' && r <= '\u9fff') {
return true
}
}
return false
}
func normalizeTranslateTarget(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "zh", "zh-cn", "zh-hans", "zh_cn":
return "zh-CN"
case "zh-tw", "zh-hant", "zh_hk", "zh-hk", "zh-mo":
return "zh-TW"
case "en", "en-us", "en-gb":
return "en"
default:
return ""
}
}
func truncateRunes(value string, max int) (string, bool) {
if max <= 0 || utf8.RuneCountInString(value) <= max {
return value, false
}
out := make([]rune, 0, max)
for i, r := range value {
if len(out) >= max {
return string(out), i < len(value)
}
out = append(out, r)
}
return string(out), false
}
func googleFreeTranslate(ctx context.Context, text, target string) (string, string, error) {
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
params := url.Values{}
params.Set("client", "gtx")
params.Set("sl", "auto")
params.Set("tl", target)
params.Set("dt", "t")
params.Set("q", text)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleTranslateEndpoint+"?"+params.Encode(), nil)
if err != nil {
return "", "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1024))
return "", "", fmt.Errorf("google translate status %d", res.StatusCode)
}
var raw any
if err := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024)).Decode(&raw); err != nil {
return "", "", err
}
translated, source := parseGoogleTranslateResponse(raw)
translated = strings.TrimSpace(translated)
if translated == "" {
return "", source, errors.New("empty translation")
}
return translated, source, nil
}
func parseGoogleTranslateResponse(raw any) (string, string) {
root, _ := raw.([]any)
var b strings.Builder
if len(root) > 0 {
if sentences, ok := root[0].([]any); ok {
for _, item := range sentences {
parts, ok := item.([]any)
if !ok || len(parts) == 0 {
continue
}
if s, ok := parts[0].(string); ok {
b.WriteString(s)
}
}
}
}
source := ""
if len(root) > 2 {
if s, ok := root[2].(string); ok {
source = s
}
}
return b.String(), source
}
@@ -0,0 +1,32 @@
package app
import "testing"
func TestParseGoogleTranslateResponse(t *testing.T) {
raw := []any{
[]any{
[]any{"你好", "Hello", nil, nil, float64(3)},
[]any{",世界", ", world", nil, nil, float64(3)},
},
nil,
"en",
}
translated, source := parseGoogleTranslateResponse(raw)
if translated != "你好,世界" {
t.Fatalf("translated = %q", translated)
}
if source != "en" {
t.Fatalf("source = %q", source)
}
}
func TestTruncateRunes(t *testing.T) {
got, truncated := truncateRunes("你好world", 4)
if got != "你好wo" || !truncated {
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
}
got, truncated = truncateRunes("你好", 4)
if got != "你好" || truncated {
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
}
}
+197
View File
@@ -0,0 +1,197 @@
package app
import (
"net/http"
"strings"
"sync"
"time"
)
const maxMaildirRecentErrors = 10
type maildirSyncCounts struct {
FilesScanned int `json:"filesScanned"`
Imported int `json:"imported"`
Backfilled int `json:"backfilled"`
Cleaned int `json:"cleaned"`
FileErrors int `json:"fileErrors"`
fileErrorDetails []string `json:"-"`
}
func (c maildirSyncCounts) total() int {
return c.Imported + c.Backfilled + c.Cleaned
}
type maildirSyncRun struct {
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Counts maildirSyncCounts `json:"counts"`
}
type maildirSyncHealthResponse struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
Root string `json:"root"`
ScanSeconds int `json:"scanSeconds"`
WorkerStarted bool `json:"workerStarted"`
Running bool `json:"running"`
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
LastError string `json:"lastError,omitempty"`
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
RecentErrors []string `json:"recentErrors"`
Summary maildirSyncCounts `json:"summary"`
}
type maildirSyncHealthTracker struct {
mu sync.Mutex
workerStarted bool
running bool
current *maildirSyncRun
lastRun *maildirSyncRun
lastError string
nextRunAt *time.Time
recentErrors []string
summary maildirSyncCounts
}
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
return &maildirSyncHealthTracker{}
}
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = true
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) markWorkerStopped() {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = false
h.nextRunAt = nil
}
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
h.running = true
h.current = run
h.lastRun = cloneMaildirSyncRun(run)
}
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := h.current
if run == nil {
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
}
finished := finishedAt.UTC()
run.FinishedAt = &finished
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
run.Counts = counts
run.Status = "success"
run.Error = ""
if err != nil {
run.Status = "error"
run.Error = err.Error()
h.lastError = run.Error
h.pushRecentError(run.Error)
} else if counts.FileErrors > 0 {
run.Status = "partial"
if len(counts.fileErrorDetails) > 0 {
run.Error = counts.fileErrorDetails[0]
h.lastError = run.Error
}
for _, detail := range counts.fileErrorDetails {
h.pushRecentError(detail)
}
} else {
h.lastError = ""
}
h.summary.FilesScanned += counts.FilesScanned
h.summary.Imported += counts.Imported
h.summary.Backfilled += counts.Backfilled
h.summary.Cleaned += counts.Cleaned
h.summary.FileErrors += counts.FileErrors
h.running = false
h.current = nil
h.lastRun = cloneMaildirSyncRun(run)
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
root := strings.TrimSpace(cfg.MaildirRoot)
scanSeconds := cfg.MaildirScanSeconds
if scanSeconds <= 0 {
scanSeconds = 30
}
out := maildirSyncHealthResponse{
Configured: root != "",
Enabled: root != "",
Root: root,
ScanSeconds: scanSeconds,
}
if h == nil {
return out
}
h.mu.Lock()
defer h.mu.Unlock()
out.WorkerStarted = h.workerStarted
out.Running = h.running
out.LastRun = cloneMaildirSyncRun(h.lastRun)
out.LastError = h.lastError
out.NextRunAt = cloneTimePtr(h.nextRunAt)
out.RecentErrors = append([]string(nil), h.recentErrors...)
out.Summary = h.summary
return out
}
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
h.recentErrors = append([]string{value}, h.recentErrors...)
if len(h.recentErrors) > maxMaildirRecentErrors {
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
}
}
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
if in == nil {
return nil
}
out := *in
out.FinishedAt = cloneTimePtr(in.FinishedAt)
return &out
}
func cloneTimePtr(in *time.Time) *time.Time {
if in == nil {
return nil
}
out := in.UTC()
return &out
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
}
@@ -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)
}
+288 -42
View File
@@ -45,58 +45,83 @@ type parsedMail struct {
}
func (a *App) maildirWorker(ctx context.Context) {
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
if n, err := a.syncMaildirOnce(ctx); err != nil {
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n > 0 {
a.log.Info("initial maildir sync imported messages", "count", n)
} else if n := counts.total(); n > 0 {
a.log.Info("initial maildir sync processed messages", "count", n)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.maildirHealth.markWorkerStopped()
a.log.Info("maildir sync worker stopped")
return
case <-ticker.C:
n, err := a.syncMaildirOnce(ctx)
counts, err := a.syncMaildirOnceTracked(ctx, interval)
if err != nil {
a.log.Warn("maildir sync failed", "error", err)
continue
}
if n > 0 {
a.log.Info("maildir sync imported messages", "count", n)
if n := counts.total(); n > 0 {
a.log.Info("maildir sync processed messages", "count", n)
}
}
}
}
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
startedAt := a.now().UTC()
a.maildirHealth.markRunStarted(startedAt)
counts, err := a.syncMaildirOnceDetailed(ctx)
finishedAt := a.now().UTC()
var nextRunAt *time.Time
if interval > 0 && err == nil {
next := finishedAt.Add(interval)
nextRunAt = &next
}
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
return counts, err
}
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
counts, err := a.syncMaildirOnceDetailed(ctx)
return counts.total(), err
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return 0, nil
return maildirSyncCounts{}, nil
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
return 0, err
return maildirSyncCounts{}, err
}
imported := 0
counts := maildirSyncCounts{}
for _, mb := range mailboxes {
if mb.Unregistered {
count, err := a.syncUnregisteredMaildir(ctx, mb)
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
counts.FilesScanned += mbCounts.FilesScanned
counts.Imported += mbCounts.Imported
counts.FileErrors += mbCounts.FileErrors
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
if err != nil {
return imported, err
return counts, err
}
imported += count
continue
}
folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil {
return imported, err
return counts, err
}
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
for _, folder := range folders {
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return imported, ctx.Err()
return counts, ctx.Err()
default:
}
dir := filepath.Join(folderBase, sub)
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
if errors.Is(err, os.ErrNotExist) {
continue
}
return imported, err
return counts, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("maildir file import failed", "path", path, "error", err)
continue
}
if ok {
imported++
counts.Imported++
}
}
}
}
}
return imported, nil
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
if err != nil {
return counts, err
}
counts.Backfilled += backfilled
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
if err != nil {
return counts, err
}
counts.Cleaned += cleaned
return counts, nil
}
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
@@ -152,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil {
return nil, err
}
if a.cfg.CatchAllEnabled {
if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
@@ -178,13 +216,13 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
imported := 0
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return imported, ctx.Err()
return counts, ctx.Err()
default:
}
dir := filepath.Join(base, sub)
@@ -193,24 +231,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
if errors.Is(err, os.ErrNotExist) {
continue
}
return imported, err
return counts, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
continue
}
if ok {
imported++
counts.Imported++
}
}
}
return imported, nil
return counts, nil
}
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
@@ -248,6 +289,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
return false, err
} else if exists {
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
_, err = a.insertMessage(ctx, msg, attachments)
@@ -291,7 +333,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
@@ -311,11 +353,20 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
return false, err
} else if exists {
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
}
return false, nil
}
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
} else if handled {
return false, nil
}
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
a.processInboundForwarding(ctx, id, mb.ID, raw)
}
return err == nil, err
}
@@ -338,6 +389,200 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
var oldRead, oldStarred int
var oldModSeq int64
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
if err == nil {
if oldFolderID != folderID {
if oldFolderID != "" {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
return err == nil, err
}
modSeq := oldModSeq
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return false, err
}
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
return err == nil, err
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if strings.TrimSpace(messageID) == "" {
return false, nil
}
type candidate struct {
ID string
RawPath string
}
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
if err != nil {
return false, err
}
var chosen candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
rows.Close()
return false, err
}
if c.RawPath == "" || c.RawPath == rawPath {
chosen = c
break
}
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
if err != nil {
rows.Close()
return false, err
}
if ok {
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
chosen = c
break
} else if err != nil {
rows.Close()
return false, err
}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return false, err
}
if err := rows.Close(); err != nil {
return false, err
}
if chosen.ID == "" {
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
return false, nil
}
var previousFolderID string
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
return false, err
}
if previousFolderID != "" && previousFolderID != folderID {
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
return err == nil, err
}
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
var existing string
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
if err != nil || existing == "" || existing == rawPath {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
if err != nil {
return 0, err
}
type item struct {
ID string
RawPath string
}
var missing []item
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
rows.Close()
return 0, err
}
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
if err != nil {
rows.Close()
return 0, err
}
if !ok {
continue
}
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
missing = append(missing, it)
} else if err != nil {
rows.Close()
return 0, err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, it := range missing {
a.deleteMessageFiles(ctx, it.ID)
var folderID sql.NullString
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
return 0, err
}
if folderID.Valid && folderID.String != "" {
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
return 0, err
}
}
}
return len(missing), nil
}
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
base := filepath.Base(path)
flags := ""
hasFlags := false
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
if idx := strings.LastIndex(base, sep); idx >= 0 {
flags = base[idx+len(sep):]
hasFlags = true
break
}
}
if hasFlags {
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
}
return !strings.EqualFold(folderName, "Inbox"), false
}
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
@@ -382,19 +627,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
receivedAt = sentAt
}
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
}, parsed.Attachments, nil
}
+613
View File
@@ -0,0 +1,613 @@
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
}
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if folderID != "" {
oldFolderID := state.FolderID
state.FolderID = folderID
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return err
}
}
}
if state.MailboxID == "" || state.FolderID == "" {
return nil
}
if !replace && state.RawPath != "" {
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
return err
} else if ok {
if _, err := os.Stat(state.RawPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, state.FolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
filename := maildirFilename(messageID, state.MessageID)
tmpPath := filepath.Join(folderBase, "tmp", filename)
finalPath := filepath.Join(folderBase, subdir, filename)
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
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
}
if replace || state.RawPath != "" {
a.removeMaildirPath(ctx, state.RawPath)
}
if updateFolder {
if state.IMAPUID > 0 && folderID == "" {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
} else {
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
} else {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
return err
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
}
if state.FolderID != "" && state.FolderID != targetFolderID {
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
return err
}
}
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if metaErr != nil {
return metaErr
}
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.MailboxID == "" {
return nil
}
state.FolderID = targetFolderID
if state.RawPath == "" {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
return err
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, targetFolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
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 {
return err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
msg.FolderID = folderID
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
}
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
var rawPath string
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.RawPath == "" {
return nil
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return nil
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
currentRead := state.IsRead
currentStarred := state.IsStarred
if read != nil {
currentRead = *read
}
if starred != nil {
currentStarred = *starred
}
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
return nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
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
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return
}
ok, err := a.pathIsUnderMaildirRoot(rawPath)
if err != nil || !ok {
if err != nil {
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
}
return
}
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
}
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
if err != nil {
return 0, err
}
var ids []string
for rows.Next() {
select {
case <-ctx.Done():
rows.Close()
return 0, ctx.Err()
default:
}
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
count := 0
for _, id := range ids {
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
return count, err
}
count++
}
return count, nil
}
type maildirMessageState struct {
MailboxID string
FolderID string
MessageID string
RawPath string
IsRead bool
IsStarred bool
IMAPUID int64
IMAPModSeq int64
}
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
var state maildirMessageState
var mailboxID, folderID sql.NullString
var read, starred int
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
if err != nil {
return state, err
}
state.MailboxID = mailboxID.String
state.FolderID = folderID.String
state.IsRead = intBool(read)
state.IsStarred = intBool(starred)
return state, nil
}
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
var msg storedMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred int
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
if err != nil {
return msg, err
}
msg.To = jsonDecodeSlice(toJSON)
msg.CC = jsonDecodeSlice(ccJSON)
msg.BCC = jsonDecodeSlice(bccJSON)
msg.SentAt = parseTime(sent)
msg.ReceivedAt = parseTime(received)
msg.IsRead = intBool(read)
msg.IsStarred = intBool(starred)
return msg, nil
}
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AttachmentInput
for rows.Next() {
var filename, contentType, storagePath string
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
return nil, err
}
data, err := os.ReadFile(storagePath)
if err != nil {
return nil, err
}
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
}
return out, rows.Err()
}
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
var mb maildirMailbox
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
return mb, err
}
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
var name string
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
return name, err
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return false, err
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return false, err
}
rel, err := filepath.Rel(rootAbs, pathAbs)
if err != nil {
return false, err
}
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
}
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 == "" {
base = messageID
}
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
}
func safeMaildirName(value string) string {
value = strings.Trim(value, "<>")
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '_', r == '-', r == '@':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.Trim(b.String(), "._-")
if out == "" {
out = "message"
}
if len(out) > 120 {
out = out[:120]
}
return out
}
func messageDate(msg storedMessage) time.Time {
if !msg.SentAt.IsZero() {
return msg.SentAt
}
if !msg.ReceivedAt.IsZero() {
return msg.ReceivedAt
}
return time.Now().UTC()
}
func maildirPathWithFlags(path string, read, starred bool) string {
dir := filepath.Dir(path)
name := filepath.Base(path)
if read || starred {
dir = filepath.Join(filepath.Dir(dir), "cur")
} else if filepath.Base(dir) == "cur" {
dir = filepath.Join(filepath.Dir(dir), "new")
}
base := name
sep := maildirFlagSeparator()
existingFlags := ""
if idx := strings.LastIndex(base, sep); idx >= 0 {
existingFlags = base[idx+len(sep):]
base = base[:idx]
}
flags := preserveMaildirFlags(existingFlags, "SF")
if read {
flags = appendMaildirFlag(flags, 'S')
}
if starred {
flags = appendMaildirFlag(flags, 'F')
}
if flags != "" {
base += sep + flags
}
return filepath.Join(dir, base)
}
func preserveMaildirFlags(flags, managed string) string {
var b strings.Builder
for _, flag := range flags {
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
continue
}
b.WriteRune(flag)
}
return b.String()
}
func appendMaildirFlag(flags string, flag rune) string {
if strings.ContainsRune(flags, flag) {
return flags
}
return flags + string(flag)
}
func maildirFlagSeparator() string {
if runtime.GOOS == "windows" {
return "!2,"
}
return ":2,"
}
+1 -1
View File
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
}
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
}
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
+414
View File
@@ -0,0 +1,414 @@
package app
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const deliveryWebhookMaxAge = 5 * time.Minute
type deliveryWebhookEvent struct {
ID string `json:"id"`
Provider string `json:"provider"`
QueueID string `json:"queueId"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason"`
OccurredAt string `json:"occurredAt"`
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
}
timestamp := strings.TrimSpace(r.Header.Get("X-LanQin-Timestamp"))
signature := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("X-LanQin-Signature")), "sha256=")
unix, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || signature == "" {
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
return
}
signedAt := time.Unix(unix, 0)
if delta := a.now().UTC().Sub(signedAt); delta < -deliveryWebhookMaxAge || delta > deliveryWebhookMaxAge {
respondError(w, http.StatusUnauthorized, "webhook timestamp is outside the allowed window")
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
badRequest(w, errors.New("invalid webhook body"))
return
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
expected, err := hex.DecodeString(signature)
if err != nil || !hmac.Equal(mac.Sum(nil), expected) {
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
return
}
var payload struct {
Events []deliveryWebhookEvent `json:"events"`
}
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.DisallowUnknownFields()
if err := dec.Decode(&payload); err != nil || len(payload.Events) == 0 || len(payload.Events) > 100 {
badRequest(w, errors.New("events must contain between 1 and 100 items"))
return
}
accepted := 0
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start delivery event transaction")
return
}
defer tx.Rollback()
for _, event := range payload.Events {
inserted, err := a.storeDeliveryEvent(r, tx, event)
if err != nil {
badRequest(w, err)
return
}
if inserted {
accepted++
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to store delivery events")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "accepted": accepted, "duplicates": len(payload.Events) - accepted})
}
func (a *App) storeDeliveryEvent(r *http.Request, tx *sql.Tx, event deliveryWebhookEvent) (bool, error) {
event.ID = strings.TrimSpace(event.ID)
event.Provider = strings.ToLower(strings.TrimSpace(event.Provider))
event.QueueID = strings.TrimSpace(event.QueueID)
event.MessageID = strings.TrimSpace(event.MessageID)
event.RFCMessageID = strings.TrimSpace(event.RFCMessageID)
event.Recipient = normalizeEmail(event.Recipient)
event.Status = strings.ToLower(strings.TrimSpace(event.Status))
if event.ID == "" || len(event.ID) > 200 || event.Provider == "" || len(event.Provider) > 80 || event.Recipient == "" || len(event.Recipient) > 320 || len(event.Reason) > 2000 || !validDeliveryEventStatus(event.Status) {
return false, errors.New("invalid delivery event")
}
if event.QueueID == "" && event.MessageID == "" && event.RFCMessageID == "" {
return false, errors.New("queueId, messageId, or rfcMessageId is required")
}
occurredAt, err := time.Parse(time.RFC3339Nano, event.OccurredAt)
if err != nil {
return false, errors.New("occurredAt must be an RFC3339 timestamp")
}
var queueID, sentMessageID, rfcMessageID string
err = tx.QueryRowContext(r.Context(), `SELECT id,sent_message_id,message_id FROM send_queue
WHERE (?<>'' AND id=?) OR (?<>'' AND sent_message_id=?) OR (?<>'' AND message_id=?)
ORDER BY created_at DESC LIMIT 1`, event.QueueID, event.QueueID, event.MessageID, event.MessageID, event.RFCMessageID, event.RFCMessageID).Scan(&queueID, &sentMessageID, &rfcMessageID)
if err != nil {
return false, errors.New("send item not found")
}
if (event.QueueID != "" && event.QueueID != queueID) || (event.MessageID != "" && event.MessageID != sentMessageID) || (event.RFCMessageID != "" && event.RFCMessageID != rfcMessageID) {
return false, errors.New("delivery event identifiers do not refer to the same send item")
}
var recipientsJSON string
if err := tx.QueryRowContext(r.Context(), `SELECT recipients_json FROM send_queue WHERE id=?`, queueID).Scan(&recipientsJSON); err != nil {
return false, errors.New("send item not found")
}
foundRecipient := false
for _, recipient := range jsonDecodeSlice(recipientsJSON) {
if normalizeEmail(recipient) == event.Recipient {
foundRecipient = true
break
}
}
if !foundRecipient {
return false, errors.New("delivery event recipient does not belong to the send item")
}
id := newID("dev")
createdAt := a.now().UTC()
reason := strings.TrimSpace(event.Reason)
res, err := tx.ExecContext(r.Context(), `INSERT OR IGNORE INTO delivery_events(id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, event.ID, event.Provider, queueID, sentMessageID, rfcMessageID, event.Recipient, event.Status, reason, occurredAt.UTC().Format(time.RFC3339Nano), createdAt.Format(time.RFC3339Nano))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n > 0 {
item := DeliveryEvent{ID: id, ExternalID: event.ID, Provider: event.Provider, QueueID: queueID, MessageID: sentMessageID, RFCMessageID: rfcMessageID, Recipient: event.Recipient, Status: event.Status, Reason: reason, OccurredAt: occurredAt.UTC(), CreatedAt: createdAt}
var mailboxID string
if err := tx.QueryRowContext(r.Context(), `SELECT mailbox_id FROM send_queue WHERE id=?`, queueID).Scan(&mailboxID); err != nil {
return false, err
}
if err := a.enqueueStatusWebhook(r.Context(), tx, "delivery:"+event.Provider+":"+event.ID, "delivery."+event.Status, mailboxID, item); err != nil {
return false, err
}
}
return n > 0, nil
}
func validDeliveryEventStatus(status string) bool {
switch status {
case "delivered", "bounced", "complained", "rejected", "deferred":
return true
default:
return false
}
}
func (a *App) handleOpenAPIListSends(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
limit := parseOpenAPILimit(r, 30, 100)
where := "mb.user_id=?"
args := []any{user.ID}
if mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")); mailboxID != "" {
where += " AND sq.mailbox_id=?"
args = append(args, mailboxID)
}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
if !validSendQueueStatus(status) {
badRequest(w, errors.New("invalid send queue status"))
return
}
where += " AND sq.status=?"
args = append(args, status)
}
cursorCreatedAt, cursorID, _, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
if cursorCreatedAt != "" {
where += " AND (sq.created_at<? OR (sq.created_at=? AND sq.id<?))"
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
}
args = append(args, limit+1)
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
WHERE `+where+` ORDER BY sq.created_at DESC,sq.id DESC LIMIT ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list sends")
return
}
defer rows.Close()
items := []openAPISendStatus{}
for rows.Next() {
item, err := scanSendQueueEntry(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan sends")
return
}
status := openAPISendStatusFromQueue(item, item.MailFrom)
a.applyDeliveryStatus(r.Context(), &status)
items = append(items, status)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list sends")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeSendQueueCursor(last.CreatedAt, last.QueueID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPISendEvents(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
audit, err := a.sendAuditEvents(r, item.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send events")
return
}
delivery, err := a.deliveryEvents(r, item.SentMessageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load delivery events")
return
}
respondJSON(w, http.StatusOK, map[string]any{"auditEvents": audit, "deliveryEvents": delivery})
}
func (a *App) handleOpenAPIRetrySend(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
if item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send item is not failed"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`, sendQueueStatusQueued, now, now, item.ID, sendQueueStatusFailed)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to retry send item")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusConflict, "send item status changed")
return
}
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
}
func (a *App) handleOpenAPICancelSend(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send item cannot be canceled"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?)`, sendQueueStatusCanceled, now, item.ID, sendQueueStatusQueued, sendQueueStatusFailed)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to cancel send item")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusConflict, "send item status changed")
return
}
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
}
func sendAuditInputFromEntry(item SendQueueEntry, userID, errorText string) sendAuditInput {
return sendAuditInput{QueueID: item.ID, UserID: userID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, Error: errorText}
}
func (a *App) resolveOpenAPISendQueue(r *http.Request, id string) (SendQueueEntry, error) {
user := currentUser(r)
if item, err := a.loadSendQueueEntryForUser(r.Context(), strings.TrimSpace(id), user.ID); err == nil {
return item, nil
}
return a.loadLatestSendQueueForMessage(r.Context(), strings.TrimSpace(id), user.ID)
}
func (a *App) handleOpenAPIMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleOpenAPIListAliases(w http.ResponseWriter, r *http.Request) {
limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases
WHERE (?='' OR source>? OR (source=? AND id>?)) ORDER BY source,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list aliases")
return
}
defer rows.Close()
items := []Alias{}
for rows.Next() {
item, err := scanOpenAPIAlias(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan aliases")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list aliases")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Source, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPIGetAlias(w http.ResponseWriter, r *http.Request) {
item, err := scanOpenAPIAlias(a.db.QueryRowContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases WHERE id=?`, chi.URLParam(r, "id")))
if err != nil {
respondError(w, http.StatusNotFound, "alias not found")
return
}
respondJSON(w, http.StatusOK, item)
}
type aliasScanner interface{ Scan(...any) error }
func scanOpenAPIAlias(row aliasScanner) (Alias, error) {
var item Alias
var enabled int
var created string
err := row.Scan(&item.ID, &item.DomainID, &item.Source, &item.Destination, &enabled, &created)
item.Enabled = intBool(enabled)
item.CreatedAt = parseTime(created)
return item, err
}
func (a *App) sendAuditEvents(r *http.Request, queueID string) ([]SendAuditEvent, error) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at FROM send_audit_events WHERE queue_id=? ORDER BY created_at,id`, queueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SendAuditEvent{}
for rows.Next() {
var item SendAuditEvent
var recipientsJSON, createdAt string
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
return nil, err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) deliveryEvents(r *http.Request, sentMessageID string) ([]DeliveryEvent, error) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at FROM delivery_events WHERE sent_message_id=? ORDER BY occurred_at,id`, sentMessageID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DeliveryEvent{}
for rows.Next() {
var item DeliveryEvent
var occurredAt, createdAt string
if err := rows.Scan(&item.ID, &item.ExternalID, &item.Provider, &item.QueueID, &item.MessageID, &item.RFCMessageID, &item.Recipient, &item.Status, &item.Reason, &occurredAt, &createdAt); err != nil {
return nil, err
}
item.OccurredAt = parseTime(occurredAt)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
return items, rows.Err()
}
+925
View File
@@ -0,0 +1,925 @@
package app
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains
WHERE (?='' OR name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list domains")
return
}
defer rows.Close()
items := []Domain{}
for rows.Next() {
item, err := scanDomain(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan domains")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list domains")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Name, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
id, err := a.createDomainTx(r.Context(), nil, req.Name)
if err != nil {
badRequest(w, err)
return
}
domain, err := a.domainByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load domain")
return
}
respondJSON(w, http.StatusCreated, domain)
}
func (a *App) handleOpenAPIGetDomain(w http.ResponseWriter, r *http.Request) {
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
return
}
respondJSON(w, http.StatusOK, domain)
}
func (a *App) handleOpenAPIUpdateDomain(w http.ResponseWriter, r *http.Request) {
var req struct {
Status string `json:"status"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
status := strings.TrimSpace(req.Status)
if status != "active" && status != "disabled" {
badRequest(w, errors.New("invalid status"))
return
}
id := chi.URLParam(r, "id")
res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update domain")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "domain not found")
return
}
domain, err := a.domainByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load domain")
return
}
respondJSON(w, http.StatusOK, domain)
}
func (a *App) handleOpenAPIDeleteDomain(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check domain")
return
}
if count > 0 {
badRequest(w, errors.New("domain still has mailboxes"))
return
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete domain")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "domain not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) {
limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE (?='' OR mb.address>? OR (mb.address=? AND mb.id>?)) ORDER BY mb.address,mb.id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
return
}
defer rows.Close()
items := []Mailbox{}
for rows.Next() {
item, err := scanMailbox(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Address, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) {
var req struct {
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if err := requireString("domainId", req.DomainID); err != nil {
badRequest(w, err)
return
}
if err := requireString("localPart", req.LocalPart); err != nil {
badRequest(w, err)
return
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
return
}
localPart := normalizeLocalPart(req.LocalPart)
if localPart == "" {
badRequest(w, errors.New("localPart is required"))
return
}
address := localPart + "@" + domain.Name
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = address
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
userID, err := a.resolveMailboxOwnerTx(r.Context(), tx, req.UserID, req.OwnerEmail, address, displayName, string(passwordHash))
if err != nil {
respondMailboxOwnerError(w, err)
return
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
if err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
mailbox, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
return
}
respondJSON(w, http.StatusCreated, mailbox)
}
func (a *App) handleOpenAPIGetMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
respondJSON(w, http.StatusOK, mailbox)
}
func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
UserID string `json:"userId"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = current.DisplayName
}
quotaMB := req.QuotaMB
if quotaMB <= 0 {
quotaMB = current.QuotaMB
}
status := strings.TrimSpace(req.Status)
if status == "" {
status = current.Status
}
if status != "active" && status != "disabled" {
badRequest(w, errors.New("invalid status"))
return
}
userID := strings.TrimSpace(req.UserID)
if userID == "" {
userID = current.UserID
}
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
respondMailboxOwnerError(w, err)
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
mailbox, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
return
}
respondJSON(w, http.StatusOK, mailbox)
}
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var owner string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
current := currentUser(r)
if current != nil && owner == current.ID {
var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return
}
if count <= 1 {
badRequest(w, errors.New("cannot delete your last mailbox"))
return
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
return
}
messageIDs := []string{}
for rows.Next() {
var messageID string
if rows.Scan(&messageID) == nil {
messageIDs = append(messageIDs, messageID)
}
}
rows.Close()
for _, messageID := range messageIDs {
a.deleteMessage(r.Context(), messageID)
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
var userID string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, chi.URLParam(r, "id")).Scan(&userID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?,updated_at=? WHERE id=?`, string(hash), now, userID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to reset password")
return
}
res, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?,updated_at=? WHERE user_id=?`, string(hash), now, userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to reset mailbox passwords")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save password")
return
}
affected, _ := res.RowsAffected()
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affectedMailboxes": affected})
}
func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
var req mailComposeInput
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
requestJSON, _ := json.Marshal(req)
requestSum := sha256.Sum256(requestJSON)
requestHash := hex.EncodeToString(requestSum[:])
if idempotencyKey != "" {
if len(idempotencyKey) > 128 || strings.ContainsAny(idempotencyKey, "\r\n") {
badRequest(w, errors.New("invalid Idempotency-Key"))
return
}
status, replayed, err := a.reserveOpenAPISendIdempotency(r.Context(), currentUser(r).ID, idempotencyKey, requestHash)
if err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
if replayed {
w.Header().Set("Idempotency-Replayed", "true")
respondJSON(w, http.StatusOK, status)
return
}
}
msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI)
if err != nil {
if idempotencyKey != "" {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=? AND sent_message_id=''`, currentUser(r).ID, idempotencyKey)
}
respondSendError(w, err)
return
}
status := openAPISendStatusFromMessage(msg, mb.Address)
if msg.SendQueueID != "" {
if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil {
status = openAPISendStatusFromQueue(item, mb.Address)
}
} else {
item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID)
if err == nil {
status = openAPISendStatusFromQueue(item, mb.Address)
}
}
a.applyDeliveryStatus(r.Context(), &status)
if idempotencyKey != "" {
_, _ = a.db.ExecContext(r.Context(), `UPDATE send_idempotency_keys SET sent_message_id=?,queue_id=? WHERE user_id=? AND idempotency_key=?`, status.MessageID, status.QueueID, currentUser(r).ID, idempotencyKey)
}
respondJSON(w, http.StatusCreated, status)
}
func (a *App) handleOpenAPISendStatus(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
if err != nil {
item, err = a.loadSendQueueEntryForSentMessage(r.Context(), id, user.ID)
}
if err == nil {
mailboxAddress := ""
if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil {
mailboxAddress = mb.Address
}
status := openAPISendStatusFromQueue(item, mailboxAddress)
a.applyDeliveryStatus(r.Context(), &status)
respondJSON(w, http.StatusOK, status)
return
}
msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
mailboxAddress := ""
if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil {
mailboxAddress = mb.Address
}
respondJSON(w, http.StatusOK, openAPISendStatusFromMessage(msg, mailboxAddress))
}
func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
limit := parseOpenAPILimit(r, 30, 100)
cursorReceivedAt, cursorID, offset, err := parseOpenAPIMessageCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
if folder == "" {
folder = "Inbox"
}
where := "m.mailbox_id=?"
args := []any{mailboxID}
if folder != "" && !strings.EqualFold(folder, "all") {
where += " AND lower(f.name)=lower(?)"
args = append(args, folder)
}
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
where += " AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like, like, like, like, like)
}
if cursorReceivedAt != "" {
where += " AND (m.received_at<? OR (m.received_at=? AND m.id<?))"
args = append(args, cursorReceivedAt, cursorReceivedAt, cursorID)
}
args = append(args, limit+1)
query := `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m JOIN folders f ON f.id=m.folder_id
WHERE ` + where + `
ORDER BY m.received_at DESC,m.id DESC LIMIT ?`
if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load messages")
return
}
defer rows.Close()
items := []MailMessage{}
for rows.Next() {
item, err := scanMessageSummary(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan messages")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load messages")
return
}
nextCursor := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
nextCursor = encodeOpenAPIMessageCursor(last.ReceivedAt, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor})
}
type domainScanner interface{ Scan(dest ...any) error }
func scanDomain(row domainScanner) (Domain, error) {
var item Domain
var checked sql.NullString
var created string
err := row.Scan(&item.ID, &item.Name, &item.Status, &item.DKIMSelector, &item.DKIMPublicKey, &item.DNSStatus, &checked, &created)
if err != nil {
return item, err
}
item.DNSCheckedAt = nullableTime(checked)
item.CreatedAt = parseTime(created)
return item, nil
}
type mailboxScanner interface{ Scan(dest ...any) error }
func scanMailbox(row mailboxScanner) (Mailbox, error) {
var item Mailbox
var created string
err := row.Scan(&item.ID, &item.UserID, &item.UserEmail, &item.DomainID, &item.LocalPart, &item.Address, &item.DisplayName, &item.QuotaMB, &item.Status, &created)
if err != nil {
return item, err
}
item.CreatedAt = parseTime(created)
return item, nil
}
type openAPISendStatus struct {
ID string `json:"id"`
QueueID string `json:"queueId,omitempty"`
Status string `json:"status"`
QueueStatus string `json:"queueStatus,omitempty"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
Subject string `json:"subject,omitempty"`
Recipients []string `json:"recipients,omitempty"`
AttemptCount int `json:"attemptCount,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
RecipientStatuses []openAPIRecipientStatus `json:"recipientStatuses,omitempty"`
}
type openAPIRecipientStatus struct {
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Provider string `json:"provider,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
}
func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus {
status := item.Status
if status == sendQueueStatusDelivered {
status = "relayed"
}
return openAPISendStatus{
ID: firstNonEmpty(item.SentMessageID, item.ID),
QueueID: item.ID,
Status: status,
QueueStatus: item.Status,
MessageID: item.SentMessageID,
RFCMessageID: item.MessageID,
MailboxID: item.MailboxID,
MailboxAddress: mailboxAddress,
Subject: item.Subject,
Recipients: item.Recipients,
AttemptCount: item.AttemptCount,
MaxAttempts: item.MaxAttempts,
NextAttemptAt: timePtr(item.NextAttemptAt),
LastError: item.LastError,
CreatedAt: item.CreatedAt,
UpdatedAt: timePtr(item.UpdatedAt),
DeliveredAt: item.DeliveredAt,
}
}
func (a *App) reserveOpenAPISendIdempotency(ctx context.Context, userID, key, requestHash string) (openAPISendStatus, bool, error) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM send_idempotency_keys WHERE created_at<?`, a.now().UTC().Add(-24*time.Hour).Format(time.RFC3339Nano))
res, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_idempotency_keys(user_id,idempotency_key,request_hash,created_at) VALUES(?,?,?,?)`, userID, key, requestHash, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return openAPISendStatus{}, false, err
}
if n, _ := res.RowsAffected(); n > 0 {
return openAPISendStatus{}, false, nil
}
var storedHash, sentMessageID, queueID string
if err := a.db.QueryRowContext(ctx, `SELECT request_hash,sent_message_id,queue_id FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=?`, userID, key).Scan(&storedHash, &sentMessageID, &queueID); err != nil {
return openAPISendStatus{}, false, err
}
if storedHash != requestHash {
return openAPISendStatus{}, false, errors.New("Idempotency-Key was already used with a different request")
}
if sentMessageID == "" {
return openAPISendStatus{}, false, errors.New("a request with this Idempotency-Key is still processing")
}
item, err := a.loadSendQueueEntryForUser(ctx, queueID, userID)
if err != nil {
return openAPISendStatus{}, false, err
}
status := openAPISendStatusFromQueue(item, item.MailFrom)
a.applyDeliveryStatus(ctx, &status)
return status, true, nil
}
func (a *App) applyDeliveryStatus(ctx context.Context, status *openAPISendStatus) {
rows, err := a.db.QueryContext(ctx, `SELECT recipient,status,reason,provider,occurred_at FROM delivery_events
WHERE sent_message_id=? ORDER BY occurred_at DESC,id DESC`, status.MessageID)
if err != nil {
return
}
defer rows.Close()
seen := map[string]bool{}
counts := map[string]int{}
for rows.Next() {
var item openAPIRecipientStatus
var occurredAt string
if rows.Scan(&item.Recipient, &item.Status, &item.Reason, &item.Provider, &occurredAt) != nil || seen[item.Recipient] {
continue
}
seen[item.Recipient] = true
item.OccurredAt = parseTime(occurredAt)
status.RecipientStatuses = append(status.RecipientStatuses, item)
counts[item.Status]++
}
if len(status.RecipientStatuses) == 0 {
return
}
if len(status.RecipientStatuses) < len(status.Recipients) || len(counts) > 1 {
status.Status = "partial"
return
}
for _, value := range []string{"complained", "bounced", "rejected", "deferred", "delivered"} {
if counts[value] > 0 {
status.Status = value
return
}
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
type openAPIMessageCursor struct {
ReceivedAt string `json:"receivedAt"`
ID string `json:"id"`
}
type openAPIListCursor struct {
Sort string `json:"sort"`
ID string `json:"id"`
}
func encodeOpenAPIMessageCursor(receivedAt time.Time, id string) string {
payload, _ := json.Marshal(openAPIMessageCursor{ReceivedAt: receivedAt.UTC().Format(time.RFC3339Nano), ID: id})
return base64.RawURLEncoding.EncodeToString(payload)
}
func parseOpenAPIMessageCursor(raw string) (receivedAt, id string, offset int, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", 0, nil
}
if n, convErr := strconv.Atoi(raw); convErr == nil {
if n < 0 {
return "", "", 0, errors.New("invalid cursor")
}
return "", "", n, nil
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", 0, errors.New("invalid cursor")
}
var cursor openAPIMessageCursor
if err := json.Unmarshal(data, &cursor); err != nil || cursor.ReceivedAt == "" || cursor.ID == "" {
return "", "", 0, errors.New("invalid cursor")
}
if _, err := time.Parse(time.RFC3339Nano, cursor.ReceivedAt); err != nil {
return "", "", 0, errors.New("invalid cursor")
}
return cursor.ReceivedAt, cursor.ID, 0, nil
}
func encodeOpenAPIListCursor(sortValue, id string) string {
payload, _ := json.Marshal(openAPIListCursor{Sort: sortValue, ID: id})
return base64.RawURLEncoding.EncodeToString(payload)
}
func parseOpenAPIListCursor(raw string) (sortValue, id string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", nil
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("invalid cursor")
}
var cursor openAPIListCursor
if err := json.Unmarshal(data, &cursor); err != nil || cursor.Sort == "" || cursor.ID == "" {
return "", "", errors.New("invalid cursor")
}
return cursor.Sort, cursor.ID, nil
}
func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus {
recipients := append(append([]string{}, msg.To...), msg.CC...)
recipients = append(recipients, msg.BCC...)
return openAPISendStatus{
ID: msg.ID,
Status: sendAuditAccepted,
MessageID: msg.ID,
RFCMessageID: msg.MessageID,
MailboxID: msg.MailboxID,
MailboxAddress: mailboxAddress,
Subject: msg.Subject,
Recipients: dedupeEmails(recipients),
CreatedAt: msg.ReceivedAt,
}
}
func timePtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, ownerEmail, address, displayName, passwordHash string) (string, error) {
userID = strings.TrimSpace(userID)
if userID != "" {
var disabled int
if err := tx.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", errNotFound
}
return "", err
}
if intBool(disabled) {
return "", errors.New("owner user is disabled")
}
return userID, nil
}
email := normalizeEmail(ownerEmail)
if email == "" {
email = address
}
if !strings.Contains(email, "@") {
return "", errors.New("invalid owner email")
}
var existing string
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
if err == nil {
return existing, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
userID = newID("usr")
now := a.now().UTC().Format(time.RFC3339Nano)
if displayName == "" {
displayName = email
}
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
return userID, err
}
func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error {
var disabled int
if err := a.db.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errNotFound
}
return err
}
if intBool(disabled) {
return errors.New("owner user is disabled")
}
return nil
}
func respondMailboxOwnerError(w http.ResponseWriter, err error) {
if errors.Is(err, errNotFound) {
respondError(w, http.StatusNotFound, "owner user not found")
return
}
if err != nil {
badRequest(w, err)
return
}
}
func respondSendError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, errNoRecipients), errors.Is(err, errInvalidMIME), errors.Is(err, errAttachmentTooLarge):
badRequest(w, err)
case errors.Is(err, errSMTPRateLimited):
respondError(w, http.StatusTooManyRequests, err.Error())
case errors.Is(err, errSenderNotAuthorized):
respondError(w, http.StatusForbidden, err.Error())
case errors.Is(err, errMailboxQuotaExceeded):
respondError(w, http.StatusInsufficientStorage, err.Error())
default:
respondError(w, http.StatusInternalServerError, err.Error())
}
}
func (a *App) loadLatestSendQueueForMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
WHERE sq.sent_message_id=? AND mb.user_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, userID)
return scanSendQueueEntry(row)
}
func (a *App) loadLatestSendQueueForMailboxMessage(ctx context.Context, sentMessageID, mailboxID string) (SendQueueEntry, error) {
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq LEFT JOIN messages m ON m.id=sq.sent_message_id
WHERE sq.sent_message_id=? AND sq.mailbox_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, mailboxID)
return scanSendQueueEntry(row)
}
func (a *App) loadSendQueueEntryForSentMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID)
}
func (a *App) loadOpenAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) {
var messageID string
err := a.db.QueryRowContext(ctx, `SELECT m.id
FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id
WHERE (m.id=? OR m.message_id=?) AND mb.user_id=? AND lower(f.name)='sent'
ORDER BY m.received_at DESC LIMIT 1`, id, id, userID).Scan(&messageID)
if err != nil {
return nil, err
}
return a.messageByID(ctx, messageID, false)
}
func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
return defaultLimit
}
if limit > maxLimit {
return maxLimit
}
return limit
}
+23
View File
@@ -0,0 +1,23 @@
package app
import "testing"
func TestHasMinimumPasswordLength(t *testing.T) {
tests := []struct {
name string
password string
want bool
}{
{name: "five ASCII characters", password: "abc12", want: false},
{name: "six ASCII characters", password: "abc123", want: true},
{name: "six Unicode characters", password: "密码测试六位", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
}
})
}
}
@@ -0,0 +1,241 @@
package app
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
}
func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, defaultPermissionLimits())
}
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
FROM permission_groups
ORDER BY created_at ASC,name ASC`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
items := []PermissionGroup{}
for rows.Next() {
var item PermissionGroup
var rawPermissions, rawLimits, created, updated string
var system int
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
return
}
item.Permissions = decodeStoredPermissions(rawPermissions)
item.Limits = decodeStoredLimits(rawLimits)
item.System = intBool(system)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
items = append(items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
if err := rows.Close(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
var adminCount, regularCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
WHERE u.role<>'admin'
AND NOT EXISTS (
SELECT 1 FROM user_permission_groups upg
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
).Scan(&regularCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
for i := range items {
switch items[i].ID {
case PermissionGroupSuperAdmin:
items[i].UserCount = adminCount
case PermissionGroupRegular:
items[i].UserCount = regularCount
default:
var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
items[i].UserCount = count
}
}
sortPermissionGroups(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
}
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
permissions, err := normalizePermissionList(req.Permissions)
if err != nil {
badRequest(w, err)
return
}
if !actorCanGrantPermissions(currentUser(r), permissions) {
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
id := newID("pg")
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
badRequest(w, err)
return
}
group, err := a.permissionGroupByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load permission group")
return
}
respondJSON(w, http.StatusCreated, group)
}
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if id == PermissionGroupSuperAdmin {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
permissions, err := normalizePermissionList(req.Permissions)
if err != nil {
badRequest(w, err)
return
}
if !actorCanGrantPermissions(currentUser(r), permissions) {
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
} else {
var rawLimits string
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
limits = decodeStoredLimits(rawLimits)
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
badRequest(w, err)
return
}
group, err := a.permissionGroupByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load permission group")
return
}
respondJSON(w, http.StatusOK, group)
}
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var system int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if intBool(system) {
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
return
}
var userCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check permission group")
return
}
if userCount > 0 {
badRequest(w, errors.New("permission group is assigned to users"))
return
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+249 -84
View File
@@ -15,6 +15,7 @@ import (
type contextKey string
const userContextKey contextKey = "user"
const apiTokenScopesContextKey contextKey = "api_token_scopes"
func (a *App) Router() http.Handler {
r := chi.NewRouter()
@@ -24,113 +25,201 @@ func (a *App) Router() http.Handler {
r.Use(middleware.Recoverer)
r.Use(a.corsMiddleware)
r.Post("/auth-policy", a.handleAuthPolicy)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
})
r.Route("/api", func(r chi.Router) {
r.Get("/public/settings", a.handlePublicSettings)
r.Get("/verify-email", a.handleVerifyForwardingEmail)
r.Post("/auth/register", a.handleRegister)
r.Post("/auth/login", a.handleLogin)
r.Post("/auth/logout", a.handleLogout)
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Get("/me/api-tokens", a.handleListAPITokens)
r.With(a.requireAuth).Post("/me/api-tokens", a.handleCreateAPIToken)
r.With(a.requireAuth).Post("/me/api-tokens/{id}", a.handleUpdateAPIToken)
r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/forwarding", a.handleForwardingSettings)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails", a.handleAddForwardingVerifiedEmail)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails/{id}/resend", a.handleResendForwardingVerifiedEmail)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/forwarding/verified-emails/{id}", a.handleDeleteForwardingVerifiedEmail)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/account", a.handleUpdateAccountForwarding)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/mailboxes/{id}/forwarding", a.handleUpdateMailboxForwarding)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}", a.handleUpdateRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/move", a.handleMoveRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/apply", a.handleApplyRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Post("/open/v1/delivery-events", a.handleOpenAPIDeliveryWebhook)
r.Route("/open", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
r.Route("/open/v1", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/mail/mailboxes", a.handleMyMailboxes)
r.Get("/mail/folders", a.handleMailFolders)
r.Get("/mail/labels", a.handleMailLabels)
r.Post("/mail/labels", a.handleCreateMailLabel)
r.Get("/mail/messages", a.handleMailMessages)
r.Get("/mail/starred", a.handleStarredMessages)
r.Get("/mail/messages/{id}", a.handleMailMessage)
r.Post("/mail/send", a.handleMailSend)
r.Get("/mail/scheduled-sends", a.handleScheduledSends)
r.Post("/mail/schedule-send", a.handleScheduleSend)
r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
r.Post("/mail/drafts", a.handleSaveDraft)
r.Post("/mail/drafts/{id}", a.handleSaveDraft)
r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.Post("/mail/messages/{id}/star", a.handleStar)
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.Post("/mail/messages/{id}/move", a.handleMove)
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.Get("/mail/attachments/{id}", a.handleAttachment)
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders/reorder", a.handleReorderMailFolders)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/folders/{id}", a.handleDeleteMailFolder)
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/export", a.handleExportMail)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/import", a.handleImportMail)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/translate", a.handleTranslateExternalIMAPMessage)
r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
})
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Use(a.requireAdmin)
r.Get("/admin/overview", a.handleAdminOverview)
r.Get("/admin/users", a.handleListUsers)
r.Post("/admin/users", a.handleCreateUser)
r.Post("/admin/users/{id}", a.handleUpdateUser)
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
r.Delete("/admin/users/{id}", a.handleDeleteUser)
r.Get("/admin/domains", a.handleListDomains)
r.Post("/admin/domains", a.handleCreateDomain)
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
r.Get("/admin/mailboxes", a.handleListMailboxes)
r.Post("/admin/mailboxes", a.handleCreateMailbox)
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
r.Get("/admin/aliases", a.handleListAliases)
r.Post("/admin/aliases", a.handleCreateAlias)
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.Get("/admin/messages", a.handleAdminMessages)
r.Get("/admin/messages/{id}", a.handleAdminMessage)
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.Get("/admin/settings", a.handleGetSystemSettings)
r.Post("/admin/settings", a.handleUpdateSystemSettings)
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.Get("/admin/mail-templates", a.handleListMailTemplates)
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
})
})
return r
}
func (a *App) registerOpenAPIRoutes(r chi.Router) {
r.Use(a.requireAPIToken)
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains", a.handleOpenAPIListDomains)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/domains", a.handleOpenAPICreateDomain)
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains/{id}", a.handleOpenAPIGetDomain)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/domains/{id}", a.handleOpenAPIUpdateDomain)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/domains/{id}", a.handleOpenAPIDeleteDomain)
r.With(a.requireAPITokenScope("dns:read"), a.requireAdminAccess, a.requirePermission(PermissionDNSView)).Get("/domains/{id}/dns-records", a.handleDNSRecords)
r.With(a.requireAPITokenScope("dns:check"), a.requireAdminAccess, a.requirePermission(PermissionDNSCheck)).Post("/domains/{id}/dns-check", a.handleDNSCheck)
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes", a.handleOpenAPIListMailboxes)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/mailboxes", a.handleOpenAPICreateMailbox)
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes/{id}", a.handleOpenAPIGetMailbox)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionUsersResetPassword)).Post("/mailboxes/{id}/password", a.handleOpenAPIResetMailboxPassword)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
r.With(a.requireAPITokenScope("messages:send"), a.requirePermission(PermissionMailSend)).Post("/send", a.handleOpenAPISendMail)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send", a.handleOpenAPIListSends)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}", a.handleOpenAPISendStatus)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}/events", a.handleOpenAPISendEvents)
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/retry", a.handleOpenAPIRetrySend)
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/cancel", a.handleOpenAPICancelSend)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/messages/{id}", a.handleOpenAPIMessage)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailAttachments)).Get("/attachments/{id}", a.handleAttachment)
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases", a.handleOpenAPIListAliases)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesCreate)).Post("/aliases", a.handleCreateAlias)
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases/{id}", a.handleOpenAPIGetAlias)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesUpdate)).Post("/aliases/{id}", a.handleUpdateAlias)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesDelete)).Delete("/aliases/{id}", a.handleDeleteAlias)
}
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Idempotency-Key")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
@@ -152,52 +241,119 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
})
}
func (a *App) requireAdmin(next http.Handler) http.Handler {
func (a *App) requireAPIToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "admin role required")
user, scopes, err := a.authenticateAPIToken(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "api token required")
return
}
next.ServeHTTP(w, r)
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, apiTokenScopesContextKey, scopes)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *App) requireAPITokenScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scopes, _ := r.Context().Value(apiTokenScopesContextKey).(map[string]bool)
if !scopes["*"] && !scopes[scope] {
respondError(w, http.StatusForbidden, "api token scope required: "+scope)
return
}
next.ServeHTTP(w, r)
})
}
}
func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User)
return user
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
cookie, err := r.Cookie(a.config().CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
return nil, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if u.Disabled {
return nil, errors.New("disabled")
}
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
return nil, err
}
return &u, nil
}
func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, error) {
token := bearerToken(r)
if token == "" {
return nil, nil, errors.New("no api token")
}
now := a.now().UTC().Format(time.RFC3339Nano)
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
FROM api_tokens at JOIN users u ON u.id=at.user_id
WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
var tokenID, scopesJSON string
var u User
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string
if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
return nil, nil, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if u.Disabled {
return nil, nil, errors.New("disabled")
}
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
return nil, nil, err
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID)
scopes := map[string]bool{}
for _, scope := range jsonDecodeSlice(scopesJSON) {
scopes[scope] = true
}
return &u, scopes, nil
}
func bearerToken(r *http.Request) string {
fields := strings.Fields(strings.TrimSpace(r.Header.Get("Authorization")))
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
return ""
}
return fields[1]
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
loginName := normalizeLoginName(email)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
FROM users WHERE login_name=? OR email=?
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
var u User
var passwordHash string
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil {
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound
}
@@ -205,16 +361,21 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err
}
return &u, passwordHash, nil
}
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound
}
@@ -222,6 +383,10 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, err
}
return &u, nil
}
+467
View File
@@ -0,0 +1,467 @@
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
const (
sendQueueStatusQueued = "queued"
sendQueueStatusSending = "sending"
sendQueueStatusDelivered = "delivered"
sendQueueStatusFailed = "failed"
sendQueueStatusCanceled = "canceled"
sendAuditAccepted = "accepted"
sendAuditQueued = "queued"
sendAuditDelivered = "delivered"
sendAuditFailed = "failed"
sendAuditRetry = "retry"
sendAuditCanceled = "canceled"
sendSourceWebmail = "webmail"
sendSourceSubmission = "submission"
sendSourceOpenAPI = "open_api"
sendSourceForwarding = "forwarding"
sendSourceRuleForwarding = "rule_forwarding"
sendSourceForwardingVerification = "forwarding_verification"
sendQueueStaleAfter = 15 * time.Minute
sendQueueConcurrency = 4
sendQueueDeliveredMarkerDir = "send_queue_delivered"
)
type sendQueueInput struct {
UserID string
MailboxID string
SentMessageID string
MessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
MIMEBytes []byte
Now time.Time
}
type sendQueueItem struct {
ID string
UserID string
MailboxID string
SentMessageID string
MessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
MIMEBytes []byte
AttemptCount int
MaxAttempts int
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
if now.IsZero() {
now = a.now().UTC()
}
id := newID("snd")
messageID := strings.TrimSpace(in.MessageID)
mimeBase64 := base64.StdEncoding.EncodeToString(in.MIMEBytes)
recipientsJSON := jsonEncode(dedupeEmails(in.Recipients))
_, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, in.UserID, in.MailboxID, in.SentMessageID, messageID, in.Source, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return "", err
}
if strings.TrimSpace(messageID) != "" {
var existingID, status string
var attemptCount, maxAttempts int
if err := a.db.QueryRowContext(ctx, `SELECT id,status,attempt_count,max_attempts FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=?`, in.MailboxID, in.Source, messageID).Scan(&existingID, &status, &attemptCount, &maxAttempts); err != nil {
return "", err
}
if existingID != id {
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
if err != nil {
return "", err
}
a.deleteSendQueueDeliveredMarker(existingID)
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
QueueID: existingID,
UserID: in.UserID,
MailboxID: in.MailboxID,
SentMessageID: in.SentMessageID,
Source: in.Source,
MailFrom: in.MailFrom,
HeaderFrom: in.HeaderFrom,
Recipients: in.Recipients,
})
}
return existingID, nil
}
}
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
QueueID: id,
UserID: in.UserID,
MailboxID: in.MailboxID,
SentMessageID: in.SentMessageID,
Source: in.Source,
MailFrom: in.MailFrom,
HeaderFrom: in.HeaderFrom,
Recipients: in.Recipients,
})
return id, nil
}
func (a *App) sendQueueWorker(ctx context.Context) {
a.log.Info("send queue worker started")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.log.Info("send queue worker stopped")
return
default:
}
if err := a.processDueSendQueue(ctx); err != nil {
a.log.Warn("send queue worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("send queue worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
return err
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM send_queue WHERE (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=? ORDER BY next_attempt_at, created_at LIMIT 20`, sendQueueStatusQueued, sendQueueStatusFailed, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return err
}
sem := make(chan struct{}, sendQueueConcurrency)
done := make(chan struct{}, len(ids))
for _, id := range ids {
select {
case <-ctx.Done():
return ctx.Err()
case sem <- struct{}{}:
}
go func(id string) {
defer func() {
<-sem
done <- struct{}{}
}()
a.processSendQueueItem(ctx, id)
}(id)
}
for range ids {
select {
case <-ctx.Done():
return ctx.Err()
case <-done:
}
}
return nil
}
func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
cutoff := a.now().UTC().Add(-sendQueueStaleAfter).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE status=? AND updated_at<=? AND attempt_count<max_attempts LIMIT 20`, sendQueueStatusSending, cutoff)
if err != nil {
return err
}
defer rows.Close()
var items []sendQueueItem
for rows.Next() {
var item sendQueueItem
var recipientsJSON, mimeBase64 string
if err := rows.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
return err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
if err != nil {
return err
}
if delivered {
items = append(items, item)
continue
}
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
if err != nil {
return err
}
item.MIMEBytes = mimeBytes
items = append(items, item)
}
if err := rows.Err(); err != nil {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range items {
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
if err != nil {
return err
}
if delivered {
if err := a.markSendQueueDelivered(ctx, item); err != nil {
return err
}
continue
}
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusQueued, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n > 0 {
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
}
}
return nil
}
func (a *App) processSendQueueItem(ctx context.Context, id string) {
item, err := a.claimSendQueueItem(ctx, id)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
a.log.Warn("failed to claim send queue item", "id", id, "error", err)
}
return
}
if err := a.sendSMTP(item.MailFrom, item.Recipients, item.MIMEBytes); err != nil {
a.markSendQueueFailed(ctx, item, err)
return
}
if err := a.writeSendQueueDeliveredMarker(item.ID); err != nil {
a.log.Warn("failed to persist send queue delivered marker", "id", item.ID, "error", err)
}
if err := a.markSendQueueDelivered(ctx, item); err != nil {
a.log.Warn("failed to mark send queue delivered", "id", item.ID, "error", err)
return
}
}
func (a *App) markSendQueueDelivered(ctx context.Context, item sendQueueItem) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,delivered_at=?,updated_at=?,last_error='',mime_base64='' WHERE id=?`, sendQueueStatusDelivered, now, now, item.ID); err != nil {
return err
}
a.deleteSendQueueDeliveredMarker(item.ID)
a.recordSendAudit(ctx, sendAuditDelivered, sendQueueStatusDelivered, sendAuditInputFromQueue(item, ""))
return nil
}
func (a *App) claimSendQueueItem(ctx context.Context, id string) (sendQueueItem, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,attempt_count=attempt_count+1,updated_at=? WHERE id=? AND (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=?`, sendQueueStatusSending, now, id, sendQueueStatusQueued, sendQueueStatusFailed, now)
if err != nil {
return sendQueueItem{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return sendQueueItem{}, sql.ErrNoRows
}
var item sendQueueItem
var recipientsJSON, mimeBase64 string
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE id=?`, id)
if err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
return sendQueueItem{}, err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
if err != nil {
return sendQueueItem{}, err
}
item.MIMEBytes = mimeBytes
return item, nil
}
func (a *App) markSendQueueFailed(ctx context.Context, item sendQueueItem, sendErr error) {
now := a.now().UTC()
status := sendQueueStatusFailed
nextAttempt := now.Add(sendRetryDelay(item.AttemptCount))
if item.AttemptCount >= item.MaxAttempts {
nextAttempt = now.Add(365 * 24 * time.Hour)
}
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=?`, status, nextAttempt.Format(time.RFC3339Nano), sendErr.Error(), now.Format(time.RFC3339Nano), item.ID)
if err != nil {
a.log.Warn("failed to mark send queue failed", "id", item.ID, "error", err)
}
event := sendAuditRetry
if item.AttemptCount >= item.MaxAttempts {
event = sendAuditFailed
}
a.recordSendAudit(ctx, event, status, sendAuditInputFromQueue(item, sendErr.Error()))
}
func sendRetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
delays := []time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute, time.Hour, 6 * time.Hour}
if attempt > len(delays) {
return delays[len(delays)-1]
}
return delays[attempt-1]
}
type sendAuditInput struct {
QueueID string
UserID string
MailboxID string
SentMessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
Error string
}
func sendAuditInputFromQueue(item sendQueueItem, errorText string) sendAuditInput {
return sendAuditInput{
QueueID: item.ID,
UserID: item.UserID,
MailboxID: item.MailboxID,
SentMessageID: item.SentMessageID,
Source: item.Source,
MailFrom: item.MailFrom,
HeaderFrom: item.HeaderFrom,
Recipients: item.Recipients,
Error: errorText,
}
}
func (a *App) recordSendAudit(ctx context.Context, event, status string, in sendAuditInput) {
source := strings.TrimSpace(in.Source)
if source == "" {
source = "unknown"
}
id := newID("audit")
createdAt := a.now().UTC()
item := SendAuditEvent{ID: id, QueueID: in.QueueID, MailboxID: in.MailboxID, SentMessageID: in.SentMessageID, Source: source, Event: event, Status: status, MailFrom: normalizeEmail(in.MailFrom), HeaderFrom: normalizeEmail(in.HeaderFrom), Recipients: dedupeEmails(in.Recipients), Error: in.Error, CreatedAt: createdAt}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
a.log.Warn("failed to start send audit transaction", "event", event, "error", err)
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, item.MailFrom, item.HeaderFrom, jsonEncode(item.Recipients), in.Error, createdAt.Format(time.RFC3339Nano)); err != nil {
a.log.Warn("failed to record send audit", "event", event, "error", err)
return
}
if err := a.enqueueStatusWebhook(ctx, tx, "audit:"+id, "send."+event, in.MailboxID, item); err != nil {
a.log.Warn("failed to enqueue send status webhook", "event", event, "error", err)
return
}
if err := tx.Commit(); err != nil {
a.log.Warn("failed to commit send audit", "event", event, "error", err)
}
}
func (a *App) sendQueueDeliveredMarkerPath(id string) string {
safeID := filepath.Base(strings.TrimSpace(id))
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
path := a.sendQueueDeliveredMarkerPath(id)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
tmp := filepath.Join(dir, filepath.Base(path)+"."+newID("tmp"))
if err := os.WriteFile(tmp, []byte(a.now().UTC().Format(time.RFC3339Nano)), 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (a *App) hasSendQueueDeliveredMarker(id string) (bool, error) {
_, err := os.Stat(a.sendQueueDeliveredMarkerPath(id))
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
func (a *App) deleteSendQueueDeliveredMarker(id string) {
err := os.Remove(a.sendQueueDeliveredMarkerPath(id))
if err != nil && !errors.Is(err, os.ErrNotExist) {
a.log.Warn("failed to remove send queue delivered marker", "id", id, "error", err)
}
}
func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (string, string, error) {
from = normalizeEmail(from)
if from == "" {
from = normalizeEmail(mb.Address)
}
if from == normalizeEmail(mb.Address) {
return normalizeEmail(mb.Address), mb.DisplayName, nil
}
var displayName string
var enabled int
err := a.db.QueryRowContext(ctx, `SELECT display_name,enabled FROM send_as_grants WHERE mailbox_id=? AND address=?`, mb.ID, from).Scan(&displayName, &enabled)
if err == nil {
if enabled == 0 {
return "", "", errSenderNotAuthorized
}
return from, strings.TrimSpace(displayName), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", err
}
var aliasDestination string
err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination)
if err == nil {
for _, destination := range strings.Split(aliasDestination, ",") {
if normalizeEmail(destination) == normalizeEmail(mb.Address) {
return from, mb.DisplayName, nil
}
}
}
return "", "", errSenderNotAuthorized
}
+3 -3
View File
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken()
sessionID := newID("ses")
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName,
Name: a.config().CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP,
Secure: !a.config().AllowInsecureHTTP,
})
return nil
}
+205 -126
View File
@@ -10,63 +10,80 @@ import (
)
type SystemSettings struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretSet bool `json:"externalImapSecretSet"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
}
type systemSettingsUpdate struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretKey string `json:"externalImapSecretKey"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
}
type PublicSettings struct {
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
}
type PublicDomain struct {
@@ -83,15 +100,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
refreshSeconds := a.cfg.MailRefreshSeconds
cfg := a.config()
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
if cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
@@ -114,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
next := a.cfg
next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
@@ -165,12 +183,33 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
next.ExternalIMAPEnabled = req.ExternalIMAPEnabled
if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" {
next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey)
}
if req.ExternalIMAPSyncSeconds <= 0 {
req.ExternalIMAPSyncSeconds = 300
}
next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds
next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts
next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID)
if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" {
next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret)
}
next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID)
if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" {
next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret)
}
if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" {
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.cfg = next
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -180,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
cfg := a.cfg
cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
@@ -213,7 +252,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
domain = "lanqin.local"
}
now := a.now().UTC()
subject := "LanQin Email SMTP 测试"
subject := "NewSzxcn 邮箱 SMTP 测试"
bodyText := "这是一封 SMTP 测试邮件。"
bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>"
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
@@ -247,33 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil {
return err
@@ -286,84 +335,114 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
}
switch key {
case "publicHostname":
a.cfg.PublicHostname = value
cfg.PublicHostname = value
case "publicBaseUrl":
a.cfg.PublicBaseURL = value
cfg.PublicBaseURL = value
case "smtpHost":
a.cfg.SMTPHost = value
cfg.SMTPHost = value
case "smtpPort":
a.cfg.SMTPPort = value
cfg.SMTPPort = value
case "smtpUsername":
a.cfg.SMTPUsername = value
cfg.SMTPUsername = value
case "smtpPassword":
a.cfg.SMTPPassword = value
cfg.SMTPPassword = value
case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true"
cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
a.cfg.MaildirRoot = value
cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n
cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n
cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true"
cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
a.cfg.OpenRegistration = value == "true"
cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true"
cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true"
cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value
cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value
cfg.TurnstileSecretKey = value
case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true"
cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true"
cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n
cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
cfg.ExternalIMAPOutlookClientSecret = value
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
a.setConfig(cfg)
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
"externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled),
"externalImapSecretKey": cfg.ExternalIMAPSecretKey,
"externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds),
"externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts),
"externalImapGmailClientId": cfg.ExternalIMAPGmailClientID,
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
+216
View File
@@ -0,0 +1,216 @@
package app
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const statusWebhookMaxAttempts = 10
type statusWebhookEnvelope struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
Data any `json:"data"`
}
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
id := newID("whk")
payload := jsonEncode(statusWebhookEnvelope{ID: id, Type: eventType, CreatedAt: now.Format(time.RFC3339Nano), Data: data})
_, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO status_webhook_outbox(id,event_key,event_type,mailbox_id,payload_json,next_attempt_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, eventKey, eventType, mailboxID, payload, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
}
func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
if err := a.processDueStatusWebhooks(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.log.Warn("status webhook worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("status webhook worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), statusWebhookMaxAttempts)
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM status_webhook_outbox
WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? ORDER BY next_attempt_at,created_at LIMIT 20`, statusWebhookMaxAttempts, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type item struct {
id, payload string
attempt int
}
items := []item{}
for rows.Next() {
var value item
if err := rows.Scan(&value.id, &value.payload, &value.attempt); err != nil {
rows.Close()
return err
}
items = append(items, value)
}
if err := rows.Close(); err != nil {
return err
}
for _, value := range items {
if err := a.deliverStatusWebhook(ctx, value.id, []byte(value.payload)); err != nil {
now := a.now().UTC()
next := now.Add(sendRetryDelay(value.attempt + 1))
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), value.id)
continue
}
now := a.now().UTC().Format(time.RFC3339Nano)
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, now, now, value.id)
}
return nil
}
func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload []byte) error {
target, err := a.validatedStatusWebhookURL(ctx)
if err != nil {
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "NewSzxcn-Email-Webhook/1.0")
req.Header.Set("X-LanQin-Webhook-Id", eventID)
req.Header.Set("X-LanQin-Timestamp", timestamp)
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Transport: &http.Transport{DialContext: a.statusWebhookDialContext, DisableKeepAlives: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second},
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status webhook returned %d", resp.StatusCode)
}
return nil
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
if !a.config().StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
}
return target, nil
}
func (a *App) statusWebhookDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isPublicStatusWebhookIP(ip) {
return nil, errors.New("private or local status webhook hosts are not allowed")
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
var lastErr error
for _, ip := range ips {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("status webhook host resolved without usable addresses")
}
return nil, lastErr
}
func validatePublicWebhookHost(ctx context.Context, host string) error {
if strings.EqualFold(host, "localhost") {
return errors.New("localhost status webhook hosts are not allowed")
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("failed to resolve status webhook host: %w", err)
}
for _, ip := range ips {
if !isPublicStatusWebhookIP(ip) {
return errors.New("private or local status webhook hosts are not allowed")
}
}
return nil
}
func isPublicStatusWebhookIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsGlobalUnicast() && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
}
func truncateWebhookError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 1000 {
return value[:1000]
}
return value
}
+506
View File
@@ -0,0 +1,506 @@
package app
import (
"bytes"
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"io"
"log"
netmail "net/mail"
"net/textproto"
"sort"
"strings"
"time"
"github.com/emersion/go-sasl"
smtpserver "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
)
const (
defaultSubmissionMaxRecipients = 200
)
type SubmissionServers struct {
Plain *smtpserver.Server
TLS *smtpserver.Server
}
func (s *SubmissionServers) Shutdown(ctx context.Context) error {
if s == nil {
return nil
}
var errs []error
if s.Plain != nil {
if err := s.Plain.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
if s.TLS != nil {
if err := s.TLS.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserver.Server {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
return s
}
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile)
if certFile == "" || keyFile == "" {
return nil, errors.New("LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled")
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &cert, nil
},
}, nil
}
type submissionLogWriter struct {
log slogLogger
}
func (w submissionLogWriter) Write(p []byte) (int, error) {
if w.log != nil {
w.log.Warn(strings.TrimSpace(string(p)))
}
return len(p), nil
}
type slogLogger interface {
Warn(msg string, args ...any)
}
type submissionBackend struct {
app *App
}
func (b submissionBackend) NewSession(*smtpserver.Conn) (smtpserver.Session, error) {
return &submissionSession{app: b.app}, nil
}
type submissionSession struct {
app *App
user *User
mailbox *Mailbox
mailFrom string
recipients []string
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
from = normalizeEmail(from)
authorized, _, err := s.app.authorizedSender(context.Background(), s.mailbox, from)
if err != nil || from == "" || from != authorized {
return smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
s.mailFrom = from
s.recipients = nil
return nil
}
func (s *submissionSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
to = normalizeEmail(to)
if to == "" || !strings.Contains(to, "@") {
return smtpError(501, smtpserver.EnhancedCode{5, 1, 3}, "invalid recipient")
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
if s.mailFrom == "" || len(s.recipients) == 0 {
return smtpError(503, smtpserver.EnhancedCode{5, 5, 1}, "missing sender or recipients")
}
if err := s.app.submitSMTPMessage(context.Background(), s.user, s.mailbox, s.mailFrom, s.recipients, r); err != nil {
var smtpErr *smtpserver.SMTPError
if errors.As(err, &smtpErr) {
return smtpErr
}
return smtpError(451, smtpserver.EnhancedCode{4, 0, 0}, "message submission failed")
}
s.Reset()
return nil
}
func (s *submissionSession) Reset() {
s.mailFrom = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.Reset()
return nil
}
func (a *App) authenticateSubmission(ctx context.Context, username, password string) (*User, *Mailbox, error) {
address := normalizeEmail(username)
if address == "" {
return nil, nil, errors.New("missing username")
}
var mb Mailbox
var passwordHash, created string
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at
FROM mailboxes WHERE address=? AND status='active'`, address)
if err := row.Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &passwordHash, &mb.QuotaMB, &mb.Status, &created); err != nil {
return nil, nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return nil, nil, err
}
mb.CreatedAt = parseTime(created)
user, err := a.userByID(ctx, mb.UserID)
if err != nil {
return nil, nil, err
}
if user.Disabled {
return nil, nil, errors.New("user disabled")
}
if !userHasPermission(user, PermissionMailSend) {
return nil, nil, errors.New("send permission required")
}
return user, &mb, nil
}
func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, mailFrom string, recipients []string, r io.Reader) error {
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
if errors.Is(err, errSMTPRateLimited) {
return smtpError(452, smtpserver.EnhancedCode{4, 7, 0}, err.Error())
}
return err
}
raw, err := io.ReadAll(r)
if err != nil {
return err
}
prepared, msg, attachments, err := a.prepareSubmittedMessage(ctx, raw, mb, mailFrom, recipients)
if err != nil {
return err
}
msg.MailboxID = mb.ID
sentID, insertedSent, err := a.insertSentMessageOnce(ctx, msg, attachments)
if err != nil {
return err
}
if insertedSent {
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
return err
}
}
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
if sentID != "" {
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
if insertedSent {
a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
}
return err
}
}
return nil
}
func (a *App) prepareSubmittedMessage(ctx context.Context, raw []byte, mb *Mailbox, mailFrom string, recipients []string) ([]byte, storedMessage, []AttachmentInput, error) {
header, body, err := readMessageHeader(raw)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
fromAddress, fromName, ok := singleHeaderAddress(header.Get("From"))
if !ok || fromAddress == "" {
return nil, storedMessage{}, nil, smtpError(550, smtpserver.EnhancedCode{5, 7, 1}, "From header must contain exactly one address")
}
authAddress, fromName, err := a.authorizedSender(ctx, mb, fromAddress)
if err != nil || normalizeEmail(mailFrom) != authAddress || normalizeEmail(fromAddress) != authAddress {
return nil, storedMessage{}, nil, smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
now := a.now().UTC()
messageID := strings.TrimSpace(header.Get("Message-Id"))
if messageID == "" {
messageID = fmt.Sprintf("<%s@%s>", newID("msg"), domainPart(authAddress))
header.Set("Message-ID", messageID)
} else {
header.Set("Message-ID", messageID)
}
sentAt := parseMailDate(header.Get("Date"))
if sentAt.IsZero() {
sentAt = now
header.Set("Date", sentAt.Format(time.RFC1123Z))
}
header.Del("Bcc")
prepared := serializeMessage(header, body)
msg, attachments, err := a.parseMaildirMessage(prepared, authAddress)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
if msg.MessageID == "" {
msg.MessageID = messageID
}
if msg.SentAt.IsZero() {
msg.SentAt = sentAt
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = sentAt
}
msg.From = authAddress
msg.FromName = fromName
msg.To = dedupeEmails(msg.To)
msg.CC = dedupeEmails(msg.CC)
msg.BCC = deduceBCCRecipients(recipients, addressList(header.Get("To")), addressList(header.Get("Cc")))
msg.IsRead = true
msg.RawPath = ""
if msg.Subject == "" {
msg.Subject = "(no subject)"
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
return prepared, msg, attachments, nil
}
func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, bool, error) {
sentFolderID, err := a.ensureFolder(ctx, msg.MailboxID, "Sent")
if err != nil {
return "", false, err
}
msg.FolderID = sentFolderID
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return "", false, err
}
committed := false
messageIDForCleanup := ""
defer func() {
if !committed {
_ = tx.Rollback()
if messageIDForCleanup != "" {
a.deleteMessageFiles(ctx, messageIDForCleanup)
}
}
}()
if msg.MessageID != "" {
existing, err := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
if err == nil {
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) {
return "", false, err
}
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return existing, false, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", false, err
}
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil {
if errors.Is(err, errSentDedupeExists) {
existing, qerr := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
if qerr == nil {
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return existing, false, nil
}
if errors.Is(qerr, sql.ErrNoRows) {
return "", false, fmt.Errorf("sent dedupe key exists without sent message: %w", errSentDedupeExists)
}
return "", false, qerr
}
return "", false, err
}
}
id, err := a.insertMessageWithDB(ctx, tx, msg, attachments)
if err != nil {
return "", false, err
}
messageIDForCleanup = id
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return id, true, nil
}
var errSentDedupeExists = errors.New("sent message already exists")
func (a *App) insertSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) error {
return a.insertSentDedupeKeyWithDB(ctx, a.db, mailboxID, folderID, messageID)
}
func (a *App) insertSentDedupeKeyWithDB(ctx context.Context, db dbExecutor, mailboxID, folderID, messageID string) error {
if strings.TrimSpace(messageID) == "" {
return nil
}
res, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)`, mailboxID, folderID, messageID, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return errSentDedupeExists
}
return nil
}
func sentMessageIDByMessageID(ctx context.Context, db dbQueryer, mailboxID, folderID, messageID string) (string, error) {
var existing string
err := db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
return existing, err
}
func (a *App) deleteSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) {
if strings.TrimSpace(messageID) == "" {
return
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mailboxID, folderID, messageID)
}
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, nil, err
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return nil, nil, err
}
return textproto.MIMEHeader(msg.Header), body, nil
}
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
var buf bytes.Buffer
keys := make([]string, 0, len(header))
for key := range header {
keys = append(keys, key)
}
sort.SliceStable(keys, func(i, j int) bool {
return textproto.CanonicalMIMEHeaderKey(keys[i]) < textproto.CanonicalMIMEHeaderKey(keys[j])
})
for _, key := range keys {
values := header[key]
canonical := textproto.CanonicalMIMEHeaderKey(key)
for _, value := range values {
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
}
}
buf.WriteString("\r\n")
buf.Write(body)
return buf.Bytes()
}
func singleHeaderAddress(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", false
}
items, err := netmail.ParseAddressList(value)
if err != nil || len(items) != 1 {
decoded := decodeMIMEHeader(value)
items, err = netmail.ParseAddressList(decoded)
if err != nil || len(items) != 1 {
return "", "", false
}
}
item := items[0]
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name)), true
}
func deduceBCCRecipients(envelope, to, cc []string) []string {
visible := map[string]bool{}
for _, item := range append(to, cc...) {
if email := normalizeEmail(item); email != "" {
visible[email] = true
}
}
var out []string
seen := map[string]bool{}
for _, item := range envelope {
email := normalizeEmail(item)
if email == "" || visible[email] || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func domainPart(email string) string {
parts := strings.SplitN(normalizeEmail(email), "@", 2)
if len(parts) != 2 || parts[1] == "" {
return "lanqin.local"
}
return parts[1]
}
func smtpError(code int, enhanced smtpserver.EnhancedCode, message string) *smtpserver.SMTPError {
return &smtpserver.SMTPError{Code: code, EnhancedCode: enhanced, Message: message}
}
@@ -0,0 +1,289 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var (
BuildVersion = "dev"
BuildCommit = ""
BuildDate = ""
)
type systemVersionInfo struct {
CurrentVersion string `json:"currentVersion"`
CurrentCommit string `json:"currentCommit,omitempty"`
BuildDate string `json:"buildDate,omitempty"`
LatestVersion string `json:"latestVersion,omitempty"`
LatestName string `json:"latestName,omitempty"`
ReleaseURL string `json:"releaseUrl,omitempty"`
ReleaseNotes string `json:"releaseNotes,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
UpdateEnabled bool `json:"updateEnabled"`
CheckError string `json:"checkError,omitempty"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
PublishedAt time.Time `json:"published_at"`
}
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
info, err := a.systemVersion(r.Context())
if err != nil {
info.CheckError = "暂时无法连接版本服务"
a.log.Warn("check system version", "error", err)
}
respondJSON(w, http.StatusOK, info)
}
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "system administrator required")
return
}
if !a.updateEnabled() {
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
return
}
info, err := a.systemVersion(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to check latest release")
return
}
if !info.UpdateAvailable {
respondError(w, http.StatusConflict, "already on the latest version")
return
}
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
if err != nil {
a.log.Error("backup database before update", "error", err)
respondError(w, http.StatusInternalServerError, "failed to back up database")
return
}
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
respondJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"currentVersion": info.CurrentVersion,
"targetVersion": info.LatestVersion,
"message": "更新已启动,服务会在完成后自动恢复",
})
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.config().AppVersion)
if current == "" {
current = BuildVersion
}
info := systemVersionInfo{
CurrentVersion: current,
CurrentCommit: strings.TrimSpace(BuildCommit),
BuildDate: strings.TrimSpace(BuildDate),
UpdateEnabled: a.updateEnabled(),
}
release, err := a.fetchLatestRelease(ctx)
if err != nil {
return info, err
}
info.LatestVersion = strings.TrimSpace(release.TagName)
info.LatestName = strings.TrimSpace(release.Name)
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
info.ReleaseNotes = strings.TrimSpace(release.Body)
if !release.PublishedAt.IsZero() {
info.PublishedAt = &release.PublishedAt
}
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
return info, nil
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return githubRelease{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
}
var release githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
return githubRelease{}, err
}
if strings.TrimSpace(release.TagName) == "" {
return githubRelease{}, errors.New("release API returned an empty tag")
}
return release, nil
}
func (a *App) updateEnabled() bool {
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("update service returned %s", resp.Status)
}
return nil
}
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
go func() {
// Let the accepted response reach the browser before Watchtower replaces this container.
time.Sleep(250 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := a.triggerUpdateService(ctx); err != nil {
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
}
}()
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
return "", err
}
if err := pruneUpdateBackups(backupDir, 5); err != nil {
a.log.Warn("prune update backups", "error", err)
}
return backupPath, nil
}
func pruneUpdateBackups(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
type backupFile struct {
path string
modTime time.Time
}
backups := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
}
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
if keep < 0 {
keep = 0
}
if len(backups) <= keep {
return nil
}
for _, backup := range backups[keep:] {
if err := os.Remove(backup.path); err != nil {
return err
}
}
return nil
}
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
func versionIsNewer(latest, current string) bool {
latestParts, latestPrerelease, latestOK := parseVersion(latest)
currentParts, currentPrerelease, currentOK := parseVersion(current)
if !latestOK {
return false
}
if !currentOK {
return true
}
for i := 0; i < len(latestParts); i++ {
if latestParts[i] != currentParts[i] {
return latestParts[i] > currentParts[i]
}
}
return currentPrerelease != "" && latestPrerelease == ""
}
func parseVersion(value string) ([3]int, string, bool) {
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
if match == nil {
return [3]int{}, "", false
}
var parts [3]int
for i := 0; i < 3; i++ {
if match[i+1] == "" {
continue
}
part, err := strconv.Atoi(match[i+1])
if err != nil {
return [3]int{}, "", false
}
parts[i] = part
}
return parts, match[4], true
}
@@ -0,0 +1,205 @@
package app
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSystemVersionAndUpdate(t *testing.T) {
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"tag_name":"v0.2.0","name":"Version 0.2.0","html_url":"https://example.test/releases/v0.2.0","body":"Release notes","published_at":"2026-08-03T00:00:00Z"}`)
}))
defer releaseServer.Close()
var updateRequests atomic.Int32
updateStarted := make(chan struct{}, 1)
releaseUpdate := make(chan struct{})
var releaseUpdateOnce sync.Once
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
defer releaseBlockedUpdate()
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("update method = %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer update-secret" {
t.Errorf("authorization = %q", got)
}
updateRequests.Add(1)
updateStarted <- struct{}{}
<-releaseUpdate
w.WriteHeader(http.StatusOK)
}))
defer updateServer.Close()
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
AppVersion: "v0.1.0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: dir,
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ReleaseAPIURL: releaseServer.URL,
UpdateServiceURL: updateServer.URL,
UpdateServiceToken: "update-secret",
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
var version systemVersionInfo
if code := admin.do("GET", "/api/admin/system/version", nil, &version); code != http.StatusOK {
t.Fatalf("version code=%d", code)
}
if version.CurrentVersion != "v0.1.0" || version.LatestVersion != "v0.2.0" || !version.UpdateAvailable || !version.UpdateEnabled {
t.Fatalf("unexpected version response: %+v", version)
}
type updateResponse struct {
code int
err error
}
response := make(chan updateResponse, 1)
go func() {
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
if err != nil {
response <- updateResponse{err: err}
return
}
req.AddCookie(admin.cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
response <- updateResponse{err: err}
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
response <- updateResponse{code: resp.StatusCode}
}()
select {
case result := <-response:
if result.err != nil || result.code != http.StatusAccepted {
t.Fatalf("update response=%+v", result)
}
case <-time.After(2 * time.Second):
releaseBlockedUpdate()
t.Fatal("update response waited for container replacement")
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("scheduled update request did not start")
}
releaseBlockedUpdate()
if got := updateRequests.Load(); got != 1 {
t.Fatalf("update requests=%d", got)
}
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
if err != nil || len(backups) != 1 {
t.Fatalf("backups=%v err=%v", backups, err)
}
if info, err := os.Stat(backups[0]); err != nil || info.Size() == 0 {
t.Fatalf("backup stat=%v err=%v", info, err)
}
}
func TestSystemUpdateRequiresSystemAdministrator(t *testing.T) {
a := newTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/update", nil)
req = req.WithContext(context.WithValue(req.Context(), userContextKey, &User{ID: "operator", Role: "user"}))
recorder := httptest.NewRecorder()
a.handleSystemUpdate(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("code=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestSystemVersionHandlesReleaseFailure(t *testing.T) {
dir := t.TempDir()
a, err := New(Config{
Addr: ":0",
AppVersion: "v0.1.0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: dir,
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
ReleaseAPIURL: "http://127.0.0.1:1/releases/latest",
AllowInsecureHTTP: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
defer a.Close()
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/version", nil)
recorder := httptest.NewRecorder()
a.handleSystemVersion(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("code=%d", recorder.Code)
}
var info systemVersionInfo
if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if !strings.Contains(info.CheckError, "版本服务") || info.CurrentVersion != "v0.1.0" {
t.Fatalf("unexpected response: %+v", info)
}
}
func TestVersionIsNewer(t *testing.T) {
tests := []struct {
latest string
current string
want bool
}{
{"v0.2.0", "v0.1.9", true},
{"v1.0.0", "v0.99.99", true},
{"v1.0.0", "v1.0.0", false},
{"v1.0.0-beta.1", "v1.0.0", false},
{"v1.0.0", "v1.0.0-beta.1", true},
{"v1.0.0+build.2", "v1.0.0+build.1", false},
{"v1.0.0", "dev", true},
}
for _, tt := range tests {
if got := versionIsNewer(tt.latest, tt.current); got != tt.want {
t.Errorf("versionIsNewer(%q, %q)=%v want %v", tt.latest, tt.current, got, tt.want)
}
}
}
func TestPruneUpdateBackupsWithFewerFilesThanLimit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pre-update-one.db")
if err := os.WriteFile(path, []byte("backup"), 0o600); err != nil {
t.Fatal(err)
}
if err := pruneUpdateBackups(dir, 5); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("backup should be retained: %v", err)
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ func defaultMailTemplates() []MailTemplate {
{
Key: "welcome",
Name: "欢迎邮件",
Subject: "欢迎使用 LanQin Email",
Subject: "欢迎使用 NewSzxcn 邮箱",
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
UpdatedAt: now,
@@ -51,7 +51,7 @@ func defaultMailTemplates() []MailTemplate {
{
Key: smtpTestTemplateKey,
Name: "SMTP 测试",
Subject: "LanQin Email SMTP 测试",
Subject: "NewSzxcn 邮箱 SMTP 测试",
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>",
UpdatedAt: now,
+2 -2
View File
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
}
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
if !a.cfg.TurnstileEnabled {
if !a.config().TurnstileEnabled {
return nil
}
token = strings.TrimSpace(token)
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" {
return errors.New("turnstile verification required")
}
+10 -5
View File
@@ -122,11 +122,12 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
}
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,created_at FROM users WHERE id=?`, id)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var secret, created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &created); err != nil {
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound
}
@@ -134,12 +135,16 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err
}
return &u, secret, nil
}
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -169,12 +174,12 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusOK, map[string]any{
"secret": secret,
"otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret),
"otpauthUrl": totpProvisioningURI("NewSzxcn 邮箱", current.Email, secret),
})
}
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
+209 -47
View File
@@ -3,13 +3,20 @@ package app
import "time"
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
CreatedAt time.Time `json:"createdAt"`
}
type AdminUser struct {
@@ -18,6 +25,31 @@ type AdminUser struct {
Mailboxes []string `json:"mailboxes"`
}
type APIToken struct {
ID string `json:"id"`
Name string `json:"name"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Disabled bool `json:"disabled"`
Scopes []string `json:"scopes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DeliveryEvent struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
Provider string `json:"provider"`
QueueID string `json:"queueId"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Domain struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -39,6 +71,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -52,11 +85,15 @@ type Alias struct {
}
type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
SortOrder int `json:"sortOrder"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
UIDValidity int64 `json:"uidValidity"`
UIDNext int64 `json:"uidNext"`
HighestModSeq int64 `json:"highestModseq"`
}
type MailLabel struct {
@@ -68,32 +105,46 @@ type MailLabel struct {
}
type MailMessage struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"`
SendQueueID string `json:"sendQueueId,omitempty"`
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
ExternalAccountID string `json:"externalAccountId,omitempty"`
}
type MailAuthentication struct {
AuthenticationResults string `json:"authenticationResults"`
ReceivedSPF string `json:"receivedSpf"`
SPF string `json:"spf"`
DKIM string `json:"dkim"`
DMARC string `json:"dmarc"`
}
type Attachment struct {
@@ -163,9 +214,11 @@ type MailRule struct {
}
type MailRuleCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
Field string `json:"field,omitempty"`
Operator string `json:"operator,omitempty"`
Value string `json:"value,omitempty"`
MatchMode string `json:"matchMode,omitempty"`
Conditions []MailRuleCondition `json:"conditions,omitempty"`
}
type MailRuleAction struct {
@@ -184,12 +237,24 @@ type BlockedSender struct {
}
type MailStats struct {
TotalMessages int64 `json:"totalMessages"`
UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
StorageBytes int64 `json:"storageBytes"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
TotalMessages int64 `json:"totalMessages"`
TotalIncoming int64 `json:"totalIncoming"`
TotalOutgoing int64 `json:"totalOutgoing"`
UnreadMessages int64 `json:"unreadMessages"`
TodayOutgoing int64 `json:"todayOutgoing"`
DraftMessages int64 `json:"draftMessages"`
FailedSends int64 `json:"failedSends"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
AverageMessageBytes int64 `json:"averageMessageBytes"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
Trend []MailStatsTrendPoint `json:"trend"`
Distribution []MailStatsDistributionItem `json:"distribution"`
TopContacts []MailStatsContact `json:"topContacts"`
}
type MailStatsFolderCount struct {
@@ -199,3 +264,100 @@ type MailStatsFolderCount struct {
Unread int64 `json:"unread"`
Bytes int64 `json:"bytes"`
}
type MailStatsTrendPoint struct {
Date string `json:"date"`
Incoming int64 `json:"incoming"`
Outgoing int64 `json:"outgoing"`
}
type MailStatsDistributionItem struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
}
type MailStatsContact struct {
Email string `json:"email"`
Count int64 `json:"count"`
}
type ExternalIMAPAccount struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLSMode string `json:"tlsMode"`
Username string `json:"username"`
AuthMode string `json:"authMode"`
OAuthProvider string `json:"oauthProvider,omitempty"`
OAuthEmail string `json:"oauthEmail,omitempty"`
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
StorageMode string `json:"storageMode"`
SyncReadState bool `json:"syncReadState"`
Enabled bool `json:"enabled"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
LastStatus string `json:"lastStatus"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ExternalIMAPFolder struct {
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
}
type ExternalIMAPSyncRun struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
Folder string `json:"folder,omitempty"`
Status string `json:"status"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
}
type SendQueueEntry struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
Source string `json:"source"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Status string `json:"status"`
AttemptCount int `json:"attemptCount"`
MaxAttempts int `json:"maxAttempts"`
NextAttemptAt time.Time `json:"nextAttemptAt"`
LastError string `json:"lastError"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
}
type SendAuditEvent struct {
ID string `json:"id"`
QueueID string `json:"queueId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId,omitempty"`
Source string `json:"source"`
Event string `json:"event"`
Status string `json:"status"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
+149 -2
View File
@@ -14,15 +14,36 @@ import (
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
type HTMLPolicy struct{ policy *bluemonday.Policy }
const minimumPasswordLength = 6
func hasMinimumPasswordLength(password string) bool {
return utf8.RuneCountInString(password) >= minimumPasswordLength
}
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
p.AllowElements("html", "head", "body", "center", "font")
p.AllowAttrs("style").Globally()
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
p.AllowAttrs("width", "height").Matching(bluemonday.NumberOrPercent).Globally()
p.AllowAttrs("bgcolor", "color").Matching(regexp.MustCompile(`(?i)^#[0-9a-f]{3,8}$|^[a-z][a-z0-9 -]{0,31}$`)).Globally()
p.AllowAttrs("border", "cellpadding", "cellspacing").Matching(bluemonday.Number).OnElements("table")
p.AllowStyles(
"background", "background-color", "background-image", "border", "border-collapse", "border-color",
"border-radius", "border-spacing", "border-style", "border-width", "box-shadow", "color", "display",
"font", "font-family", "font-size", "font-style", "font-weight", "height", "letter-spacing",
"line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "max-width",
"min-width", "opacity", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top",
"text-align", "text-decoration", "text-transform", "vertical-align", "white-space", "width",
).MatchingHandler(safeEmailCSSValue).Globally()
return &HTMLPolicy{policy: p}
}
@@ -30,7 +51,68 @@ func (p *HTMLPolicy) Sanitize(s string) string {
if p == nil || p.policy == nil {
return s
}
return p.policy.Sanitize(s)
styles, withoutStyles := extractSafeEmailStyles(s)
clean := p.policy.Sanitize(withoutStyles)
if len(styles) == 0 {
return clean
}
return strings.Join(styles, "") + clean
}
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
var htmlNonContentTagRe = regexp.MustCompile(`(?is)<(style|script|head|title|noscript)\b[^>]*>.*?</\s*(style|script|head|title|noscript)\s*>`)
func extractSafeEmailStyles(value string) ([]string, string) {
styles := []string{}
withoutStyles := emailStyleTagRe.ReplaceAllStringFunc(value, func(tag string) string {
match := emailStyleTagRe.FindStringSubmatch(tag)
if len(match) != 3 {
return ""
}
attrs, css := match[1], strings.TrimSpace(match[2])
if !safeEmailStyleAttrs(attrs) || !safeEmailCSSBlock(css) {
return ""
}
styles = append(styles, `<style type="text/css">`+css+`</style>`)
return ""
})
return styles, withoutStyles
}
func safeEmailStyleAttrs(attrs string) bool {
attrs = strings.ToLower(strings.TrimSpace(attrs))
if attrs == "" {
return true
}
return regexp.MustCompile(`^\s*type\s*=\s*["']?text/css["']?\s*$`).MatchString(attrs)
}
func safeEmailCSSBlock(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 50000 {
return false
}
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
for _, token := range unsafe {
if strings.Contains(value, token) {
return false
}
}
return true
}
func safeEmailCSSValue(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 512 {
return false
}
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
for _, token := range unsafe {
if strings.Contains(value, token) {
return false
}
}
return true
}
func newID(prefix string) string {
@@ -74,6 +156,55 @@ func normalizeEmail(s string) string {
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
}
func normalizeLoginName(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
if strings.Contains(s, "@") {
return normalizeEmail(s)
}
return normalizeLocalPart(s)
}
func cleanLoginName(value string, fallbacks ...string) (string, error) {
loginName := normalizeLoginName(value)
for _, fallback := range fallbacks {
if loginName != "" {
break
}
loginName = normalizeLoginName(fallback)
}
if loginName == "" {
return "", errors.New("登录名不能为空")
}
if len([]rune(loginName)) > 80 {
return "", errors.New("登录名不能超过 80 个字符")
}
if strings.Contains(loginName, "@") {
parts := strings.SplitN(loginName, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", errors.New("登录名格式无效")
}
return loginName, nil
}
if len([]rune(loginName)) < 2 {
return "", errors.New("登录名至少需要 2 个字符")
}
return loginName, nil
}
func cleanUsername(value string) (string, error) {
username, err := cleanLoginName(value)
if err != nil {
return "", err
}
if strings.Contains(username, "@") {
return "", errors.New("登录名不能使用邮箱地址")
}
return username, nil
}
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
@@ -140,6 +271,21 @@ func nullableString(v string) any {
return v
}
func nullableInt(v *int) any {
if v == nil {
return nil
}
return *v
}
func intPtrFromNull(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
value := int(v.Int64)
return &value
}
func parseTime(v string) time.Time {
t, _ := time.Parse(time.RFC3339Nano, v)
return t
@@ -167,6 +313,7 @@ func snippetFrom(text, html string) string {
}
func stripTags(s string) string {
s = htmlNonContentTagRe.ReplaceAllString(s, " ")
var b strings.Builder
inTag := false
for _, r := range s {
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LanQin Email</title>
<meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>NewSzxcn 邮箱</title>
</head>
<body>
<div id="root"></div>
+4 -9
View File
@@ -1,5 +1,5 @@
{
"name": "lanqin-email-web",
"name": "newszxcn-email-web",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-font-family": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
"@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.10",
"dompurify": "3.4.12",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.30.4",
"react-router-dom": "7.18.2",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20",
"postcss": "8.5.15",
"postcss": "8.5.25",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3",
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<path d="M14 22.5A6.5 6.5 0 0 1 20.5 16h23A6.5 6.5 0 0 1 50 22.5v19A6.5 6.5 0 0 1 43.5 48h-23A6.5 6.5 0 0 1 14 41.5v-19Z" fill="#f8fafc"/>
<path d="M18 23.5 32 34l14-10.5" fill="none" stroke="#0f172a" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 42V24h4.8l10.4 11.3V24H44v18h-4.8L28.8 30.7V42H24Z" fill="#2563eb"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

+2 -1
View File
@@ -1,11 +1,12 @@
import React from "react"
import { Navigate } from "react-router-dom"
import { useMe } from "@/hooks/use-me"
import { hasAdminAccess } from "@/lib/permissions"
export function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe()
if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
if (!hasAdminAccess(me.data.user)) return <Navigate to="/" replace />
return <>{children}</>
}
@@ -1,4 +1,3 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+40 -31
View File
@@ -1,12 +1,15 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types"
import {
Sidebar,
SidebarContent,
@@ -24,14 +27,16 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
const adminSections = [
{ key: "overview", label: "览", icon: <BarChart3 /> },
{ key: "users", label: "用户", icon: <Users /> },
{ key: "domains", label: "域名", icon: <Globe2 /> },
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
{ key: "aliases", label: "别名转发", icon: <Copy /> },
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
{ key: "settings", label: "系统设置", icon: <Settings /> },
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限配额", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
]
export function ProtectedLayout() {
@@ -52,6 +57,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
if (isMailRoute || isProfileRoute) {
return <Outlet />
@@ -60,28 +66,31 @@ function ProtectedContent() {
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Mail className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">LanQin Email</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarHeader className="border-b">
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Mail className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">NewSzxcn </span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{isAdminRoute && <SystemVersionDialog className="ml-10" />}
</div>
</SidebarHeader>
<SidebarContent>
{user.role === "admin" && isAdminRoute && (
{isAdminRoute && visibleAdminSections.length > 0 && (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<AdminSectionItems activeSection={adminSection} />
<AdminSectionItems activeSection={adminSection} sections={visibleAdminSections} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -99,10 +108,10 @@ function ProtectedContent() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "用户"}
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
@@ -121,7 +130,7 @@ function ProtectedContent() {
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
<SidebarTrigger aria-label="打开导航" />
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
{isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
</div>
</div>
<Outlet />
@@ -131,13 +140,13 @@ function ProtectedContent() {
)
}
function AdminSectionItems({ activeSection }: { activeSection: string }) {
function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) {
const { isMobile, setOpenMobile } = useSidebar()
function closeMobile() {
if (isMobile) setOpenMobile(false)
}
return adminSections.map((item) => (
return sections.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
@@ -0,0 +1,195 @@
import * as React from "react"
import { useMutation, useQuery } from "@tanstack/react-query"
import { CheckCircle2, Download, ExternalLink, Loader2, RefreshCcw, TriangleAlert } from "lucide-react"
import { api } from "@/lib/api"
import { cn, formatDate } from "@/lib/utils"
import { useMe } from "@/hooks/use-me"
import { useToast } from "@/hooks/use-toast"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
const frontendVersion = import.meta.env.VITE_APP_VERSION || "dev"
export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "sidebar" | "inline"; className?: string }) {
const me = useMe()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [updatePhase, setUpdatePhase] = React.useState<"idle" | "starting" | "restarting">("idle")
const version = useQuery({
queryKey: ["admin", "system-version"],
queryFn: api.systemVersion,
staleTime: 5 * 60_000,
retry: 1,
})
const currentVersion = version.data?.currentVersion || frontendVersion
const isSystemAdmin = me.data?.user.role === "admin"
const update = useMutation({
mutationFn: async () => {
setUpdatePhase("starting")
const targetVersion = version.data?.latestVersion
let result: Awaited<ReturnType<typeof api.updateSystem>>
try {
result = await api.updateSystem()
} catch (error) {
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
result = {
ok: true,
currentVersion,
targetVersion,
message: "更新请求已发送,正在等待服务恢复",
}
}
setUpdatePhase("restarting")
await waitForUpdatedService(result.targetVersion)
return result
},
onError: (error) => {
setUpdatePhase("idle")
toast({ title: "更新失败", description: error.message })
},
})
const trigger = mode === "inline" ? (
<Button type="button" variant="outline" className={cn("h-11 justify-start gap-2 px-4 text-base font-normal", className)}>
<RefreshCcw className="h-5 w-5 text-primary" />
{currentVersion}
{version.data?.updateAvailable && <Badge className="ml-1"></Badge>}
</Button>
) : (
<Button
type="button"
variant={version.data?.updateAvailable ? "secondary" : "ghost"}
className={cn("h-8 w-fit max-w-full justify-start gap-2 rounded-md px-2 text-xs font-medium group-data-[collapsible=icon]:hidden", className)}
aria-label={`系统版本 ${currentVersion}`}
>
<span className="truncate">{currentVersion}</span>
<span className={cn("h-2 w-2 shrink-0 rounded-full", version.data?.updateAvailable ? "bg-amber-500" : "bg-emerald-500")} aria-hidden="true" />
</Button>
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-h-[88svh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<div className="flex items-center justify-between gap-3 pr-7">
<DialogTitle></DialogTitle>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => version.refetch()} disabled={version.isFetching || update.isPending} aria-label="重新检查更新" title="重新检查更新">
<RefreshCcw className={cn("h-4 w-4", version.isFetching && "animate-spin")} />
</Button>
</div>
</DialogHeader>
<div className="space-y-3">
<div className="border-b pb-3 text-center">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">{version.data.latestVersion}</div>}
</div>
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
{version.data?.checkError && <VersionState icon={<TriangleAlert />} title="暂时无法检查更新" description={version.data.checkError} tone="warning" />}
{version.data && !version.data.checkError && !version.data.updateAvailable && <VersionState icon={<CheckCircle2 />} title="已是最新版本" description="当前无需更新。" tone="success" />}
{version.data?.updateAvailable && (
<VersionState
icon={<Download />}
title="发现新版本"
description={`${version.data.latestVersion} 已发布${version.data.publishedAt ? ` · ${formatDate(version.data.publishedAt)}` : ""}`}
tone="warning"
/>
)}
{version.data?.releaseNotes && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
{version.data.releaseNotes}
</div>
</div>
)}
{update.isPending && (
<div className="rounded-md border bg-muted/30 p-4">
<div className="flex items-center gap-3 font-medium">
<Loader2 className="h-5 w-5 animate-spin" />
{updatePhase === "starting" ? "正在准备更新" : "正在重启服务"}
</div>
<div className="mt-2 text-sm text-muted-foreground"></div>
</div>
)}
{version.data?.updateAvailable && !version.data.updateEnabled && (
<div className="rounded-md border p-3 text-sm text-muted-foreground">
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground">sudo newszxcn-email update</code>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div>
{version.data?.releaseUrl && (
<Button type="button" variant="ghost" asChild>
<a href={version.data.releaseUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</div>
{version.data?.updateAvailable && version.data.updateEnabled && (
<Button type="button" disabled={!isSystemAdmin || update.isPending} onClick={() => update.mutate()}>
{update.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{isSystemAdmin ? "立即更新" : "仅超级管理员可更新"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function VersionState({ icon, title, description, tone = "neutral" }: { icon: React.ReactNode; title: string; description: string; tone?: "neutral" | "success" | "warning" }) {
return (
<div className={cn(
"flex items-start gap-3 rounded-md border p-4",
tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100",
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100",
)}>
<span className="mt-0.5 [&>svg]:h-5 [&>svg]:w-5">{icon}</span>
<span className="min-w-0">
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm opacity-75">{description}</span>
</span>
</div>
)
}
async function waitForUpdatedService(targetVersion: string) {
const deadline = Date.now() + 8 * 60_000
while (Date.now() < deadline) {
await delay(3000)
try {
const health = await fetch(`/healthz?update=${Date.now()}`, { cache: "no-store" })
if (!health.ok) {
continue
}
const response = await fetch(`/api/admin/system/version?update=${Date.now()}`, { credentials: "include", cache: "no-store" })
if (!response.ok) continue
const body = await response.json() as { currentVersion?: string }
if (body.currentVersion === targetVersion) {
window.location.reload()
return
}
} catch {}
}
throw new Error("更新等待超时,请稍后手动刷新页面检查服务状态")
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function isUpdateConnectionInterruption(error: unknown) {
if (!(error instanceof Error)) return false
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
}
-43
View File
@@ -1,43 +0,0 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+5 -1
View File
@@ -48,7 +48,7 @@ const Toast = React.forwardRef<
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
className={cn(toastVariants({ variant }), props.onClick && "cursor-pointer", className)}
{...props}
/>
)
@@ -76,6 +76,10 @@ const ToastClose = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
onClick={(event) => {
event.stopPropagation()
props.onClick?.(event)
}}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
+127 -24
View File
@@ -5,39 +5,50 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 224 44% 12%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 358 88% 61%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 215.4 16.3% 46.9%;
}
* { @apply border-border; }
body { @apply bg-background text-foreground antialiased; }
html, body, #root { min-height: 100%; }
html { color-scheme: light; }
html {
color-scheme: light;
font-size: 16px;
}
body {
@apply bg-background text-foreground antialiased;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
}
html, body, #root {
min-height: 100%;
min-width: 0;
width: 100%;
}
html.dark { color-scheme: dark; }
.dark {
@@ -71,6 +82,72 @@
}
}
@layer components {
.mail-shell-grid {
display: grid;
grid-template-columns: clamp(236px, 17vw, 344px) minmax(0, 1fr);
}
.mail-content-grid {
display: grid;
grid-template-columns: 384px minmax(0, 1fr);
}
.mail-list-pane,
.mail-detail-pane {
min-height: 0;
height: 100%;
}
.mail-list-pane {
border-right: 1px solid hsl(var(--border));
width: 384px;
max-width: 384px;
}
@media (max-width: 767px) {
.mail-content-grid {
grid-template-columns: minmax(0, 1fr);
}
.mail-list-pane {
border-right: 0;
width: auto;
max-width: none;
}
}
[data-sidebar="content"] {
gap: 0.75rem;
}
[data-sidebar="group"] {
padding: 0 0.25rem;
}
[data-sidebar="menu"] {
gap: 0.25rem;
}
[data-sidebar="menu-button"] {
color: hsl(var(--sidebar-foreground));
}
[data-sidebar="menu-button"] svg {
color: hsl(var(--muted-foreground));
stroke-width: 1.8;
}
[data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent));
color: hsl(var(--sidebar-accent-foreground));
}
[data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground));
}
}
html.theme-transition body {
transition: background-color 180ms ease, color 180ms ease;
}
@@ -162,3 +239,29 @@ vue-devtools-anchor,
[class*="vue-devtools"] {
display: none !important;
}
/* Label badge: hide delete button by default, show on hover (desktop) */
.label-badge-delete {
visibility: hidden;
width: 0;
padding: 0;
overflow: hidden;
transition: visibility 0.1s, width 0.1s, padding 0.1s, opacity 0.15s;
}
.label-badge:hover .label-badge-delete,
.label-badge:focus-within .label-badge-delete {
visibility: visible;
width: 1rem;
padding: 0;
opacity: 1 !important;
}
/* Touch devices: always show the delete button inside label badges */
@media (hover: none) and (pointer: coarse) {
.label-badge-delete {
visibility: visible;
width: 1rem;
padding: 0;
opacity: 1 !important;
}
}
+194 -11
View File
@@ -1,32 +1,207 @@
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
export type PermissionKey =
| "mail.access"
| "mail.messages.read"
| "mail.messages.send"
| "mail.messages.drafts"
| "mail.messages.schedule"
| "mail.messages.organize"
| "mail.labels.manage"
| "mail.attachments.download"
| "mail.contacts.manage"
| "mail.signatures.manage"
| "mail.rules.manage"
| "mail.blocked_senders.manage"
| "mail.stats.view"
| "mail.mailboxes.apply"
| "admin.overview.view"
| "admin.users.view"
| "admin.users.create"
| "admin.users.update"
| "admin.users.delete"
| "admin.users.reset_password"
| "admin.permission_groups.view"
| "admin.permission_groups.create"
| "admin.permission_groups.update"
| "admin.permission_groups.delete"
| "admin.domains.view"
| "admin.domains.create"
| "admin.domains.update"
| "admin.domains.delete"
| "admin.dns.view"
| "admin.dns.check"
| "admin.mailboxes.view"
| "admin.mailboxes.create"
| "admin.mailboxes.update"
| "admin.mailboxes.delete"
| "admin.aliases.view"
| "admin.aliases.create"
| "admin.aliases.update"
| "admin.aliases.delete"
| "admin.messages.view"
| "admin.messages.read"
| "admin.messages.attachments"
| "admin.settings.view"
| "admin.settings.update"
| "admin.settings.test_smtp"
| "admin.templates.view"
| "admin.templates.update"
| "admin.templates.reset"
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
sendQueueId?: string
sendQueueStatus?: SendQueueStatus
externalAccountId?: string
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type MailTranslation = { translatedText: string; translatedHtml?: string; sourceLanguage?: string; targetLanguage: string; truncated: boolean }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
export type SendQueueItem = {
id: string
mailboxId: string
sentMessageId?: string
messageId?: string
mailFrom?: string
headerFrom?: string
subject: string
recipients: string[]
source: string
status: SendQueueStatus
attemptCount: number
maxAttempts: number
nextAttemptAt?: string
lastError?: string
error?: string
failureReason?: string
createdAt: string
updatedAt: string
deliveredAt?: string
}
export type SendQueueAuditEvent = {
id: string
queueId?: string
mailboxId?: string
mailboxAddress?: string
sentMessageId?: string
messageId?: string
source?: string
status?: SendQueueStatus
event?: string
eventType?: string
mailFrom?: string
headerFrom?: string
recipients?: string[]
message?: string
error?: string
attemptCount?: number
createdAt: string
}
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailStats = {
totalMessages: number
totalIncoming: number
totalOutgoing: number
unreadMessages: number
todayOutgoing: number
draftMessages: number
failedSends: number
starredMessages: number
attachmentCount: number
attachmentBytes: number
storageBytes: number
quotaBytes: number
quotaUsedPct: number
averageMessageBytes: number
byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[]
trend: { date: string; incoming: number; outgoing: number }[]
distribution: { key: string; label: string; count: number }[]
topContacts: { email: string; count: number }[]
}
export type ForwardingVerifiedEmail = {
id: string
email: string
verified: boolean
createdAt: string
verifiedAt?: string
verificationSentAt?: string
verificationExpiresAt?: string
deliveryStatus?: SendQueueStatus | "verified"
deliveryError?: string
}
export type MailboxForwardingRule = { mailboxId: string; targetEmail: string; targetEmails?: string[] }
export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; accountTargetEmails?: string[]; mailboxRules: MailboxForwardingRule[] }
export type ExternalImapStorageMode = "local" | "remote"
export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
export type ExternalImapAuthMode = "password" | "oauth2"
export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; authMode: ExternalImapAuthMode; oauthProvider?: ExternalImapOAuthProvider; oauthEmail?: string; oauthConfigured?: boolean; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string }
export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
export type ExternalImapOAuthProvider = "gmail" | "outlook"
export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number }
export type ExternalImapSyncRun = { id: string; accountId: string; folder?: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
export type MaildirSyncHealth = {
configured: boolean
enabled: boolean
root: string
scanSeconds: number
workerStarted: boolean
running: boolean
lastRun?: MaildirSyncRun
lastError?: string
nextRunAt?: string
recentErrors: string[]
summary: MaildirSyncCounts
}
export type SystemVersion = {
currentVersion: string
currentCommit?: string
buildDate?: string
latestVersion?: string
latestName?: string
releaseUrl?: string
releaseNotes?: string
publishedAt?: string
updateAvailable: boolean
updateEnabled: boolean
checkError?: string
}
export type SystemUpdateResult = {
ok: boolean
currentVersion: string
targetVersion: string
message: string
}
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
@@ -50,10 +225,18 @@ export type SystemSettings = {
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: string
externalImapEnabled: boolean
externalImapSecretSet: boolean
externalImapSyncSeconds: number
externalImapAllowPrivateHosts: boolean
externalImapGmailClientId: string
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
+174 -14
View File
@@ -1,9 +1,47 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
export type MailSearchParams = {
q?: string
from?: string
to?: string
subject?: string
startDate?: string
endDate?: string
attachmentMode?: "all" | "with" | "without"
minSizeKb?: string
maxSizeKb?: string
readStatus?: "all" | "read" | "unread"
flagStatus?: "all" | "starred" | "unstarred"
hasAttachments?: boolean
unread?: boolean
starred?: boolean
}
function appendMailSearchParams(params: URLSearchParams, search: MailSearchParams | string) {
if (typeof search === "string") {
if (search) params.set("q", search)
return
}
if (search.q) params.set("q", search.q)
if (search.from) params.set("from", search.from)
if (search.to) params.set("to", search.to)
if (search.subject) params.set("subject", search.subject)
if (search.startDate) params.set("startDate", search.startDate)
if (search.endDate) params.set("endDate", search.endDate)
if (search.attachmentMode && search.attachmentMode !== "all") params.set("attachmentMode", search.attachmentMode)
else if (search.hasAttachments) params.set("hasAttachments", "1")
if (search.minSizeKb) params.set("minSizeKb", search.minSizeKb)
if (search.maxSizeKb) params.set("maxSizeKb", search.maxSizeKb)
if (search.readStatus && search.readStatus !== "all") params.set("readStatus", search.readStatus)
else if (search.unread) params.set("unread", "1")
if (search.flagStatus && search.flagStatus !== "all") params.set("flagStatus", search.flagStatus)
else if (search.starred) params.set("starred", "1")
}
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs, ...requestInit } = init
const controller = new AbortController()
@@ -31,6 +69,26 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
}
}
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
try {
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
if (!res.ok) {
if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试")
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
}
return res.json() as Promise<T>
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw new Error("导入超时,请缩小文件后重试")
throw error instanceof Error ? error : new Error("网络请求失败")
} finally {
window.clearTimeout(timeout)
}
}
export const api = {
publicSettings: () => request<PublicSettings>("/api/public/settings"),
register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }),
@@ -39,6 +97,10 @@ export const api = {
me: () => request<{ user: User }>("/api/me"),
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }),
changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }),
apiTokens: () => request<ListResponse<APIToken>>("/api/me/api-tokens"),
createApiToken: (payload: { name: string; expiresAt?: string; scopes: string[] }) => request<{ token: string; item: APIToken }>("/api/me/api-tokens", { method: "POST", body: JSON.stringify(payload) }),
updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }),
setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }),
enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }),
@@ -53,18 +115,47 @@ export const api = {
defaultSignature: (mailboxId?: string) => request<{ signature: MailSignature | null }>(`/api/me/signatures/default${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
rules: () => request<ListResponse<MailRule>>("/api/me/rules"),
createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
updateRule: (id: string, payload: Partial<{ mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }>) => request<MailRule>(`/api/me/rules/${id}`, { method: "POST", body: JSON.stringify(payload) }),
moveRule: (id: string, direction: "up" | "down") => request<{ ok: boolean }>(`/api/me/rules/${id}/move`, { method: "POST", body: JSON.stringify({ direction }) }),
applyRule: (id: string) => request<{ ok: boolean; affected: number }>(`/api/me/rules/${id}/apply`, { method: "POST" }),
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
mailStats: (mailboxId?: string, days?: number) => {
const query = new URLSearchParams()
if (mailboxId) query.set("mailboxId", mailboxId)
if (days) query.set("days", String(days))
const suffix = query.toString()
return request<MailStats>(`/api/me/stats${suffix ? `?${suffix}` : ""}`)
},
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
forwardingSettings: () => request<ForwardingSettings>("/api/me/forwarding"),
addForwardingVerifiedEmail: (email: string) => request<ForwardingSettings>("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }),
resendForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }),
deleteForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }),
updateAccountForwarding: (targetEmails: string[] | string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
updateMailboxForwarding: (mailboxId: string, targetEmails: string[] | string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
externalImapAccounts: (mailboxId?: string) => request<ListResponse<ExternalImapAccount>>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }),
updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }),
startExternalImapOAuth: (provider: ExternalImapOAuthProvider, payload: ExternalImapOAuthStartPayload) => request<{ url: string }>(`/api/me/external-imap-oauth/${provider}/start`, { method: "POST", body: JSON.stringify(payload) }),
testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
externalImapSyncRuns: (id: string) => request<ListResponse<ExternalImapSyncRun>>(`/api/me/external-imap-accounts/${id}/runs`),
syncExternalImapAccount: (id: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
syncExternalImapFolder: (id: string, folder: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync-folder`, { method: "POST", body: JSON.stringify({ folder }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -72,7 +163,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -89,7 +180,21 @@ export const api = {
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
},
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.messageId) query.set("messageId", params.messageId)
if (params.event) query.set("event", params.event)
if (params.from) query.set("from", params.from)
if (params.to) query.set("to", params.to)
if (params.cursor) query.set("cursor", params.cursor)
const suffix = query.toString()
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
},
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
@@ -98,32 +203,90 @@ export const api = {
dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
externalMessages: (id: string, folder: string, cursor = "", q = "") => {
const params = new URLSearchParams({ folder, cursor, q })
return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`)
},
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
},
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
},
deleteFolder: (id: string, mailboxId?: string, folderName?: string) => {
const query = new URLSearchParams()
if (mailboxId) query.set("mailboxId", mailboxId)
if (folderName) query.set("folderName", folderName)
const suffix = query.toString()
return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" })
},
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
},
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, q, cursor })
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
messages: (folder: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, cursor })
appendMailSearchParams(params, search)
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
},
labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ labelId, q, cursor })
labelMessages: (labelId: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ labelId, cursor })
appendMailSearchParams(params, search)
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
},
starredMessages: (q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ q, cursor })
starredMessages: (search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ cursor })
appendMailSearchParams(params, search)
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
const query = new URLSearchParams({ view: params.view })
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.folder) query.set("folder", params.folder)
if (params.labelId) query.set("labelId", params.labelId)
return `/api/mail/export?${query.toString()}`
},
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
const form = new FormData()
form.set("mailboxId", payload.mailboxId)
form.set("folder", payload.folder)
files.forEach((file) => form.append("files", file))
return uploadForm<{ ok: boolean; imported: number; skipped: number; errors: string[] }>("/api/mail/import", form)
},
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
translateMessage: (id: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string; messageId?: string; recipient?: string; from?: string; to?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.status && params.status !== "all") query.set("status", params.status)
if (params.cursor) query.set("cursor", params.cursor)
if (params.messageId) query.set("messageId", params.messageId)
if (params.recipient) query.set("recipient", params.recipient)
if (params.from) query.set("from", params.from)
if (params.to) query.set("to", params.to)
const suffix = query.toString()
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
},
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
@@ -133,6 +296,3 @@ export const api = {
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
}
+477
View File
@@ -0,0 +1,477 @@
import * as React from "react"
export type Language = "zh-CN" | "zh-TW" | "en"
export const LANGUAGE_STORAGE_KEY = "lanqin:language"
export const languageOptions: { value: Language; label: string; shortLabel: string; htmlLang: string }[] = [
{ value: "zh-CN", label: "简体中文", shortLabel: "简", htmlLang: "zh-CN" },
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁", htmlLang: "zh-TW" },
{ value: "en", label: "English", shortLabel: "EN", htmlLang: "en" },
]
const languageValues = new Set(languageOptions.map((item) => item.value))
export function getInitialLanguage(): Language {
if (typeof window === "undefined") return "zh-CN"
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
if (stored && languageValues.has(stored as Language)) return stored as Language
const browserLanguage = window.navigator.language.toLowerCase()
if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk") || browserLanguage.startsWith("zh-mo")) return "zh-TW"
if (browserLanguage.startsWith("en")) return "en"
return "zh-CN"
}
export function setStoredLanguage(language: Language) {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
window.dispatchEvent(new CustomEvent("lanqin:language", { detail: language }))
}
export function useLanguage() {
const [language, setLanguageState] = React.useState<Language>(getInitialLanguage)
React.useEffect(() => {
function sync() {
setLanguageState(getInitialLanguage())
}
window.addEventListener("storage", sync)
window.addEventListener("lanqin:language", sync)
return () => {
window.removeEventListener("storage", sync)
window.removeEventListener("lanqin:language", sync)
}
}, [])
const setLanguage = React.useCallback((nextLanguage: Language) => {
setStoredLanguage(nextLanguage)
setLanguageState(nextLanguage)
}, [])
return [language, setLanguage] as const
}
type Translation = { "zh-TW": string; en: string }
const exactTranslations: Record<string, Translation> = {
"收件箱": { "zh-TW": "收件匣", en: "Inbox" },
"已发送": { "zh-TW": "已傳送", en: "Sent" },
"草稿箱": { "zh-TW": "草稿匣", en: "Drafts" },
"归档": { "zh-TW": "封存", en: "Archive" },
"垃圾邮件": { "zh-TW": "垃圾郵件", en: "Spam" },
"回收站": { "zh-TW": "垃圾桶", en: "Trash" },
"全部邮件": { "zh-TW": "全部郵件", en: "All mail" },
"未读邮件": { "zh-TW": "未讀郵件", en: "Unread" },
"星标邮件": { "zh-TW": "星號郵件", en: "Starred" },
"有附件": { "zh-TW": "有附件", en: "Has attachments" },
"切换语言": { "zh-TW": "切換語言", en: "Switch language" },
"写邮件": { "zh-TW": "寫郵件", en: "Compose" },
"邮件夹": { "zh-TW": "郵件匣", en: "Folders" },
"外部邮箱": { "zh-TW": "外部信箱", en: "External mailbox" },
"标签": { "zh-TW": "標籤", en: "Labels" },
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
"邮箱导航": { "zh-TW": "信箱導覽", en: "Mailbox navigation" },
"刷新邮件": { "zh-TW": "重新整理郵件", en: "Refresh mail" },
"自动刷新中": { "zh-TW": "自動重新整理中", en: "Auto-refreshing" },
"自动刷新中...": { "zh-TW": "自動重新整理中...", en: "Auto-refreshing..." },
"自动刷新已开启": { "zh-TW": "自動重新整理已開啟", en: "Auto-refresh enabled" },
"全部已读": { "zh-TW": "全部已讀", en: "Mark all read" },
"搜索邮件": { "zh-TW": "搜尋郵件", en: "Search mail" },
"搜索远端邮件": { "zh-TW": "搜尋遠端郵件", en: "Search remote mail" },
"搜索发送队列": { "zh-TW": "搜尋傳送佇列", en: "Search send queue" },
"搜索待发送": { "zh-TW": "搜尋待傳送", en: "Search scheduled mail" },
"选择当前页邮件": { "zh-TW": "選擇目前頁面的郵件", en: "Select messages on this page" },
"加载中...": { "zh-TW": "載入中...", en: "Loading..." },
"加载更多": { "zh-TW": "載入更多", en: "Load more" },
"选择一封邮件阅读": { "zh-TW": "選擇一封郵件閱讀", en: "Select a message to read" },
"回复": { "zh-TW": "回覆", en: "Reply" },
"转发": { "zh-TW": "轉寄", en: "Forward" },
"投递时间线": { "zh-TW": "投遞時間軸", en: "Delivery timeline" },
"取消归档": { "zh-TW": "取消封存", en: "Unarchive" },
"删除": { "zh-TW": "刪除", en: "Delete" },
"附件": { "zh-TW": "附件", en: "Attachments" },
"确认": { "zh-TW": "確認", en: "Confirm" },
"取消": { "zh-TW": "取消", en: "Cancel" },
"关闭": { "zh-TW": "關閉", en: "Close" },
"打开": { "zh-TW": "開啟", en: "Open" },
"刷新": { "zh-TW": "重新整理", en: "Refresh" },
"新建文件夹": { "zh-TW": "新增資料夾", en: "New folder" },
"文件夹名称": { "zh-TW": "資料夾名稱", en: "Folder name" },
"创建": { "zh-TW": "建立", en: "Create" },
"创建中...": { "zh-TW": "建立中...", en: "Creating..." },
"例如:客户、账单、项目归档": { "zh-TW": "例如:客戶、帳單、專案封存", en: "e.g. Clients, invoices, project archive" },
"新建标签": { "zh-TW": "新增標籤", en: "New label" },
"删除标签失败": { "zh-TW": "刪除標籤失敗", en: "Failed to delete label" },
"添加标签失败": { "zh-TW": "新增標籤失敗", en: "Failed to add label" },
"移除标签失败": { "zh-TW": "移除標籤失敗", en: "Failed to remove label" },
"创建标签失败": { "zh-TW": "建立標籤失敗", en: "Failed to create label" },
"添加标签": { "zh-TW": "新增標籤", en: "Add label" },
"请先在侧栏新建标签": { "zh-TW": "請先在側欄新增標籤", en: "Create a label in the sidebar first" },
"无": { "zh-TW": "無", en: "None" },
"无主题": { "zh-TW": "無主旨", en: "No subject" },
"(无主题)": { "zh-TW": "(無主旨)", en: "(No subject)" },
"未知发件人": { "zh-TW": "未知寄件者", en: "Unknown sender" },
"未知发件人地址": { "zh-TW": "未知寄件者地址", en: "Unknown sender address" },
"发件人": { "zh-TW": "寄件者", en: "Sender" },
"发件人地址": { "zh-TW": "寄件者地址", en: "Sender address" },
"收件人": { "zh-TW": "收件者", en: "Recipients" },
"抄送": { "zh-TW": "副本", en: "Cc" },
"密送": { "zh-TW": "密件副本", en: "Bcc" },
"投递邮箱": { "zh-TW": "投遞信箱", en: "Delivered mailbox" },
"发送时间": { "zh-TW": "傳送時間", en: "Send time" },
"接收时间": { "zh-TW": "接收時間", en: "Received time" },
"未填写收件人": { "zh-TW": "未填寫收件者", en: "No recipients" },
"邮件正文": { "zh-TW": "郵件內文", en: "Message body" },
"邮件详情": { "zh-TW": "郵件詳情", en: "Message details" },
"上一封": { "zh-TW": "上一封", en: "Previous" },
"下一封": { "zh-TW": "下一封", en: "Next" },
"更多操作": { "zh-TW": "更多操作", en: "More actions" },
"邮件不存在": { "zh-TW": "郵件不存在", en: "Message not found" },
"标为已读": { "zh-TW": "標為已讀", en: "Mark as read" },
"标为未读": { "zh-TW": "標為未讀", en: "Mark as unread" },
"添加星标": { "zh-TW": "加上星號", en: "Add star" },
"取消星标": { "zh-TW": "移除星號", en: "Remove star" },
"移入回收站": { "zh-TW": "移至垃圾桶", en: "Move to trash" },
"移入垃圾邮件": { "zh-TW": "移至垃圾郵件", en: "Move to spam" },
"批量操作": { "zh-TW": "批次操作", en: "Bulk actions" },
"移动到": { "zh-TW": "移動到", en: "Move to" },
"移到最上": { "zh-TW": "移到最上方", en: "Move to top" },
"上移一位": { "zh-TW": "上移一位", en: "Move up" },
"下移一位": { "zh-TW": "下移一位", en: "Move down" },
"移到最下": { "zh-TW": "移到最下方", en: "Move to bottom" },
"删除文件夹": { "zh-TW": "刪除資料夾", en: "Delete folder" },
"远端文件夹没有邮件": { "zh-TW": "遠端資料夾沒有郵件", en: "No messages in remote folder" },
"当前筛选条件下没有远端邮件": { "zh-TW": "目前篩選條件下沒有遠端郵件", en: "No remote messages match the current filters" },
"没有待发送邮件": { "zh-TW": "沒有待傳送郵件", en: "No scheduled mail" },
"当前搜索没有匹配的定时邮件": { "zh-TW": "目前搜尋沒有符合的定時郵件", en: "No scheduled messages match your search" },
"发送队列为空": { "zh-TW": "傳送佇列為空", en: "Send queue is empty" },
"当前搜索没有匹配的发送任务": { "zh-TW": "目前搜尋沒有符合的傳送任務", en: "No send tasks match your search" },
"当前筛选条件下没有邮件": { "zh-TW": "目前篩選條件下沒有郵件", en: "No messages match the current filters" },
"暂无星标邮件": { "zh-TW": "暫無星號郵件", en: "No starred mail" },
"当前标签没有邮件": { "zh-TW": "目前標籤沒有郵件", en: "No messages with this label" },
"收件箱暂时为空": { "zh-TW": "收件匣暫時為空", en: "Inbox is empty" },
"还没有草稿": { "zh-TW": "還沒有草稿", en: "No drafts yet" },
"还没有已发送邮件": { "zh-TW": "還沒有已傳送郵件", en: "No sent mail yet" },
"回收站是空的": { "zh-TW": "垃圾桶是空的", en: "Trash is empty" },
"暂无垃圾邮件": { "zh-TW": "暫無垃圾郵件", en: "No spam" },
"当前文件夹没有邮件": { "zh-TW": "目前資料夾沒有郵件", en: "No messages in this folder" },
"待发送": { "zh-TW": "待傳送", en: "Scheduled" },
"发送队列": { "zh-TW": "傳送佇列", en: "Send queue" },
"全部状态": { "zh-TW": "全部狀態", en: "All statuses" },
"排队中": { "zh-TW": "佇列中", en: "Queued" },
"发送中": { "zh-TW": "傳送中", en: "Sending" },
"发送失败": { "zh-TW": "傳送失敗", en: "Failed" },
"已投递": { "zh-TW": "已投遞", en: "Delivered" },
"已取消": { "zh-TW": "已取消", en: "Canceled" },
"等待发送": { "zh-TW": "等待傳送", en: "Pending" },
"清除": { "zh-TW": "清除", en: "Clear" },
"未记录收件人": { "zh-TW": "未記錄收件者", en: "No recorded recipients" },
"时间线": { "zh-TW": "時間軸", en: "Timeline" },
"重试": { "zh-TW": "重試", en: "Retry" },
"暂无投递事件": { "zh-TW": "暫無投遞事件", en: "No delivery events" },
"队列事件": { "zh-TW": "佇列事件", en: "Queue event" },
"定时发送": { "zh-TW": "定時傳送", en: "Scheduled send" },
"未知": { "zh-TW": "未知", en: "Unknown" },
"同步": { "zh-TW": "同步", en: "Sync" },
"直连": { "zh-TW": "直連", en: "Direct" },
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
"当前账号可以访问邮箱前台,但未开启邮件查看权限。": { "zh-TW": "目前帳號可以存取信箱前台,但未開啟郵件檢視權限。", en: "This account can access mailbox UI, but mail reading is not enabled." },
"无定时发送权限": { "zh-TW": "無定時傳送權限", en: "No scheduled send permission" },
"当前账号不能查看或管理定时发送任务。": { "zh-TW": "目前帳號不能檢視或管理定時傳送任務。", en: "This account cannot view or manage scheduled send tasks." },
"无发送队列权限": { "zh-TW": "無傳送佇列權限", en: "No send queue permission" },
"当前账号不能查看发送队列。": { "zh-TW": "目前帳號不能檢視傳送佇列。", en: "This account cannot view the send queue." },
"操作失败": { "zh-TW": "操作失敗", en: "Operation failed" },
"请稍后重试": { "zh-TW": "請稍後重試", en: "Please try again later" },
"已取消定时发送": { "zh-TW": "已取消定時傳送", en: "Scheduled send canceled" },
"已移除失败记录": { "zh-TW": "已移除失敗記錄", en: "Failed record removed" },
"文件夹已创建": { "zh-TW": "資料夾已建立", en: "Folder created" },
"创建文件夹失败": { "zh-TW": "建立資料夾失敗", en: "Failed to create folder" },
"文件夹排序失败": { "zh-TW": "資料夾排序失敗", en: "Failed to reorder folders" },
"文件夹已删除": { "zh-TW": "資料夾已刪除", en: "Folder deleted" },
"删除文件夹失败": { "zh-TW": "刪除資料夾失敗", en: "Failed to delete folder" },
"已重新加入发送队列": { "zh-TW": "已重新加入傳送佇列", en: "Added back to send queue" },
"重试失败": { "zh-TW": "重試失敗", en: "Retry failed" },
"已取消发送任务": { "zh-TW": "已取消傳送任務", en: "Send task canceled" },
"取消失败": { "zh-TW": "取消失敗", en: "Cancel failed" },
"当前没有未读邮件": { "zh-TW": "目前沒有未讀郵件", en: "No unread messages" },
"删除所选邮件?": { "zh-TW": "刪除所選郵件?", en: "Delete selected messages?" },
"删除邮件": { "zh-TW": "刪除郵件", en: "Delete messages" },
"批量操作失败": { "zh-TW": "批次操作失敗", en: "Bulk action failed" },
"删除这封邮件?": { "zh-TW": "刪除這封郵件?", en: "Delete this message?" },
"这封草稿已在待发送队列中": { "zh-TW": "這封草稿已在待傳送佇列中", en: "This draft is already scheduled" },
"请先取消定时发送,再继续编辑。": { "zh-TW": "請先取消定時傳送,再繼續編輯。", en: "Cancel the scheduled send before editing." },
"打开草稿失败": { "zh-TW": "開啟草稿失敗", en: "Failed to open draft" },
"文件夹内的邮件会移回收件箱,不会被删除。": { "zh-TW": "資料夾內的郵件會移回收件匣,不會被刪除。", en: "Messages in this folder will be moved back to Inbox, not deleted." },
"编辑草稿": { "zh-TW": "編輯草稿", en: "Edit draft" },
"打开邮件": { "zh-TW": "開啟郵件", en: "Open message" },
"发件邮箱": { "zh-TW": "寄件信箱", en: "From mailbox" },
"未选择": { "zh-TW": "未選擇", en: "Not selected" },
"分别发送": { "zh-TW": "分別傳送", en: "Send separately" },
"主 题": { "zh-TW": "主 旨", en: "Subject" },
"输入主题": { "zh-TW": "輸入主旨", en: "Enter subject" },
"写信": { "zh-TW": "寫信", en: "Compose" },
"正在保存草稿...": { "zh-TW": "正在儲存草稿...", en: "Saving draft..." },
"草稿保存失败": { "zh-TW": "草稿儲存失敗", en: "Failed to save draft" },
"发送": { "zh-TW": "傳送", en: "Send" },
"发送中...": { "zh-TW": "傳送中...", en: "Sending..." },
"发送成功": { "zh-TW": "傳送成功", en: "Sent" },
"定时": { "zh-TW": "定時", en: "Schedule" },
"继续": { "zh-TW": "繼續", en: "Continue" },
"继续发送": { "zh-TW": "繼續傳送", en: "Continue sending" },
"继续分别发送": { "zh-TW": "繼續分別傳送", en: "Continue sending separately" },
"继续定时发送": { "zh-TW": "繼續定時傳送", en: "Continue scheduling" },
"请选择发件邮箱": { "zh-TW": "請選擇寄件信箱", en: "Select a from mailbox" },
"确认发送这封邮件?": { "zh-TW": "確認傳送這封郵件?", en: "Send this message?" },
"确认定时发送?": { "zh-TW": "確認定時傳送?", en: "Schedule this message?" },
"请选择发送时间": { "zh-TW": "請選擇傳送時間", en: "Select a send time" },
"发送时间需要晚于当前时间": { "zh-TW": "傳送時間需要晚於目前時間", en: "Send time must be in the future" },
"确认定时": { "zh-TW": "確認定時", en: "Confirm schedule" },
"正在设置...": { "zh-TW": "正在設定...", en: "Scheduling..." },
"输入正文": { "zh-TW": "輸入內文", en: "Write your message" },
"正文": { "zh-TW": "內文", en: "Body" },
"撤销": { "zh-TW": "復原", en: "Undo" },
"重做": { "zh-TW": "重做", en: "Redo" },
"插入": { "zh-TW": "插入", en: "Insert" },
"链接": { "zh-TW": "連結", en: "Link" },
"图片链接": { "zh-TW": "圖片連結", en: "Image link" },
"分隔线": { "zh-TW": "分隔線", en: "Divider" },
"日程": { "zh-TW": "行程", en: "Event" },
"表情": { "zh-TW": "表情", en: "Emoji" },
"格式": { "zh-TW": "格式", en: "Format" },
"预览": { "zh-TW": "預覽", en: "Preview" },
"签名": { "zh-TW": "簽名", en: "Signature" },
"清除格式": { "zh-TW": "清除格式", en: "Clear formatting" },
"加粗": { "zh-TW": "粗體", en: "Bold" },
"斜体": { "zh-TW": "斜體", en: "Italic" },
"下划线": { "zh-TW": "底線", en: "Underline" },
"删除线": { "zh-TW": "刪除線", en: "Strikethrough" },
"文字颜色": { "zh-TW": "文字顏色", en: "Text color" },
"高亮": { "zh-TW": "醒目提示", en: "Highlight" },
"无序列表": { "zh-TW": "項目符號清單", en: "Bulleted list" },
"有序列表": { "zh-TW": "編號清單", en: "Numbered list" },
"减少缩进": { "zh-TW": "減少縮排", en: "Decrease indent" },
"增加缩进": { "zh-TW": "增加縮排", en: "Increase indent" },
"左对齐": { "zh-TW": "靠左對齊", en: "Align left" },
"居中": { "zh-TW": "置中", en: "Center" },
"右对齐": { "zh-TW": "靠右對齊", en: "Align right" },
"引用": { "zh-TW": "引用", en: "Quote" },
"代码块": { "zh-TW": "程式碼區塊", en: "Code block" },
"邮件预览": { "zh-TW": "郵件預覽", en: "Message preview" },
"编辑链接": { "zh-TW": "編輯連結", en: "Edit link" },
"插入链接": { "zh-TW": "插入連結", en: "Insert link" },
"编辑图片": { "zh-TW": "編輯圖片", en: "Edit image" },
"插入图片": { "zh-TW": "插入圖片", en: "Insert image" },
"链接地址": { "zh-TW": "連結地址", en: "Link URL" },
"图片地址": { "zh-TW": "圖片地址", en: "Image URL" },
"显示文字": { "zh-TW": "顯示文字", en: "Display text" },
"默认使用链接地址": { "zh-TW": "預設使用連結地址", en: "Defaults to the URL" },
"替代文字": { "zh-TW": "替代文字", en: "Alt text" },
"图片说明": { "zh-TW": "圖片說明", en: "Image description" },
"更新": { "zh-TW": "更新", en: "Update" },
"默认字体": { "zh-TW": "預設字體", en: "Default font" },
"微软雅黑": { "zh-TW": "微軟正黑體", en: "Microsoft YaHei" },
"小号": { "zh-TW": "小號", en: "Small" },
"中号": { "zh-TW": "中號", en: "Medium" },
"大号": { "zh-TW": "大號", en: "Large" },
"默认": { "zh-TW": "預設", en: "Default" },
"红色": { "zh-TW": "紅色", en: "Red" },
"蓝色": { "zh-TW": "藍色", en: "Blue" },
"绿色": { "zh-TW": "綠色", en: "Green" },
"紫色": { "zh-TW": "紫色", en: "Purple" },
"黄色": { "zh-TW": "黃色", en: "Yellow" },
"粉色": { "zh-TW": "粉色", en: "Pink" },
"无高亮": { "zh-TW": "無醒目提示", en: "No highlight" },
"新建日程": { "zh-TW": "新增行程", en: "New event" },
"输入日程主题": { "zh-TW": "輸入行程主旨", en: "Enter event title" },
"请输入日程主题": { "zh-TW": "請輸入行程主旨", en: "Enter an event title" },
"开始": { "zh-TW": "開始", en: "Start" },
"全天": { "zh-TW": "全天", en: "All day" },
"持续": { "zh-TW": "持續", en: "Duration" },
"自定义": { "zh-TW": "自訂", en: "Custom" },
"提醒": { "zh-TW": "提醒", en: "Reminder" },
"重复": { "zh-TW": "重複", en: "Repeat" },
"农历": { "zh-TW": "農曆", en: "Lunar" },
"位置": { "zh-TW": "位置", en: "Location" },
"请输入位置": { "zh-TW": "請輸入位置", en: "Enter location" },
"描述": { "zh-TW": "描述", en: "Description" },
"输入描述": { "zh-TW": "輸入描述", en: "Enter description" },
"确定": { "zh-TW": "確定", en: "OK" },
"准时": { "zh-TW": "準時", en: "On time" },
"永不": { "zh-TW": "永不", en: "Never" },
"每天": { "zh-TW": "每天", en: "Daily" },
"每周": { "zh-TW": "每週", en: "Weekly" },
"每月": { "zh-TW": "每月", en: "Monthly" },
"每年": { "zh-TW": "每年", en: "Yearly" },
"明早 9 点": { "zh-TW": "明早 9 點", en: "Tomorrow 9 AM" },
"下周一 9 点": { "zh-TW": "下週一 9 點", en: "Next Monday 9 AM" },
"时间": { "zh-TW": "時間", en: "Time" },
}
const templateTranslations: { pattern: RegExp; "zh-TW": string; en: string }[] = [
{ pattern: /^已刷新 (.+)$/, "zh-TW": "已重新整理 $1", en: "Refreshed $1" },
{ pattern: /^已选 (\d+) 封$/, "zh-TW": "已選 $1 封", en: "$1 selected" },
{ pattern: /^(\d+) \/ (\d+) 封邮件$/, "zh-TW": "$1 / $2 封郵件", en: "$1 / $2 messages" },
{ pattern: /^(\d+) \/ (\d+) 封$/, "zh-TW": "$1 / $2 封", en: "$1 / $2 messages" },
{ pattern: /^(\d+) \/ (\d+) 封定时邮件$/, "zh-TW": "$1 / $2 封定時郵件", en: "$1 / $2 scheduled messages" },
{ pattern: /^(\d+) \/ (\d+) 个发送任务$/, "zh-TW": "$1 / $2 個傳送任務", en: "$1 / $2 send tasks" },
{ pattern: /^收到 (\d+) 封新邮件$/, "zh-TW": "收到 $1 封新郵件", en: "$1 new messages" },
{ pattern: /^新邮件:(.+)$/, "zh-TW": "新郵件:$1", en: "New message: $1" },
{ pattern: /^(.+) 等发来新邮件$/, "zh-TW": "$1 等寄來新郵件", en: "$1 and others sent new mail" },
{ pattern: /^已标记 (\d+) 封邮件为已读$/, "zh-TW": "已標記 $1 封郵件為已讀", en: "Marked $1 messages as read" },
{ pattern: /^已处理 (\d+) 封邮件$/, "zh-TW": "已處理 $1 封郵件", en: "Processed $1 messages" },
{ pattern: /^将删除当前选中的 (\d+) 封邮件,此操作无法从邮件列表中恢复。$/, "zh-TW": "將刪除目前選取的 $1 封郵件,此操作無法從郵件清單中復原。", en: "This will delete the $1 selected messages. This cannot be undone from the message list." },
{ pattern: /^邮件“(.+)”将被删除。$/, "zh-TW": "郵件「$1」將被刪除。", en: "Message “$1” will be deleted." },
{ pattern: /^删除文件夹“(.+)”?$/, "zh-TW": "刪除資料夾「$1」?", en: "Delete folder “$1”?" },
{ pattern: /^删除标签 (.+)$/, "zh-TW": "刪除標籤 $1", en: "Delete label $1" },
{ pattern: /^移除标签 (.+)$/, "zh-TW": "移除標籤 $1", en: "Remove label $1" },
{ pattern: /^移除 (.+)$/, "zh-TW": "移除 $1", en: "Remove $1" },
{ pattern: /^已将 (\d+) 封邮件移回收件箱$/, "zh-TW": "已將 $1 封郵件移回收件匣", en: "Moved $1 messages back to Inbox" },
{ pattern: /^发给 (.+)$/, "zh-TW": "寄給 $1", en: "To $1" },
{ pattern: /^来源:(.+)$/, "zh-TW": "來源:$1", en: "Source: $1" },
{ pattern: /^尝试:(\d+)\/(\d+)$/, "zh-TW": "嘗試:$1/$2", en: "Attempts: $1/$2" },
{ pattern: /^下次:(.+)$/, "zh-TW": "下次:$1", en: "Next: $1" },
{ pattern: /^投递于 (.+)$/, "zh-TW": "投遞於 $1", en: "Delivered at $1" },
{ pattern: /^尝试次数:(\d+)$/, "zh-TW": "嘗試次數:$1", en: "Attempts: $1" },
{ pattern: /^已分别发送 (\d+) 封邮件$/, "zh-TW": "已分別傳送 $1 封郵件", en: "Sent $1 messages separately" },
{ pattern: /^已定时发送 (.+)$/, "zh-TW": "已定時傳送 $1", en: "Scheduled for $1" },
{ pattern: /^草稿已保存 (.+)$/, "zh-TW": "草稿已儲存 $1", en: "Draft saved $1" },
{ pattern: /^发送时间:(.+)$/, "zh-TW": "傳送時間:$1", en: "Send time: $1" },
{ pattern: /^当前单个附件上限 (.+)$/, "zh-TW": "目前單個附件上限 $1", en: "Current per-attachment limit: $1" },
{ pattern: /^(\d+)分钟$/, "zh-TW": "$1分鐘", en: "$1 min" },
{ pattern: /^(\d+)分钟前$/, "zh-TW": "$1分鐘前", en: "$1 min before" },
{ pattern: /^(\d+) 分钟后$/, "zh-TW": "$1 分鐘後", en: "In $1 min" },
{ pattern: /^(\d+)小时$/, "zh-TW": "$1小時", en: "$1 hr" },
{ pattern: /^(\d+)小时前$/, "zh-TW": "$1小時前", en: "$1 hr before" },
{ pattern: /^(\d+) 小时后$/, "zh-TW": "$1 小時後", en: "In $1 hr" },
{ pattern: /^(\d+)天$/, "zh-TW": "$1天", en: "$1 day" },
{ pattern: /^(\d+)天前$/, "zh-TW": "$1天前", en: "$1 day before" },
{ pattern: /^(.+)前$/, "zh-TW": "$1前", en: "$1 before" },
]
export function translateUiText(value: string, language: Language): string {
if (language === "zh-CN" || !value) return value
const match = value.match(/^(\s*)([\s\S]*?)(\s*)$/)
const leading = match?.[1] || ""
const text = match?.[2] || value
const trailing = match?.[3] || ""
if (!text) return value
const translated = translateCore(text, language)
return `${leading}${translated}${trailing}`
}
function translateCore(text: string, language: Exclude<Language, "zh-CN">): string {
const exact = exactTranslations[text]
if (exact) return exact[language]
for (const rule of templateTranslations) {
if (rule.pattern.test(text)) return text.replace(rule.pattern, rule[language])
}
return text
}
const textSources = new WeakMap<Text, string>()
const textLastApplied = new WeakMap<Text, string>()
const attrSources = new WeakMap<Element, Partial<Record<string, string>>>()
const attrLastApplied = new WeakMap<Element, Partial<Record<string, string>>>()
const translatableAttributes = ["placeholder", "title", "aria-label"] as const
let translateTimer: number | undefined
export function LanguageDomSync() {
const [language] = useLanguage()
React.useEffect(() => {
document.documentElement.lang = languageOptions.find((item) => item.value === language)?.htmlLang || language
scheduleLocalize(language)
const observer = new MutationObserver(() => scheduleLocalize(language))
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...translatableAttributes] })
return () => {
observer.disconnect()
if (translateTimer) window.clearTimeout(translateTimer)
}
}, [language])
return null
}
function scheduleLocalize(language: Language) {
if (typeof window === "undefined") return
if (translateTimer) window.clearTimeout(translateTimer)
translateTimer = window.setTimeout(() => localizeDocument(language), 0)
}
function localizeDocument(language: Language) {
if (!document.body) return
localizeElement(document.body, language)
}
function localizeElement(root: Element, language: Language) {
if (shouldSkipElement(root)) return
localizeAttributes(root, language)
for (const child of Array.from(root.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) localizeTextNode(child as Text, language)
else if (child.nodeType === Node.ELEMENT_NODE) localizeElement(child as Element, language)
}
}
function localizeTextNode(node: Text, language: Language) {
if (!node.parentElement || shouldSkipElement(node.parentElement)) return
const current = node.textContent || ""
if (!current.trim()) return
let source = textSources.get(node)
const last = textLastApplied.get(node)
if (!source || (last !== undefined && current !== last && shouldTranslate(current))) {
source = current
textSources.set(node, source)
}
if (!source || !shouldTranslate(source)) return
const next = translateUiText(source, language)
textLastApplied.set(node, next)
if (current !== next) node.textContent = next
}
function localizeAttributes(element: Element, language: Language) {
if (shouldSkipElement(element)) return
for (const attr of translatableAttributes) {
const current = element.getAttribute(attr)
if (!current || !current.trim()) continue
let sources = attrSources.get(element)
if (!sources) {
sources = {}
attrSources.set(element, sources)
}
let applied = attrLastApplied.get(element)
if (!applied) {
applied = {}
attrLastApplied.set(element, applied)
}
if (!sources[attr] || (applied[attr] !== undefined && current !== applied[attr] && shouldTranslate(current))) sources[attr] = current
const source = sources[attr]
if (!source || !shouldTranslate(source)) continue
const next = translateUiText(source, language)
applied[attr] = next
if (current !== next) element.setAttribute(attr, next)
}
}
function shouldSkipElement(element: Element) {
const tag = element.tagName.toLowerCase()
if (["script", "style", "code", "pre", "textarea", "option"].includes(tag)) return true
return Boolean(element.closest("[data-lanqin-i18n-ignore], [contenteditable='true'], .ProseMirror"))
}
function shouldTranslate(value: string) {
const text = value.trim()
if (!text) return false
return Boolean(exactTranslations[text]) || templateTranslations.some((rule) => rule.pattern.test(text))
}
+74
View File
@@ -0,0 +1,74 @@
import type { PermissionKey, User } from "@/lib/api-types"
export const MAIL_PERMISSIONS: PermissionKey[] = [
"mail.access",
"mail.messages.read",
"mail.messages.send",
"mail.messages.drafts",
"mail.messages.schedule",
"mail.messages.organize",
"mail.labels.manage",
"mail.attachments.download",
"mail.contacts.manage",
"mail.signatures.manage",
"mail.rules.manage",
"mail.blocked_senders.manage",
"mail.stats.view",
"mail.mailboxes.apply",
]
export const ADMIN_PERMISSIONS: PermissionKey[] = [
"admin.overview.view",
"admin.users.view",
"admin.users.create",
"admin.users.update",
"admin.users.delete",
"admin.users.reset_password",
"admin.permission_groups.view",
"admin.permission_groups.create",
"admin.permission_groups.update",
"admin.permission_groups.delete",
"admin.domains.view",
"admin.domains.create",
"admin.domains.update",
"admin.domains.delete",
"admin.dns.view",
"admin.dns.check",
"admin.mailboxes.view",
"admin.mailboxes.create",
"admin.mailboxes.update",
"admin.mailboxes.delete",
"admin.aliases.view",
"admin.aliases.create",
"admin.aliases.update",
"admin.aliases.delete",
"admin.messages.view",
"admin.messages.read",
"admin.messages.attachments",
"admin.settings.view",
"admin.settings.update",
"admin.settings.test_smtp",
"admin.templates.view",
"admin.templates.update",
"admin.templates.reset",
]
export function hasPermission(user: User | undefined | null, permission: PermissionKey) {
if (!user) return false
if (user.role === "admin") return true
return (user.permissions || []).includes(permission)
}
export function hasAnyPermission(user: User | undefined | null, permissions: PermissionKey[]) {
if (!user) return false
if (user.role === "admin") return true
return permissions.some((permission) => (user.permissions || []).includes(permission))
}
export function hasAdminAccess(user: User | undefined | null) {
return hasAnyPermission(user, ADMIN_PERMISSIONS)
}
export function hasMailAccess(user: User | undefined | null) {
return hasPermission(user, "mail.access")
}
+24
View File
@@ -84,3 +84,27 @@ export function decodeMimeHeader(value: string): string {
}
})
}
export interface LabelColorStyle {
/** CSS background-color value (HSL string) */
backgroundColor: string
/** CSS color value for text — always high contrast against the background */
color: string
}
/**
* Generate a deterministic, high-contrast color pair for a label name.
* Uses FNV-1a hash with position-dependent mixing HSL mapping.
* Text is always white (lightness 45 % guarantees dark-enough background).
*/
export function generateLabelColor(name: string): LabelColorStyle {
// FNV-1a with 32-bit offset basis and prime
let h = 0x811c9dc5
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i) + i * 0x01000193 // position-dependent seed
h = Math.imul(h, 0x01000193)
h ^= h >>> 16
}
const hue = ((h >>> 0) % 360 + 360) % 360
return { backgroundColor: `hsl(${hue}, 70%, 45%)`, color: "#ffffff" }
}
+12 -7
View File
@@ -3,16 +3,18 @@ import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import { NotFoundPage } from "@/pages/not-found"
import "./index.css"
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
@@ -30,8 +32,11 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">...</div>}>
<RouterProvider router={router} />
</React.Suspense>
<Toaster />
<LanguageDomSync />
</QueryClientProvider>
</React.StrictMode>,
)
+1104 -149
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -21,7 +21,7 @@ export function LoginPage() {
const login = useMutation({
mutationFn: (form: FormData) => challengeToken
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
: api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }),
onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken)
@@ -38,7 +38,7 @@ export function LoginPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
@@ -49,8 +49,8 @@ export function LoginPage() {
{!challengeToken ? (
<>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
<Label htmlFor="loginName" className="text-sm font-medium"></Label>
<Input id="loginName" name="loginName" type="text" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -85,4 +85,3 @@ export function LoginPage() {
</div>
)
}
+2495 -434
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -65,7 +65,7 @@ export function RegisterPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
@@ -113,11 +113,11 @@ export function RegisterPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium"></Label>
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
+6 -1
View File
@@ -1,4 +1,9 @@
declare module "dompurify" {
const DOMPurify: { sanitize: (source: string) => string }
type SanitizeConfig = {
ADD_ATTR?: string[]
ADD_TAGS?: string[]
WHOLE_DOCUMENT?: boolean
}
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
export default DOMPurify
}
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string
readonly VITE_RELEASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+2
View File
@@ -8,6 +8,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+65 -10
View File
@@ -7,14 +7,30 @@
# =========================
# 单容器部署镜像。默认使用你的 GHCR 镜像。
# 本地源码构建请用:docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
LANQIN_IMAGE=ghcr.io/lanqin996/lanqin-email:latest
LANQIN_IMAGE=ghcr.io/zxyszx/newszxcn-email:latest
# 多容器调试版镜像。只在 docker-compose.stack.yml 中使用。
LANQIN_API_IMAGE=ghcr.io/lanqin996/lanqin-email-api:latest
LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest
LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest
LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest
LANQIN_RSPAMD_IMAGE=ghcr.io/lanqin996/lanqin-email-rspamd:latest
LANQIN_API_IMAGE=ghcr.io/zxyszx/newszxcn-email-api:latest
LANQIN_WEB_IMAGE=ghcr.io/zxyszx/newszxcn-email-web:latest
LANQIN_POSTFIX_IMAGE=ghcr.io/zxyszx/newszxcn-email-postfix:latest
LANQIN_DOVECOT_IMAGE=ghcr.io/zxyszx/newszxcn-email-dovecot:latest
LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
# 后台“立即更新”使用的内部鉴权令牌。一键安装会自动生成,请勿对外公开。
# 手动部署可执行:openssl rand -hex 24
LANQIN_UPDATE_TOKEN=
# 一键安装器记录的部署方式。手动部署时可以留空。
LANQIN_INSTALL_FIREWALL_MODE=
LANQIN_INSTALL_WEB_MODE=
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
LANQIN_HTTP_BIND=80
LANQIN_SMTP_BIND=25
LANQIN_SMTPS_BIND=465
LANQIN_SUBMISSION_BIND=587
LANQIN_IMAPS_BIND=993
LANQIN_POP3S_BIND=995
# =========================
# 对外访问地址
@@ -28,15 +44,16 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配
# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书LanQin API 的 SMTP submission 不会启用
LANQIN_TLS_CERT_FILE=
LANQIN_TLS_KEY_FILE=
# =========================
# 初始管理员
# =========================
# 第一次启动时创建这个管理员账号。
LANQIN_ADMIN_EMAIL=admin@example.com
# 第一次启动时创建管理员账号,不会自动创建同名邮箱或域名
# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
LANQIN_ADMIN_USERNAME=admin
# 生产环境必须改掉默认密码。
LANQIN_ADMIN_PASSWORD=ChangeMe123!
@@ -84,15 +101,34 @@ LANQIN_TURNSTILE_SECRET_KEY=
# SMTP 发信
# =========================
# 单容器部署默认提交给容器内 Postfix。
# Split stack 会由 docker-compose.stack.yml 默认覆盖为 postfix:25。
# 如需在 split stack 使用外部 SMTP,可设置 LANQIN_STACK_SMTP_HOST / LANQIN_STACK_SMTP_PORT。
# 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。
LANQIN_SMTP_HOST=127.0.0.1
LANQIN_SMTP_PORT=25
LANQIN_STACK_SMTP_HOST=
LANQIN_STACK_SMTP_PORT=
LANQIN_SMTP_USERNAME=
LANQIN_SMTP_PASSWORD=
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
LANQIN_SMTP_REQUIRE_TLS=false
# 开放 API 最终投递事件回调的 HMAC-SHA256 密钥。生产环境请使用高强度随机值。
# 未配置时 /api/open/v1/delivery-events 返回 503。
LANQIN_DELIVERY_WEBHOOK_SECRET=
# 可选:把发送队列与最终投递状态主动推送给外部系统。URL 默认必须为公网 HTTPS。
LANQIN_STATUS_WEBHOOK_URL=
LANQIN_STATUS_WEBHOOK_SECRET=
# 仅可信内网或本地测试可开启;开启后也允许 HTTP 与私网目标。
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
LANQIN_SUBMISSION_ADDR=
LANQIN_SUBMISSION_TLS_ADDR=
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
# =========================
# 收件 / Maildir 同步
# =========================
@@ -123,9 +159,28 @@ LANQIN_MAIL_AUTO_REFRESH=true
# 自动刷新间隔,单位:秒。
LANQIN_MAIL_REFRESH_SECONDS=30
# 是否启用外部 IMAP 接入。也可在后台“系统设置 > 外部 IMAP”里开启,默认关闭。
LANQIN_EXTERNAL_IMAP_ENABLED=false
# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串;也可在后台“系统设置 > 外部 IMAP”里配置。
LANQIN_EXTERNAL_IMAP_SECRET_KEY=
# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒;也可在后台配置。
LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300
# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险;也可在后台配置。
LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false
# Gmail 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
# Microsoft 365 / Outlook 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
# =========================
# 系统
# =========================
# 容器时区。
TZ=Asia/Shanghai
+74 -14
View File
@@ -1,4 +1,32 @@
# LanQin Email Docker 部署说明
# NewSzxcn Email Docker 部署说明
## 一键安装与更新
推荐直接使用仓库根目录的管理脚本:
```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
后续操作:
```bash
sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
## 最简单部署:单容器镜像版
@@ -7,7 +35,7 @@
```bash
cd deploy
cp .env.example .env
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / LANQIN_ADMIN_PASSWORD
docker compose pull
docker compose up -d
```
@@ -55,12 +83,12 @@ docker compose down
默认镜像:
```text
ghcr.io/lanqin996/lanqin-email:latest
ghcr.io/lanqin996/lanqin-email-api:latest
ghcr.io/lanqin996/lanqin-email-web:latest
ghcr.io/lanqin996/lanqin-email-postfix:latest
ghcr.io/lanqin996/lanqin-email-dovecot:latest
ghcr.io/lanqin996/lanqin-email-rspamd:latest
ghcr.io/zxyszx/newszxcn-email:latest
ghcr.io/zxyszx/newszxcn-email-api:latest
ghcr.io/zxyszx/newszxcn-email-web:latest
ghcr.io/zxyszx/newszxcn-email-postfix:latest
ghcr.io/zxyszx/newszxcn-email-dovecot:latest
ghcr.io/zxyszx/newszxcn-email-rspamd:latest
```
如果拉取时报:
@@ -128,18 +156,29 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
- 第三方客户端通过 SMTP `465/587` 发信时,Postfix 会把已认证发件人的邮件自动 BCC 到 `发件人+Sent@域名`Dovecot LMTP 会保存到该邮箱的 `Sent` 文件夹,Webmail 扫描后会显示在“已发送”
- 第三方客户端通过 LanQin API 提供的 SMTP `465/587` 发信Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重
- 用户可在个人邮箱管理中接入外部 IMAP 账号;默认关闭,可在后台“系统设置 > 外部 IMAP”开启并配置密钥/OAuth。本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置外部 IMAP 密码加密密钥,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback``/api/external-imap-oauth/outlook/callback`
- send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 Postfix/Dovecot 仍在使用容器自带的测试证书。
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
```dotenv
LANQIN_HTTP_BIND=127.0.0.1:8088
```
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
```env
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
LANQIN_SUBMISSION_ADDR=:587
LANQIN_SUBMISSION_TLS_ADDR=:465
```
单容器示例:
@@ -151,7 +190,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
```
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
@@ -170,12 +209,34 @@ LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false
```
果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查
需把上游服务商或 DSN 处理器的最终送达、退信、投诉、拒收事件写回开放 API,请设置
```env
LANQIN_DELIVERY_WEBHOOK_SECRET=replace-with-a-long-random-secret
```
回调地址、签名算法和事件格式见仓库中的 `docs/API.md``docs/openapi.json`。该接口未配置密钥时返回 `503`
如需把状态变化主动推送到集成方,可额外设置:
```env
LANQIN_STATUS_WEBHOOK_URL=https://integration.example.com/hooks/lanqin
LANQIN_STATUS_WEBHOOK_SECRET=replace-with-another-long-random-secret
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
```
事件先写入 SQLite outbox,再由后台 worker 投递;非 2xx 响应会按退避策略重试,最多 10 次。默认只允许公网 HTTPS,禁止重定向、URL 用户信息和私网/本机目标。只有可信内网或本地测试才应开启 `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS`
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
```bash
docker compose exec lanqin-email supervisorctl status
docker compose exec lanqin-email postconf -M smtp/inet submission/inet
docker compose exec lanqin-email postconf -M smtp/inet
# SMTP 提交 465/587 由 LanQin API 提供,不再由 Postfix 监听。
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;"
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select status,attempt_count,last_error from send_queue order by created_at desc limit 10;"
docker compose logs --tail=200 lanqin-email
```
@@ -186,4 +247,3 @@ docker compose logs --tail=200 lanqin-email
- 建议在服务器或边缘网关配置 HTTPS。
- 云厂商通常默认封禁 25 端口,需要单独申请解封。
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
+8 -2
View File
@@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY apps/api ./
ARG APP_VERSION="dev"
ARG APP_COMMIT=""
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server
CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \
-o /out/lanqin-api ./cmd/server
FROM node:20-bookworm-slim AS web-build
WORKDIR /src
@@ -16,8 +20,10 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter lanqin-email-web...
pnpm install --frozen-lockfile --filter newszxcn-email-web...
COPY apps/web apps/web
ARG VITE_APP_VERSION=""
ARG VITE_RELEASE_URL=""
RUN pnpm --dir apps/web run build
FROM debian:bookworm-slim
+21 -2
View File
@@ -7,11 +7,14 @@ set -eu
: "${LANQIN_ADDR:=127.0.0.1:8080}"
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
: "${LANQIN_SMTP_PORT:=25}"
: "${LANQIN_SUBMISSION_ADDR:=}"
: "${LANQIN_SUBMISSION_TLS_ADDR:=}"
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
: "${LANQIN_TLS_CERT_FILE:=}"
: "${LANQIN_TLS_KEY_FILE:=}"
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_MAILDIR_ROOT
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR LANQIN_SUBMISSION_MAX_MESSAGE_MB LANQIN_MAILDIR_ROOT LANQIN_TLS_CERT_FILE LANQIN_TLS_KEY_FILE
addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
@@ -23,28 +26,44 @@ elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
fi
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then
TLS_CERT="$LANQIN_TLS_CERT_FILE"
TLS_KEY="$LANQIN_TLS_KEY_FILE"
: "${LANQIN_SUBMISSION_ADDR:=:587}"
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
else
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
fi
fi
if [ -n "$LANQIN_SUBMISSION_ADDR$LANQIN_SUBMISSION_TLS_ADDR" ] && { [ "$TLS_CERT" = "/etc/ssl/certs/ssl-cert-snakeoil.pem" ] || [ "$TLS_KEY" = "/etc/ssl/private/ssl-cert-snakeoil.key" ]; }; then
echo "warning: SMTP submission disabled because LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE are not configured with readable certificate files" >&2
LANQIN_SUBMISSION_ADDR=""
LANQIN_SUBMISSION_TLS_ADDR=""
fi
export LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_tls_cert_file = ${TLS_CERT}"
postconf -e "smtpd_tls_key_file = ${TLS_KEY}"
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
+11
View File
@@ -5,6 +5,7 @@ server {
index index.html;
location /api/ {
client_max_body_size 50m;
proxy_pass http://127.0.0.1:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -17,7 +18,17 @@ server {
proxy_pass http://127.0.0.1:8080/healthz;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri $uri/ /index.html;
}
}
+6 -2
View File
@@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY apps/api ./
ARG APP_VERSION="dev"
ARG APP_COMMIT=""
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server
CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \
-o /out/lanqin-api ./cmd/server
FROM debian:bookworm-slim
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -17,5 +21,5 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
EXPOSE 8080
EXPOSE 8080 465 587
CMD ["lanqin-api"]
+5
View File
@@ -3,3 +3,8 @@ services:
build:
context: ..
dockerfile: deploy/all-in-one/Dockerfile
args:
APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local}
VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-}
+6
View File
@@ -3,11 +3,17 @@ services:
build:
context: ..
dockerfile: deploy/api.Dockerfile
args:
APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local}
web:
build:
context: ..
dockerfile: deploy/web.Dockerfile
args:
VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-}
postfix:
build:
+18 -8
View File
@@ -1,17 +1,29 @@
services:
api:
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
image: ${LANQIN_API_IMAGE:-ghcr.io/zxyszx/newszxcn-email-api:latest}
env_file: .env
environment:
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-}
LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-}
volumes:
- ./data:/data:ro
- ./data:/data
- ./mail:/var/mail/vhosts:ro
# 生产环境如需第三方客户端校验证书,请取消下面挂载的注释,并在 .env 配置:
# LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
# LANQIN_TLS_KEY_FILE=/certs/privkey.pem
# - /etc/letsencrypt/live/${LANQIN_PUBLIC_HOSTNAME}:/certs:ro
ports:
- "465:465"
- "587:587"
depends_on:
- dovecot
- postfix
restart: unless-stopped
web:
image: ${LANQIN_WEB_IMAGE:-ghcr.io/lanqin996/lanqin-email-web:latest}
image: ${LANQIN_WEB_IMAGE:-ghcr.io/zxyszx/newszxcn-email-web:latest}
restart: unless-stopped
nginx:
@@ -27,7 +39,7 @@ services:
restart: unless-stopped
postfix:
image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/lanqin996/lanqin-email-postfix:latest}
image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/zxyszx/newszxcn-email-postfix:latest}
env_file: .env
volumes:
- ./data:/data
@@ -36,15 +48,13 @@ services:
# - /etc/letsencrypt:/etc/letsencrypt:ro
ports:
- "25:25"
- "465:465"
- "587:587"
depends_on:
- dovecot
- rspamd
restart: unless-stopped
dovecot:
image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/lanqin996/lanqin-email-dovecot:latest}
image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/zxyszx/newszxcn-email-dovecot:latest}
env_file: .env
volumes:
- ./data:/data
@@ -59,7 +69,7 @@ services:
restart: unless-stopped
rspamd:
image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/lanqin996/lanqin-email-rspamd:latest}
image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/zxyszx/newszxcn-email-rspamd:latest}
env_file: .env
volumes:
- ./data:/data:ro
+27 -10
View File
@@ -1,19 +1,36 @@
services:
lanqin-email:
image: ${LANQIN_IMAGE:-ghcr.io/lanqin996/lanqin-email:latest}
image: ${LANQIN_IMAGE:-ghcr.io/zxyszx/newszxcn-email:latest}
env_file: .env
environment:
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
ports:
- "80:80"
- "443:443"
- "25:25"
- "465:465"
- "587:587"
- "993:993"
- "995:995"
- "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_SMTP_BIND:-25}:25"
- "${LANQIN_SMTPS_BIND:-465}:465"
- "${LANQIN_SUBMISSION_BIND:-587}:587"
- "${LANQIN_IMAPS_BIND:-993}:993"
- "${LANQIN_POP3S_BIND:-995}:995"
volumes:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
# - /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email"
restart: unless-stopped
updater:
image: nickfedor/watchtower:1.20.2
environment:
WATCHTOWER_HTTP_API_TOKEN: ${LANQIN_UPDATE_TOKEN:?请在 .env 设置 LANQIN_UPDATE_TOKEN}
command:
- --http-api-update
- --label-enable
- --scope
- newszxcn-email
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped

Some files were not shown because too many files have changed in this diff Show More