Compare commits

...

89 Commits

Author SHA1 Message Date
zxyszx 6439b3c2dd docs: prepare v1.2.20 release 2026-08-09 11:31:11 +08:00
zxyszx b982c30cd0 fix: merge account and mailbox forwarding 2026-08-09 11:25:23 +08:00
zxyszx aeaa151e90 docs: mark v1.2.19 release complete 2026-08-07 13:50:26 +08:00
zxyszx 60d87a6960 ci: support older ShellCheck warning code
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-07 13:32:32 +08:00
zxyszx 70dd2cec4e feat: prepare v1.2.19 release 2026-08-07 13:29:03 +08:00
zxyszx a9ec9360a8 docs: mark v1.2.18 release complete 2026-08-06 19:07:37 +08:00
zxyszx 942605b2b6 fix: refine Telegram links and 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-06 18:53:21 +08:00
zxyszx 635ab02b29 feat: harden Telegram mail notifications
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-06 17:13:08 +08:00
zxyszx 4c90c1de44 ci: improve Telegram release notification layout 2026-08-06 02:37:47 +08:00
zxyszx 1fa029370d docs: mark v1.2.16 notification release 2026-08-06 02:31:30 +08:00
zxyszx 79f920bf0b feat: add Telegram mail and release notifications
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-06 02:17:11 +08:00
zxyszx c77f63b5af fix: remove mailbox selector empty slot
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-06 01:38:10 +08:00
zxyszx 86773c64ca feat: unify email identity and administrator security
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-05 02:55:36 +08:00
zxyszx a11e1cd2f1 fix: refine mail export and mailbox deletion
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 20:57:41 +08:00
zxyszx 94efc2c62b ci: publish concise release notes 2026-08-04 19:41:01 +08:00
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
122 changed files with 19238 additions and 2613 deletions
+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)
+30
View File
@@ -0,0 +1,30 @@
## 本次更新
### 修复选中邮件下载
- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。
- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。
### 改进 EML 文件信息
- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。
- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。
- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。
### 调整邮箱与设置交互
- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。
- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。
- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。
### 修复最后一个邮箱删除
- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。
- 删除邮箱流程的错误提示改为简体中文。
### 验证
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。
**完整更新日志**[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13)
+35
View File
@@ -0,0 +1,35 @@
## 本次更新
### 统一邮箱登录与注册
- 前台和后台统一使用主登录邮箱与密码登录,不再接受旧用户名登录。
- 自助注册会一次性创建账号和邮箱,并要求填写显示名称、邮箱前缀、域名和密码确认。
- 只有主登录邮箱可以登录网页,账号下的其他邮箱不会成为额外登录入口。
- 网页修改或管理员重置密码时,会同步更新该账号全部邮箱的 SMTP/IMAP 密码。
### 加强管理员与安全管理
- 系统只保留一名安装时创建的管理员,后台和 API 均不能新增或提升第二名管理员。
- 内置“管理员”和“普通用户”权限配置不可修改或删除,自定义权限配置仍可正常创建和分配。
- 双因素认证新增恢复码,服务器菜单支持重置管理员密码和管理员 2FA。
- 保留 Cloudflare Turnstile 登录与注册防刷验证。
### 修复邮件批量移动与界面布局
- “全部邮箱”模式可以将跨邮箱选中的邮件批量移动到各邮箱对应的自定义文件夹。
- 固定邮箱选择器、复制按钮和展开菜单宽度,切换具体邮箱时不再缩窄。
- 邮箱页与设置页统一侧栏宽度,点击设置时分隔线和内容区域不再左右跳动。
### 改进安装与升级兼容
- 新安装默认创建 `admin@邮箱域名` 管理员邮箱,并支持自定义管理员邮箱前缀。
- 旧数据库升级时自动保留现有管理员、邮箱、密码和双因素认证数据,不会生成错误的占位域名账号。
- 管理员在后台修改主登录邮箱后,服务重启不会恢复成安装时的旧邮箱。
- 安装指南、环境变量示例和管理员恢复命令已同步更新。
### 验证
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查和生产构建。
- 已通过安装脚本测试、ShellCheck,以及桌面端和移动端浏览器验证。
**完整更新日志**[v1.2.13...v1.2.14](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.13...v1.2.14)
+14
View File
@@ -0,0 +1,14 @@
## 本次更新
### 修复邮箱选择器右侧空白
- “全部邮箱”状态下移除复制按钮的永久空白占位,选择器恢复为整行宽度。
- 选择具体邮箱时,复制按钮覆盖显示在选择器右侧,不再挤压或缩窄选择器。
- 下拉箭头、未读数量和复制按钮保持独立位置,长邮箱地址仍会正确截断。
### 验证
- 已通过前端 TypeScript 检查、shadcn/ui 检查和生产构建。
- 已在桌面端和 390×844 移动端验证“全部邮箱”与具体邮箱两种状态,无横向溢出或控制台错误。
**完整更新日志**[v1.2.14...v1.2.15](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.14...v1.2.15)
+3
View File
@@ -0,0 +1,3 @@
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
+6
View File
@@ -0,0 +1,6 @@
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
+4
View File
@@ -0,0 +1,4 @@
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
+6
View File
@@ -0,0 +1,6 @@
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
+3
View File
@@ -0,0 +1,3 @@
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+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)
+8
View File
@@ -5,6 +5,14 @@ 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:
+21 -1
View File
@@ -1,17 +1,28 @@
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:
@@ -29,6 +40,15 @@ 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 tests/dkim_sync_test.sh
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
bash tests/install_test.sh
bash tests/dkim_sync_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -42,7 +62,7 @@ jobs:
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
+148 -65
View File
@@ -1,6 +1,7 @@
name: Docker Release
on:
workflow_dispatch:
push:
tags:
- "v*"
@@ -17,7 +18,6 @@ concurrency:
env:
REGISTRY: ghcr.io
PLATFORMS: linux/amd64
jobs:
checks:
@@ -27,6 +27,15 @@ 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 tests/dkim_sync_test.sh
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
bash tests/install_test.sh
bash tests/dkim_sync_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -40,7 +49,7 @@ jobs:
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
@@ -76,9 +85,11 @@ jobs:
version="${version#.}"
version="${version#-}"
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_url=${release_url}" >> "$GITHUB_OUTPUT"
{
echo "tag=${tag}"
echo "version=${version}"
echo "release_url=${release_url}"
} >> "$GITHUB_OUTPUT"
echo "::notice::Docker release tag: ${tag}"
docker:
@@ -93,51 +104,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 "tag=${{ needs.release.outputs.tag }}" >> "$GITHUB_OUTPUT"
echo "version=${{ needs.release.outputs.version }}" >> "$GITHUB_OUTPUT"
{
echo "name=${image,,}"
echo "tag=${{ needs.release.outputs.tag }}"
echo "version=${{ needs.release.outputs.version }}"
} >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
@@ -150,7 +170,7 @@ jobs:
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
@@ -158,9 +178,11 @@ jobs:
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 }}
@@ -184,75 +206,136 @@ jobs:
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)"
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
fi
version_notes=".github/release-notes/${tag}.md"
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md
echo >> generated-release-notes.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
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md
echo >> generated-release-notes.md
echo >> generated-release-notes.md
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md
else
echo "- 首个公开版本。" >> generated-release-notes.md
echo >> generated-release-notes.md
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md
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
# LanQin Email ${tag}
自建邮箱 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
cp generated-release-notes.md release-notes.md
- name: Create or update GitHub release
id: release_result
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
title="LanQin Email ${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
echo "created=false" >> "$GITHUB_OUTPUT"
else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
echo "created=true" >> "$GITHUB_OUTPUT"
fi
- name: Notify Telegram release channel
if: steps.release_result.outputs.created == 'true'
continue-on-error: true
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_URL: ${{ needs.release.outputs.release_url }}
shell: bash
run: |
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
echo "::notice::Telegram release notification is not configured; skipping."
exit 0
fi
python3 - <<'PY'
import html
import os
import re
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
entries = []
for raw in notes.splitlines():
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
line = re.sub(r"^[-*+]\s+", "", line)
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
line = re.sub(r"`([^`]+)`", r"\1", line)
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
if line:
entries.append(line)
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} · {html.escape(title)}</b>"
if description:
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
sections.append(section)
tag = os.environ["RELEASE_TAG"]
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
for section in sections:
added = len(section) + (2 if visible_sections else 0)
if used + added > available:
break
visible_sections.append(section)
used += added
body = "\n\n".join(visible_sections)
if len(visible_sections) < len(sections):
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
PY
jq -n \
--arg chat_id "${TELEGRAM_CHAT_ID}" \
--rawfile text telegram-release-message.txt \
'{
chat_id:$chat_id,
text:$text,
parse_mode:"HTML",
disable_web_page_preview:true
}' > telegram-release-payload.json
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
-o telegram-release-response.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
--data-binary @telegram-release-payload.json \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
exit 1
fi
echo "::notice::Telegram release notification sent."
+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)
+208 -205
View File
@@ -1,240 +1,243 @@
# LanQin Email
# NewSzxcn-Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.md)
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.zh-CN.md)
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![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)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
[![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)
LanQin Email is a self-hosted full-stack webmail solution. The frontend is built with React + TypeScript + shadcn/ui, the backend uses Go + SQLite, and deployment can run as a single all-in-one container with API, Web, Nginx, Postfix, Dovecot, and Rspamd integrated.
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
Community: [Telegram group](https://t.me/+EhII7MSyi3QwNDQ5)
## 主要功能
## Features
- **Webmail client**: multiple mailbox switching, folders, reading and composing messages, drafts, scheduled sending, attachments, search, labels, stars, move/delete, read/unread status.
- **Mailbox enhancements**: contacts, signatures, inbox rules, sender blacklist, mail statistics, archive read messages, empty Trash/Spam.
- **Multi-domain / multi-mailbox**: domain management, DKIM key generation, DNS record display and checks, mailbox accounts, alias forwarding, catch-all toggle.
- **Accounts and permissions**: login/registration, session management, TOTP two-factor authentication, Cloudflare Turnstile, user self-service mailbox requests, permission groups/RBAC.
- **Admin panel**: overview checklist, user/permission group/domain/mailbox/alias/all-message management, system settings, mail templates, SMTP testing.
- **Mail service stack**: Postfix delivery, Dovecot IMAP/POP3, Rspamd anti-spam and DKIM signing, Maildir-to-SQLite sync.
- **Deployment friendly**: default all-in-one single container, plus a multi-container stack for debugging Postfix/Dovecot/Rspamd.
## UI Preview
| Webmail reading and list | Compose · rich-text toolbar |
| 模块 | 能力 |
| --- | --- |
| ![Webmail reading and list](./docs/screenshots/mail-preview.png) | ![Compose rich-text toolbar](./docs/screenshots/compose-preview.png) |
| Switch mailboxes, folders, search, labels, stars, and message reading panel. | Rich-text toolbar supports fonts, headings, bold, italic, underline, colors, highlights, lists, alignment, quotes, code blocks, attachments, emoji, and scheduled sending. |
| Admin panel · system overview | Third-party client configuration |
| ![Admin panel system overview](./docs/screenshots/admin-preview.png) | ![Third-party client configuration](./docs/screenshots/client-preview.png) |
| Manage users, permission groups, domains, mailboxes, aliases, system settings, and send audits. | View IMAP / POP3 / SMTP servers, ports, security modes, and account information in one place. |
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAPPOP3SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
## Repository Structure
## 一键安装
```text
.
├── apps/api # Go API, SQLite schema, mail sync, and business logic
├── apps/web # React/Vite Webmail and admin panel
├── deploy # Docker Compose, image build, Postfix/Dovecot/Rspamd config
└── .github/workflows # Docker image release workflows
支持 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
```
## Requirements
已使用 `root` 登录时,也可以使用:
### Development
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
- Go 1.25+
- Node.js 20+
- pnpm 10.28.2 (can be enabled through corepack)
### 管理面板
### Deployment
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
- Docker Engine
- Docker Compose v2
- A resolvable mail domain, plus available ports such as 25 / 465 / 587 / 993 / 995
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:尚未安装
--------------------------------------------------
1. 一键安装 NewSzxcn Email
0. 退出
==================================================
请选择 [1]
```
> Public email sending and receiving also requires correct MX, SPF, DKIM, and DMARC records, and you should confirm that your cloud provider does not block SMTP ports.
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
## Quick Start
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:运行中
版本:v1.2.19(示例,以实际安装版本为准)
地址:https://mail.example.com
--------------------------------------------------
安装与维护
1. 重新安装(完整备份,失败自动恢复)
2. 更新系统(自动备份,失败自动回滚)
3. 检查并修复现有安装
### Local Development
服务管理
4. 查看运行状态
5. 重启服务
6. 查看实时日志
Backend:
证书与恢复
7. 管理 SSL 证书
8. 回滚到上次更新前版本
账号与帮助
9. 邮箱后台配置指南
10. 查看管理员登录信息
11. 重置管理员登录密码
危险操作
12. 卸载服务(保留数据)
0. 退出
==================================================
请选择 [2]
```
容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 自动检测并确认邮箱地址域名;创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`;回车自动生成 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 repair
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 mod download
go test ./...
go run ./cmd/server
```
Frontend (new terminal):
```bash
cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
Open:
- Web: `http://localhost:5173`
- API: `http://localhost:8080`
The default admin email is `admin@lanqin.local`. For development, explicitly set `LANQIN_ADMIN_PASSWORD`; if it is not set, the backend generates a random password on first startup and prints it to the logs.
### Docker Deployment (single container)
A server only needs the Compose files and configuration under `deploy/`; building from source is not required:
提交前建议运行:
```bash
cd deploy
cp .env.example .env
# Edit .env: domain, public URL, admin email, admin password, etc.
docker compose pull
docker compose up -d
cd apps/api && go test ./...
cd apps/web && pnpm run check
```
Common commands:
## 开源协议
```bash
# View logs
docker compose logs -f lanqin-email
# Pull the latest image and restart
docker compose pull
docker compose up -d
# Stop services
docker compose down
```
To build the image locally from the full source repository:
```bash
cd deploy
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
See [`deploy/README.md`](./deploy/README.md) for more deployment details.
## First Deployment Checklist
1. Edit `deploy/.env`: at minimum, change `LANQIN_PUBLIC_HOSTNAME`, `LANQIN_PUBLIC_BASE_URL`, `LANQIN_ADMIN_EMAIL`, and `LANQIN_ADMIN_PASSWORD`.
2. In production, mount real TLS certificates and set `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`.
3. Log in to the admin panel and add your mail domain.
4. Copy and configure MX, SPF, DKIM, and DMARC records from domain management, then run the DNS check.
5. Create mailbox accounts, alias forwarding, or permission groups; enable registration, 2FA, Turnstile, and self-service mailbox requests as needed.
6. Use the admin SMTP test and Webmail send/receive tests to confirm the full path works.
## Key Environment Variables
See [`deploy/.env.example`](./deploy/.env.example) for the full configuration. Common variables:
| Variable | Description | Default / Example |
|------|------|-----------|
| `LANQIN_IMAGE` | All-in-one image | `ghcr.io/lanqin996/lanqin-email:latest` |
| `LANQIN_PUBLIC_HOSTNAME` | Mail server hostname; affects Postfix/DNS display/links | `mail.example.com` |
| `LANQIN_PUBLIC_BASE_URL` | Public Webmail URL | `https://mail.example.com` |
| `LANQIN_ADMIN_EMAIL` | Initial admin email | `admin@example.com` |
| `LANQIN_ADMIN_PASSWORD` | Initial admin password; must be changed in production | `ChangeMe123!` |
| `LANQIN_DB_PATH` | SQLite database path | `/data/lanqin.db` |
| `LANQIN_ALLOW_INSECURE_HTTP` | Allow non-HTTPS cookies; useful for local debugging | `false` |
| `LANQIN_OPEN_REGISTRATION` | Enable public registration | `false` |
| `LANQIN_TWO_FACTOR_ENABLED` | Global 2FA feature toggle | `false` |
| `LANQIN_TURNSTILE_ENABLED` | Enable Turnstile | `false` |
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail outbound SMTP | `127.0.0.1` / `25` |
| `LANQIN_MAILDIR_ROOT` | Maildir root directory | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | Whether unregistered recipient addresses go into all messages | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | Allow users to request mailboxes by themselves | `false` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | Enable external IMAP access; also configurable in Admin > System Settings > External IMAP | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | Encryption key for external IMAP passwords; required before enabling access; also configurable in admin | Random long string |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | Sync interval for external IMAP local-storage mode; also configurable in admin | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | Allow external IMAP to connect to private/localhost hosts; also configurable in admin | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail external IMAP OAuth2; callback is `/api/external-imap-oauth/gmail/callback` | Empty |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook external IMAP OAuth2; callback is `/api/external-imap-oauth/outlook/callback` | Empty |
## Architecture
```text
┌────────────────────────────────────────────────────────────┐
│ lanqin-email single container │
│ │
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
│ │ Web │ │ Webmail API│ └──────┬───────┘ │
│ │ static │ └─────┬──────┘ │ │
│ └─────────┘ │ Maildir sync │ maps │
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
│ └─────────┘ └────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
Mail flow:
1. **Receiving**: Postfix receives mail → Rspamd scores/marks it → Dovecot writes to Maildir → API worker syncs it into SQLite → Webmail displays it.
2. **Sending**: Webmail calls the API → API builds MIME → SMTP submits to Postfix or an external SMTP server → mail is delivered to the destination.
3. **Local delivery**: In development, internal mailboxes can send directly into the recipient Inbox; if `LANQIN_SMTP_HOST` is not configured, external recipients are not actually delivered.
4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`.
5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables.
## Development and Verification
```bash
# API tests
cd apps/api
go test ./...
# Web checks and build
cd apps/web
pnpm run check
# Single-container source build verification
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## Production Notes
- In production, always change the default admin password and protect `.env`, the SQLite database, Maildir, and DKIM private keys.
- The Web UI can sit behind host Nginx / aaPanel / an edge gateway, but SMTP/IMAP/POP3 certificates must be mounted separately for Postfix/Dovecot inside the container.
- Cloud providers often block port 25 by default; if public email does not send or receive, first check ports, security groups, firewalls, and reverse DNS.
- SQLite is suitable for single-node deployments; before multi-node deployment, migrate the database and adjust Postfix/Dovecot query configuration accordingly.
## SMTP Submission
- Third-party client SMTP submission on `465/587` is handled by the LanQin API process.
- Before enabling SMTP submission, configure `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`; the API will not expose 465/587 externally with a localhost self-signed certificate.
- Postfix only keeps port `25` for public inbound mail and internal/external relay.
- Webmail/API and third-party client sends are first written into Sent, then enter the send queue.
- The send queue is relayed by a LanQin API background worker to `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`; failures are audited and retried with backoff.
- v1 supports sending from the user's own mailbox. For send-as, use an enabled alias forwarding source that points to the user's mailbox, or configure `send_as_grants` in the database.
- If the client later writes its own Sent copy through IMAP APPEND, Maildir sync deduplicates by `Message-ID` within the Sent folder.
## License
[MIT](./LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
Friends: [LINUX DO](https://linux.do/) — a new ideal community
[MIT](LICENSE)
+136 -208
View File
@@ -1,242 +1,170 @@
# LanQin Email
# NewSzxcn-Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.md)
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.zh-CN.md)
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![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)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
## 主要功能
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
## 功能特性
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
- **邮件服务栈**Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
## 界面预览
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
| 模块 | 能力 |
| --- | --- |
| ![Webmail 邮件阅读与列表](./docs/screenshots/mail-preview.png) | ![写邮件富文本编辑工具栏](./docs/screenshots/compose-preview.png) |
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
| 管理后台 · 系统概览 | 第三方客户端配置 |
| ![管理后台系统概览](./docs/screenshots/admin-preview.png) | ![第三方客户端配置](./docs/screenshots/client-preview.png) |
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAPPOP3SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
## 目录结构
## 一键安装
```text
.
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
├── apps/web # React/Vite Webmail 与管理后台
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
└── .github/workflows # 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)
```
- Go 1.25+
- Node.js 20+
- pnpm 10.28.2(可通过 corepack 启用)
脚本会自动完成:
### 部署环境
- 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
- 等待 Web 与 API 健康检查通过
- Docker Engine
- Docker Compose v2
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口
> 一键安装不会替你修改 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 mod download
go test ./...
go run ./cmd/server
```
前端(新终端):
```bash
cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
访问
- Web`http://localhost:5173`
- API`http://localhost:8080`
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
### Docker 部署(单容器)
服务器只需要 `deploy/` 下的 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 build
```
常用命令:
```bash
# 查看日志
docker compose logs -f lanqin-email
# 更新镜像并重启
docker compose pull
docker compose up -d
# 停止服务
docker compose down
```
如需在完整源码仓库中本地构建镜像:
```bash
cd deploy
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
## 首次部署清单
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME``LANQIN_PUBLIC_BASE_URL``LANQIN_ADMIN_EMAIL``LANQIN_ADMIN_PASSWORD`
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`
3. 登录管理后台,添加邮件域名。
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
## 关键环境变量
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
| 变量 | 说明 | 默认/示例 |
|------|------|-----------|
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
## 架构
```text
┌────────────────────────────────────────────────────────────┐
│ lanqin-email 单容器 │
│ │
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
│ │ Web 静态│ │ Webmail API│ └──────┬───────┘ │
│ └─────────┘ └─────┬──────┘ │ │
│ │ Maildir sync │ maps │
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
│ └─────────┘ └────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
邮件流转:
1. **收件**Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
2. **发件**Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开发与验证
```bash
# API 测试
cd apps/api
go test ./...
# Web 检查与构建
cd apps/web
pnpm run check
# 单容器源码构建验证
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## 生产注意事项
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
## SMTP 提交
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`API 不会用 localhost 自签证书对外提供 465/587。
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
## License
[MIT](./LICENSE)
## Star 趋势
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
## 开源协议
[MIT](LICENSE)
+1
View File
@@ -0,0 +1 @@
1.2.15
+8 -8
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,19 +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-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // 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/oauth2 v0.36.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
+8 -8
View File
@@ -10,8 +10,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
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.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
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=
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
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=
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
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=
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
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=
+141 -76
View File
@@ -49,10 +49,10 @@ 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
ORDER BY u.created_at DESC`)
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 CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
@@ -62,13 +62,15 @@ 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)
@@ -92,26 +94,33 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
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"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
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
}
actor := currentUser(r)
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail, err := cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = email
badRequest(w, errors.New("displayName is required"))
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
@@ -121,12 +130,20 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" && (actor == nil || actor.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
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)
@@ -142,8 +159,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err = tx.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); err != nil {
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, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
@@ -171,10 +188,13 @@ 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"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
LoginName string `json:"loginName"`
Email string `json:"email"`
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)
@@ -198,7 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "user not found")
return
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
if existing.Role == "admin" && role != "admin" {
badRequest(w, errors.New("唯一管理员不能降级"))
return
}
if existing.Role != "admin" && role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail := existing.Email
loginName := existing.LoginName
if strings.TrimSpace(emailInput) != "" {
primaryEmail, err = cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
loginName = primaryEmail
}
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return
}
@@ -210,6 +252,21 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("default administrator must remain an active super administrator"))
return
}
if existing.Role == "admin" && disabled {
badRequest(w, errors.New("唯一管理员不能停用"))
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
@@ -254,8 +311,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err := tx.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); err != nil {
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, 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
}
@@ -269,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
if existing.Role == "admin" {
a.updateConfig(func(cfg *Config) {
cfg.AdminEmail = primaryEmail
})
}
user, err := a.adminUserByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
@@ -296,8 +362,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)
@@ -492,14 +558,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)
@@ -513,8 +580,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
@@ -526,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
if role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -568,12 +632,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
} else {
ownerEmail := normalizeEmail(req.OwnerEmail)
if ownerEmail == "" {
ownerEmail = address
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
}
if !strings.Contains(ownerEmail, "@") {
badRequest(w, errors.New("invalid owner email"))
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, 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)
@@ -588,8 +653,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
}
_, 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, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
badRequest(w, err)
return
@@ -684,26 +749,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
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
}
var count int
if current != nil && owner == current.ID {
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")
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
return
}
messageIDs := []string{}
@@ -719,12 +767,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -757,6 +805,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
@@ -765,6 +820,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" {
@@ -822,6 +880,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
@@ -964,7 +1027,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
}
@@ -1009,7 +1072,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
}
@@ -1055,18 +1118,20 @@ 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 {
+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
+84 -50
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"errors"
"net/http"
"strings"
@@ -11,6 +12,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"`
@@ -34,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "验证码错误")
return
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
if consumeErr != nil || !ok {
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
return
}
}
a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil {
@@ -49,17 +54,25 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
email, err := cleanPrimaryEmail(emailInput)
if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), email)
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 +89,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
}
@@ -96,23 +109,54 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
email, err := cleanPrimaryEmail(req.Email)
if err != nil {
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)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
badRequest(w, errors.New("请输入显示名称"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
return
}
parts := strings.SplitN(email, "@", 2)
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
if mailboxLocalPart == "" {
mailboxLocalPart = normalizeLocalPart(parts[0])
}
mailboxDomainID := strings.TrimSpace(req.DomainID)
var mailboxDomain string
if mailboxDomainID != "" {
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
} else {
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
badRequest(w, errors.New("所选邮箱域名不可用"))
} else {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return
}
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
return
}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
if item == mailboxLocalPart {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
}
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -127,8 +171,14 @@ 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 {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
defer tx.Rollback()
if _, err := tx.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
@@ -136,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
}
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
user, err := a.userByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -145,46 +207,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
return
}
// Create a mailbox for the registered user
var mailboxDomainID string
var mailboxLocalPart string
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
// User selected a specific domain and local part
mailboxDomainID = strings.TrimSpace(req.DomainID)
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
} else {
// Auto-detect: use the first active domain and email local part
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
mailboxDomainID = ""
}
if mailboxDomainID != "" {
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
}
}
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
}
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
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})
}
@@ -194,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
return
}
var req struct {
DisplayName string `json:"displayName"`
}
@@ -234,8 +268,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)
+33 -1
View File
@@ -9,11 +9,14 @@ import (
type Config struct {
Addr string
AppVersion string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminUsername string
AdminEmail string
MailDomain string
AdminPassword string
PublicHostname string
PublicBaseURL string
@@ -49,19 +52,35 @@ type Config struct {
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
TelegramMailEnabled bool
TelegramBotToken string
TelegramPrivateChatID string
TelegramBodyMode string
TelegramMailboxIDs string
TelegramIncludeUnregistered bool
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"),
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),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
@@ -97,8 +116,21 @@ func LoadConfig() Config {
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false),
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")),
TelegramMailboxIDs: getenv("LANQIN_TELEGRAM_MAILBOX_IDS", ""),
TelegramIncludeUnregistered: getenvBool("LANQIN_TELEGRAM_INCLUDE_UNREGISTERED", false),
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", ""),
}
}
+39 -3
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
}
}
@@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
dkimName := d.DKIMSelector + "._domainkey." + d.Name
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录")
checks["dkim"] = checkDKIMRecord(dkimTXT, d.DKIMPublicKey)
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
@@ -85,6 +85,42 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks}
}
func checkDKIMRecord(records []string, expectedPublicKey string) DNSCheckStatus {
found := append([]string{}, records...)
expectedPublicKey = compactDKIMPublicKey(expectedPublicKey)
dkimFound := false
for _, record := range records {
tags := map[string]string{}
for _, part := range strings.Split(record, ";") {
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
if !strings.EqualFold(tags["v"], "DKIM1") {
continue
}
dkimFound = true
if expectedPublicKey != "" && compactDKIMPublicKey(tags["p"]) == expectedPublicKey {
return DNSCheckStatus{OK: true, Message: "DKIM 公钥匹配", Found: found}
}
}
if dkimFound {
return DNSCheckStatus{OK: false, Message: "DKIM 公钥与后台生成的记录不一致", Found: found}
}
return DNSCheckStatus{OK: false, Message: "未找到 DKIM 记录", Found: found}
}
func compactDKIMPublicKey(value string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
}
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
found := append([]string{}, records...)
for _, item := range records {
+21 -24
View File
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
}
func (a *App) externalIMAPWorker(ctx context.Context) {
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
}
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
respondError(w, http.StatusForbidden, "external imap is disabled")
return
}
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return
}
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
}
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
}
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
if a.cfg.ExternalIMAPAllowPrivateHosts {
if a.config().ExternalIMAPAllowPrivateHosts {
return nil
}
if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
}
func (a *App) externalIMAPKey() ([]byte, error) {
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
}
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
}
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
switch provider {
case externalIMAPOAuthGmail:
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
ClientID: a.config().ExternalIMAPGmailClientID,
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
},
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook:
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
ClientID: a.config().ExternalIMAPOutlookClientID,
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
}
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
}
imported++
} else {
skipped++
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
}
type externalIMAPFolderState struct {
LastUID uint32
LastUID uint32
Initialized bool
}
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
var state externalIMAPFolderState
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
state.Initialized = true
}
return state
}
@@ -1376,15 +1382,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml"
}
func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
parts := externalIMAPAttachmentPartsFromBodyStructure(body)
items := make([]Attachment, 0, len(parts))
for _, part := range parts {
items = append(items, part.Attachment)
}
return items
}
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC()
items := []externalIMAPAttachmentPart{}
@@ -0,0 +1,236 @@
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 := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
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
}
-19
View File
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
+545 -39
View File
@@ -24,6 +24,10 @@ const mailMessagesPageSize = 30
const customFolderDefaultSortOrderBase = 100000
func isAllMailboxID(mailboxID string) bool {
return strings.EqualFold(strings.TrimSpace(mailboxID), "all")
}
type AttachmentInput struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
@@ -55,10 +59,14 @@ type storedMessage struct {
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at,
COALESCE(SUM(CASE WHEN lower(f.name)='inbox' AND m.is_read=0 THEN 1 ELSE 0 END),0) AS unread_count
FROM mailboxes mb
JOIN domains d ON d.id=mb.domain_id
LEFT JOIN folders f ON f.mailbox_id=mb.id
LEFT JOIN messages m ON m.folder_id=f.id
WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
GROUP BY mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
ORDER BY mb.address`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
@@ -69,7 +77,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var m Mailbox
var created string
if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created, &m.UnreadCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
@@ -81,6 +89,10 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
a.handleAllMailFolders(w, r)
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
@@ -118,6 +130,43 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
FROM folders f
JOIN mailboxes mb ON mb.id=f.mailbox_id
LEFT JOIN messages m ON m.folder_id=f.id
WHERE mb.user_id=? AND mb.status='active'
GROUP BY f.name,f.role
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
WHEN lower(f.name)='drafts' THEN 6000
WHEN lower(f.name)='archive' THEN 7000
WHEN lower(f.name)='spam' THEN 8000
WHEN lower(f.name)='trash' THEN 9000
ELSE MIN(f.sort_order)
END, f.name`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
}
defer rows.Close()
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
items = append(items, f)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
@@ -215,11 +264,6 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
Name string `json:"name"`
}
@@ -236,6 +280,47 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("system folder already exists"))
return
}
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
return
}
mailboxIDs := []string{}
for rows.Next() {
var mailboxID string
if err := rows.Scan(&mailboxID); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
mailboxIDs = append(mailboxIDs, mailboxID)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
rows.Close()
if len(mailboxIDs) == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
for _, mailboxID := range mailboxIDs {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
}
}
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
@@ -250,16 +335,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
if folderID == "" {
badRequest(w, errors.New("folder id is required"))
return
}
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
a.handleDeleteAllMailFolders(w, r, folderID)
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var folderName string
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -329,6 +418,115 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
}
func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
folderName = strings.TrimSpace(folderID[4:])
}
name, err := normalizeCustomFolderName(folderName)
if err != nil {
badRequest(w, err)
return
}
if isSystemFolderName(name) {
badRequest(w, errors.New("system folders cannot be deleted"))
return
}
user := currentUser(r)
type folderTarget struct {
folderID string
mailboxID string
inboxID string
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
}
targets := []folderTarget{}
for rows.Next() {
var target folderTarget
if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
targets = append(targets, target)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
rows.Close()
if len(targets) == 0 {
respondError(w, http.StatusNotFound, "folder not found")
return
}
for i := range targets {
targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load inbox")
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
moved := 0
for _, target := range targets {
messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
return
}
messageIDs := []string{}
for messageRows.Next() {
var messageID string
if err := messageRows.Scan(&messageID); err != nil {
messageRows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
messageIDs = append(messageIDs, messageID)
}
if err := messageRows.Err(); err != nil {
messageRows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
messageRows.Close()
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
return
}
moved++
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
for _, target := range targets {
_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
}
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
return a.ensureFolder(ctx, mailboxID, name)
}
@@ -345,6 +543,30 @@ func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (
}
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
if !ok {
respondError(w, http.StatusNotFound, "label not found")
return
}
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
return
}
folder := r.URL.Query().Get("folder")
if folder == "" {
folder = "Inbox"
}
if normalized, err := normalizeFolderNameForUser(folder); err != nil {
badRequest(w, err)
return
} else {
folder = normalized
}
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?`, []any{user.ID, folder})
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
@@ -377,6 +599,11 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) {
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1`, []any{user.ID})
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
@@ -394,9 +621,15 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
limit := mailMessagesPageSize
if q != "" {
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
like := "%" + q + "%"
args = append(args, like, like, like, like, like)
args = append(args, like, like, like, like, like, like, like, like)
}
var err error
where, args, err = appendMailMessageSearchFilters(r, where, args)
if err != nil {
badRequest(w, err)
return
}
args = append(args, limit+1, offset)
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(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
@@ -428,7 +661,131 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func appendMailMessageSearchFilters(r *http.Request, where string, args []any) (string, []any, error) {
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)`
like := "%" + from + "%"
args = append(args, like, like)
}
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)`
like := "%" + to + "%"
args = append(args, like, like, like, like)
}
if subject := strings.TrimSpace(r.URL.Query().Get("subject")); subject != "" {
where += ` AND m.subject LIKE ?`
args = append(args, "%"+subject+"%")
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachmentMode"))) {
case "with":
where += ` AND m.has_attachments=1`
case "without":
where += ` AND m.has_attachments=0`
default:
if mailSearchFlag(r, "hasAttachments") {
where += ` AND m.has_attachments=1`
}
}
if minSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("minSizeKb"), "minSizeKb"); err != nil {
return where, args, err
} else if ok {
where += ` AND m.size_bytes>=?`
args = append(args, minSize)
}
if maxSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("maxSizeKb"), "maxSizeKb"); err != nil {
return where, args, err
} else if ok {
where += ` AND m.size_bytes<=?`
args = append(args, maxSize)
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("readStatus"))) {
case "read":
where += ` AND m.is_read=1`
case "unread":
where += ` AND m.is_read=0`
default:
if mailSearchFlag(r, "unread") {
where += ` AND m.is_read=0`
}
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flagStatus"))) {
case "starred":
where += ` AND m.is_starred=1`
case "unstarred":
where += ` AND m.is_starred=0`
default:
if mailSearchFlag(r, "starred") {
where += ` AND m.is_starred=1`
}
}
if start, ok, err := mailSearchDateBoundary(r.URL.Query().Get("startDate"), false); err != nil {
return where, args, err
} else if ok {
where += ` AND m.received_at>=?`
args = append(args, start)
}
if end, ok, err := mailSearchDateBoundary(r.URL.Query().Get("endDate"), true); err != nil {
return where, args, err
} else if ok {
where += ` AND m.received_at<=?`
args = append(args, end)
}
return where, args, nil
}
func mailSearchFlag(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func mailSearchDateBoundary(value string, endOfDay bool) (string, bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", false, nil
}
if len(value) == len("2006-01-02") {
t, err := time.Parse("2006-01-02", value)
if err != nil {
return "", false, fmt.Errorf("invalid date %q", value)
}
if endOfDay {
t = t.AddDate(0, 0, 1).Add(-time.Nanosecond)
}
return t.UTC().Format(time.RFC3339Nano), true, nil
}
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return "", false, fmt.Errorf("invalid date %q", value)
}
return t.UTC().Format(time.RFC3339Nano), true, nil
}
func mailSearchSizeBytes(value string, key string) (int64, bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false, nil
}
kb, err := strconv.ParseInt(value, 10, 64)
if err != nil || kb < 0 {
return 0, false, fmt.Errorf("invalid %s %q", key, value)
}
return kb * 1024, true, nil
}
func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
labels, err := a.labelsForUser(r.Context(), currentUser(r).ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load labels")
return
}
respondJSON(w, http.StatusOK, map[string]any{"items": labels})
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
@@ -691,6 +1048,14 @@ var errSenderNotAuthorized = errors.New("sender address is not authorized")
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
return a.sendMailWithSource(ctx, user, mb, req, sendSourceWebmail)
}
func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput, source string) (*MailMessage, error) {
source = strings.TrimSpace(source)
if source == "" {
source = sendSourceWebmail
}
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
return nil, err
}
@@ -742,8 +1107,8 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
a.deleteMessage(ctx, sentID)
return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
}
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
a.deleteMessage(ctx, sentID)
return nil, fmt.Errorf("failed to enqueue delivery: %w", err)
}
@@ -756,7 +1121,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil {
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue
}
copyMsg := base
@@ -767,11 +1132,12 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
continue
}
if rcptMailbox.Status != "active" {
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base
copyMsg.MailboxID = ""
copyMsg.FolderID = ""
@@ -780,6 +1146,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
}
continue
@@ -791,11 +1158,16 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg := base
copyMsg.MailboxID = rcptMailbox.ID
copyMsg.FolderID = inboxID
copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
}
}
}
@@ -1113,15 +1485,23 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
args := []any{user.ID}
where := `user_id=?`
if !isAllMailboxID(mailboxID) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
where += ` AND mailbox_id=?`
args = append(args, mb.ID)
}
args = append(args, "pending", "sending", "failed")
rows, err := a.db.QueryContext(r.Context(), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at
FROM scheduled_sends
WHERE user_id=? AND mailbox_id=? AND status IN ('pending','sending','failed')
ORDER BY send_at ASC, created_at DESC`, user.ID, mb.ID)
WHERE `+where+` AND status IN (?,?,?)
ORDER BY send_at ASC, created_at DESC`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
return
@@ -1158,20 +1538,28 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
if strings.EqualFold(status, "all") {
status = ""
}
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
limit := 30
args := []any{user.ID, mb.ID}
where := `mb.user_id=? AND sq.mailbox_id=?`
args := []any{user.ID}
where := `mb.user_id=?`
if !isAllMailboxID(mailboxID) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
where += ` AND sq.mailbox_id=?`
args = append(args, mb.ID)
}
if status != "" {
if !validSendQueueStatus(status) {
badRequest(w, errors.New("invalid send queue status"))
@@ -1775,6 +2163,74 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
Folder string `json:"folder"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
folder, err := normalizeFolderNameForUser(req.Folder)
if err != nil {
badRequest(w, err)
return
}
ids := make([]string, 0, len(req.IDs))
seen := map[string]bool{}
for _, id := range req.IDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
badRequest(w, errors.New("请选择要移动的邮件"))
return
}
type itemResult struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
OK bool `json:"ok"`
Message string `json:"message"`
}
results := make([]itemResult, 0, len(ids))
folderByMailbox := map[string]string{}
moved := 0
for _, id := range ids {
msg, err := a.loadMessageForRequest(r, id, false)
if err != nil {
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
continue
}
folderID := folderByMailbox[msg.MailboxID]
if folderID == "" {
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
if err != nil {
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
continue
}
folderByMailbox[msg.MailboxID] = folderID
}
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
continue
}
moved++
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
}
failed := len(results) - moved
message := fmt.Sprintf("已移动 %d 封邮件", moved)
if failed > 0 {
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
}
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
@@ -1894,13 +2350,18 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) {
attID := chi.URLParam(r, "id")
row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID)
var filename, contentType, path string
row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?`, attID)
var filename, contentType, path, mailboxID string
var size int64
if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
if err := row.Scan(&filename, &contentType, &size, &path, &mailboxID); err != nil {
respondError(w, http.StatusNotFound, "attachment not found")
return
}
user := currentUser(r)
if mailboxID == "" && (user == nil || user.Role != "admin") {
respondError(w, http.StatusForbidden, "system admin required")
return
}
f, err := os.Open(path)
if err != nil {
respondError(w, http.StatusNotFound, "attachment file missing")
@@ -2108,7 +2569,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil {
return err
}
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -2145,7 +2606,30 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE l.mailbox_id=?
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)`, mailboxID)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []MailLabel{}
for rows.Next() {
var item MailLabel
if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color, &item.MessageCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
FROM mail_labels l
JOIN mailboxes mb ON mb.id=l.mailbox_id
LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE mb.user_id=? AND mb.status='active'
GROUP BY lower(l.name)
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
if err != nil {
return nil, err
}
@@ -2165,7 +2649,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
WHERE ml.message_id=?
ORDER BY lower(l.name)`, messageID)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID)
if err != nil {
return nil, err
}
@@ -2196,7 +2680,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
ORDER BY lower(l.name)`, args...)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
if err != nil {
return err
}
@@ -2214,6 +2698,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
return rows.Err()
}
func mailLabelOrderSQL(alias string) string {
return mailLabelNameOrderSQL(alias + `.name`)
}
func mailLabelNameOrderSQL(expression string) string {
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
}
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
name = normalizeLabelName(name)
if name == "" {
@@ -2251,6 +2743,20 @@ func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID stri
return count > 0
}
func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bool {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?`, labelID, userID).Scan(&count)
return count > 0
}
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
var name string
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
return "", false
}
return name, true
}
func normalizeLabelName(name string) string {
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
if len([]rune(name)) > 32 {
@@ -2284,7 +2790,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p)
}
}
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}
func (a *App) deleteMessage(ctx context.Context, messageID string) {
@@ -0,0 +1,418 @@
package app
import (
"archive/zip"
"bufio"
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode"
)
const maxMailImportBytes int64 = 256 << 20
const maxSelectedMailExport = 200
type exportedMessage struct {
raw []byte
subject string
date time.Time
}
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 _, id := range ids {
message, err := a.rawMessageForExport(r.Context(), id)
if err != nil {
_ = zw.Close()
return
}
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
header.SetModTime(message.date)
entry, err := zw.CreateHeader(header)
if err != nil {
_ = zw.Close()
return
}
if _, err := entry.Write(message.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{}
selectedIDs, err := selectedExportMessageIDs(r)
if err != nil {
return nil, err
}
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
}
if isAllMailboxID(mailboxID) {
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
if !ok {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))")
args = append(args, labelName)
} else {
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")
}
}
if len(selectedIDs) > 0 {
placeholders := make([]string, 0, len(selectedIDs))
for _, id := range selectedIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")")
}
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 selectedExportMessageIDs(r *http.Request) ([]string, error) {
values := r.URL.Query()["messageId"]
if len(values) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(values))
ids := make([]string, 0, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
if len(ids) > maxSelectedMailExport {
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
}
}
return ids, nil
}
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
msg, err := a.storedMessageByID(ctx, id)
if err != nil {
return exportedMessage{}, err
}
exportDate := msg.ReceivedAt
if exportDate.IsZero() {
exportDate = messageDate(msg)
}
if msg.RawPath != "" {
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
}
}
}
attachments, err := a.attachmentInputsForMessage(ctx, id)
if err != nil {
return exportedMessage{}, 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 exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
}
func exportMessageFilename(subject string, date time.Time) string {
name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
return '-'
}
return r
}, strings.TrimSpace(subject))
name = strings.Trim(name, " .-_")
if name == "" {
name = "无主题"
}
runes := []rune(name)
if len(runes) > 80 {
name = string(runes[:80])
}
if date.IsZero() {
return name + ".eml"
}
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
}
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,273 @@
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
stdmail "net/mail"
"net/url"
"strings"
"testing"
"time"
)
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: 中文标题\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 != "中文标题" || list.Items[1].Subject != "older imported message" {
t.Fatalf("list code=%d items=%+v", code, list.Items)
}
receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC)
if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil {
t.Fatal(err)
}
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))
}
if zr.File[0].Name != "中文标题 (20240103).eml" {
t.Fatalf("first filename=%q", zr.File[0].Name)
}
wantModified := receivedAt
if !zr.File[0].Modified.Equal(wantModified) {
t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified)
}
entry, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
exported, err := io.ReadAll(entry)
entry.Close()
if err != nil {
t.Fatalf("exported message err=%v raw=%q", err, exported)
}
parsed, err := stdmail.ReadMessage(bytes.NewReader(exported))
if err != nil {
t.Fatal(err)
}
decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
if err != nil || decodedSubject != "中文标题" {
t.Fatalf("decoded subject=%q err=%v", decodedSubject, err)
}
messageDate, err := parsed.Header.Date()
if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) {
t.Fatalf("message date=%s err=%v", messageDate, err)
}
selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID)
status, selectedArchive := getMailExport(t, owner, selectedPath)
if status != http.StatusOK {
t.Fatalf("selected export status=%d body=%q", status, selectedArchive)
}
selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive)))
if err != nil {
t.Fatal(err)
}
if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" {
t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File))
}
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 TestSelectedMailExportStillEnforcesOwnership(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, "export-owner", "Export Owner", "Password123!", nil)
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
owner := &testClient{t: t, server: ts}
other := &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)
}
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("other login=%d", code)
}
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
var imported map[string]any
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
t.Fatalf("other import=%d response=%v", code, imported)
}
var otherList struct {
Items []MailMessage `json:"items"`
}
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
}
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
status, archive := getMailExport(t, owner, path)
if status != http.StatusOK {
t.Fatalf("cross-owner 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) != 0 {
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
}
}
func exportEntryNames(files []*zip.File) []string {
names := make([]string, 0, len(files))
for _, file := range files {
names = append(names, file.Name)
}
return names
}
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
}
+4 -4
View File
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -0,0 +1,34 @@
package app
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(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 mailboxes struct {
Items []Mailbox `json:"items"`
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
t.Fatalf("delete final mailbox code=%d", code)
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
}
var me map[string]any
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
t.Fatalf("account was not preserved code=%d", code)
}
}
+1 -1
View File
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
}
+53 -22
View File
@@ -45,13 +45,13 @@ 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
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
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 := counts.total(); n > 0 {
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return maildirSyncCounts{}, nil
}
@@ -190,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
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
@@ -297,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
_, err = a.insertMessage(ctx, msg, attachments)
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
return err == nil, err
}
@@ -338,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
if strings.TrimSpace(msg.RecipientAddr) == "" {
msg.RecipientAddr = mb.Address
}
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path
if msg.MessageUID == "" {
@@ -371,6 +372,10 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
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)
if a.shouldNotifyTelegramMessage(ctx, id) {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
}
return err == nil, err
}
@@ -393,16 +398,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID 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=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
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
@@ -513,7 +508,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
@@ -599,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
return address
}
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
address = normalizeEmail(address)
if strings.HasSuffix(address, "@"+domain) {
@@ -623,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if len(to) == 0 {
to = []string{fallbackTo}
}
recipientAddr := originalMailRecipient(m.Header)
sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err
}
if looksLikeHTMLDocument(parsed.Text) {
if strings.TrimSpace(parsed.HTML) == "" {
parsed.HTML = parsed.Text
}
parsed.Text = telegramHTMLToText(parsed.Text)
}
bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" {
@@ -643,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
RecipientAddr: recipientAddr,
Subject: subject,
From: from,
FromName: fromName,
@@ -658,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
}, parsed.Attachments, nil
}
func originalMailRecipient(header netmail.Header) string {
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
value := strings.TrimSpace(header.Get(key))
if key == "Original-Recipient" {
if _, suffix, ok := strings.Cut(value, ";"); ok {
value = strings.TrimSpace(suffix)
}
}
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
return address
}
}
return ""
}
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
@@ -696,6 +717,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil
}
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
if converted, readErr := io.ReadAll(reader); readErr == nil {
decoded = converted
}
}
}
decoded = []byte(strings.ToValidUTF8(string(decoded), ""))
}
switch strings.ToLower(mediaType) {
case "text/html":
if parsed.HTML == "" {
+9 -9
View File
@@ -14,7 +14,7 @@ import (
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
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 {
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
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
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
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`)
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
+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()
}
+908
View File
@@ -0,0 +1,908 @@
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")
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
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, "删除邮箱失败")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
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 email=? AND disabled=0`, 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)
}
})
}
}
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if id == PermissionGroupSuperAdmin {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
if intBool(existingSystem) {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
var req struct {
+59 -61
View File
@@ -137,16 +137,18 @@ type PermissionGroup struct {
}
type PermissionLimits struct {
MaxAttachmentMB int `json:"maxAttachmentMb"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
POP3MinuteLimit int `json:"pop3MinuteLimit"`
MaxAttachmentMB int `json:"maxAttachmentMb"`
MaxMailboxCount int `json:"maxMailboxCount"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
POP3MinuteLimit int `json:"pop3MinuteLimit"`
}
func defaultPermissionLimits() PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: 25,
MaxMailboxCount: 9,
SMTPDailyLimit: 200,
SMTPMinuteLimit: 20,
IMAPMinuteLimit: 200,
@@ -158,6 +160,9 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
if limits.MaxAttachmentMB < 0 {
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
}
if limits.MaxMailboxCount < 0 {
return PermissionLimits{}, errors.New("maxMailboxCount cannot be negative")
}
if limits.SMTPDailyLimit < 0 {
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
}
@@ -173,6 +178,17 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
return limits, nil
}
func normalizeMailboxLimitOverride(value *int) (*int, error) {
if value == nil {
return nil, nil
}
if *value < 0 {
return nil, errors.New("mailboxLimitOverride cannot be negative")
}
normalized := *value
return &normalized, nil
}
func decodeStoredLimits(value string) PermissionLimits {
limits := defaultPermissionLimits()
if strings.TrimSpace(value) == "" {
@@ -197,11 +213,12 @@ func encodePermissionLimits(limits PermissionLimits) string {
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
}
}
@@ -216,11 +233,12 @@ func mergeLimitValue(left, right int) int {
}
func minimalLimits() PermissionLimits {
// minimalLimits sets every field to 1 so that mergePermissionLimits
// (which takes the max of each field) produces correct aggregation
// minimalLimits sets every field to 1 so that mergePermissionLimits
// (which takes the max of each field) produces correct aggregation
// when no group has a limit set for a given field.
return PermissionLimits{
MaxAttachmentMB: 1,
MaxMailboxCount: 1,
SMTPDailyLimit: 1,
SMTPMinuteLimit: 1,
IMAPMinuteLimit: 1,
@@ -236,6 +254,7 @@ func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
return true
}
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
canGrantLimitValue(actor.Limits.MaxMailboxCount, limits.MaxMailboxCount) &&
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
@@ -292,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"},
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限", Description: "查看权限组、权限目录和使用人数。", Category: "权限"},
{Key: PermissionGroupsCreate, Label: "创建权限", Description: "创建自定义权限。", Category: "权限"},
{Key: PermissionGroupsUpdate, Label: "编辑权限", Description: "修改自定义权限名称、说明和权限。", Category: "权限"},
{Key: PermissionGroupsDelete, Label: "删除权限", Description: "删除未被用户使用的自定义权限。", Category: "权限"},
{Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"},
{Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"},
{Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -315,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
{Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
{Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
{Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
{Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
@@ -435,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
return []PermissionGroup{
{
ID: PermissionGroupSuperAdmin,
Name: "超级管理员",
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
@@ -471,21 +490,6 @@ func regularUserDefaultPermissions() []string {
}
}
func fixedPermissionGroupIDs() map[string]bool {
out := map[string]bool{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = true
}
return out
}
func assignablePermissionGroupIDs() map[string]bool {
out := fixedPermissionGroupIDs()
delete(out, PermissionGroupSuperAdmin)
delete(out, PermissionGroupRegular)
return out
}
func isAssignablePermissionGroupID(groupID string) bool {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
}
@@ -498,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out
}
func permissionGroupNames() map[string]string {
out := map[string]string{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = group.Name
}
return out
}
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() {
@@ -597,6 +593,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
if u == nil {
return nil
}
if u.LoginName == "" {
u.LoginName = u.Email
}
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
if err != nil {
return err
@@ -611,6 +610,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
}
u.Permissions = permissions
u.Limits = limits
if u.Role != "admin" && u.MailboxLimitOverride != nil {
u.Limits.MaxMailboxCount = *u.MailboxLimitOverride
}
u.PermissionGroupIDs = groupIDs
u.PermissionGroups = groups
u.Protected = a.isDefaultAdminUser(u)
@@ -769,7 +771,7 @@ func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, grou
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
if role == "admin" {
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "管理员"}
return []string{group.ID}, []PermissionGroupSummary{group}, nil
}
ids := []string{PermissionGroupRegular}
@@ -1029,11 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
}
func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
return u != nil && u.Role == "admin"
}
func sortPermissionGroups(items []PermissionGroup) {
+471 -20
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -26,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled,
Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return
}
@@ -72,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return
}
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
@@ -88,6 +89,17 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
return
}
if user.Role != "admin" && user.Limits.MaxMailboxCount > 0 {
var ownedCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=? AND status='active'`, user.ID).Scan(&ownedCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox quota")
return
}
if ownedCount >= user.Limits.MaxMailboxCount {
respondError(w, http.StatusForbidden, "邮箱数量已达上限")
return
}
}
var passwordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil {
@@ -117,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil
}
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
@@ -483,6 +495,15 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("rule action is required"))
return
}
actions, err := a.cleanRuleActions(r.Context(), user.ID, actions)
if err != nil {
badRequest(w, err)
return
}
if len(actions) == 0 {
badRequest(w, errors.New("rule action is required"))
return
}
conditionsJSON, err := json.Marshal(conditions)
if err != nil {
badRequest(w, err)
@@ -544,6 +565,201 @@ func (a *App) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleUpdateRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := chi.URLParam(r, "id")
item, err := a.ruleByID(r.Context(), user.ID, id)
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "rule not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
var req struct {
MailboxID *string `json:"mailboxId"`
Name *string `json:"name"`
MatchMode *string `json:"matchMode"`
Conditions *[]MailRuleCondition `json:"conditions"`
Actions *[]MailRuleAction `json:"actions"`
ApplyExisting *bool `json:"applyToExisting"`
StopProcessing *bool `json:"stopProcessing"`
Enabled *bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if req.MailboxID != nil {
mailboxID, ok := a.optionalMailboxIDForUser(r, *req.MailboxID)
if !ok {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
item.MailboxID = mailboxID
}
if req.Name != nil {
item.Name = strings.TrimSpace(*req.Name)
if item.Name == "" {
item.Name = "收件规则"
}
}
if req.MatchMode != nil {
raw := strings.ToLower(strings.TrimSpace(*req.MatchMode))
if raw != "all" && raw != "and" && raw != "any" && raw != "or" {
badRequest(w, errors.New("invalid match mode"))
return
}
item.MatchMode = normalizeRuleMatchMode(raw)
}
if req.Conditions != nil {
item.Conditions = normalizeRuleConditions(*req.Conditions, "", "")
if len(item.Conditions) == 0 {
badRequest(w, errors.New("rule condition is required"))
return
}
}
if req.Actions != nil {
item.Actions = normalizeRuleActions(*req.Actions, "")
item.Actions, err = a.cleanRuleActions(r.Context(), user.ID, item.Actions)
if err != nil || len(item.Actions) == 0 {
if err == nil {
err = errors.New("rule action is required")
}
badRequest(w, err)
return
}
}
if req.ApplyExisting != nil {
item.ApplyToExisting = *req.ApplyExisting
}
if req.StopProcessing != nil {
item.StopProcessing = *req.StopProcessing
}
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
conditionsJSON, err := json.Marshal(item.Conditions)
if err != nil {
badRequest(w, err)
return
}
actionsJSON, err := json.Marshal(item.Actions)
if err != nil {
badRequest(w, err)
return
}
item.FromContains = legacyConditionValue(item.Conditions, "from")
item.SubjectContains = legacyConditionValue(item.Conditions, "subject")
item.Action = item.Actions[0].Type
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `UPDATE mail_rules SET mailbox_id=?,name=?,match_mode=?,conditions_json=?,actions_json=?,from_contains=?,subject_contains=?,action=?,apply_to_existing=?,stop_processing=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, item.MailboxID, item.Name, item.MatchMode, string(conditionsJSON), string(actionsJSON), item.FromContains, item.SubjectContains, item.Action, boolInt(item.ApplyToExisting), boolInt(item.StopProcessing), boolInt(item.Enabled), now, id, user.ID)
if err != nil {
badRequest(w, err)
return
}
updated, err := a.ruleByID(r.Context(), user.ID, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
respondJSON(w, http.StatusOK, updated)
}
func (a *App) handleMoveRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
Direction string `json:"direction"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if req.Direction != "up" && req.Direction != "down" {
badRequest(w, errors.New("invalid direction"))
return
}
type orderedRule struct{ id, createdAt string }
rows, err := a.db.QueryContext(r.Context(), `SELECT id,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rules")
return
}
items := []orderedRule{}
for rows.Next() {
var item orderedRule
if err := rows.Scan(&item.id, &item.createdAt); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan rules")
return
}
items = append(items, item)
}
rows.Close()
index := -1
for i := range items {
if items[i].id == chi.URLParam(r, "id") {
index = i
break
}
}
if index < 0 {
respondError(w, http.StatusNotFound, "rule not found")
return
}
target := index - 1
if req.Direction == "down" {
target = index + 1
}
if target < 0 || target >= len(items) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[target].createdAt, items[index].id, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[index].createdAt, items[target].id, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleApplyRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
item, err := a.ruleByID(r.Context(), user.ID, chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "rule not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
affected, err := a.applyRuleToExistingMessages(r.Context(), user.ID, item.MailboxID, item)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to apply rule")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affected": affected})
}
func (a *App) ruleByID(ctx context.Context, userID, id string) (MailRule, error) {
return scanRule(a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=? AND user_id=?`, id, userID))
}
func (a *App) handleListBlockedSenders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC`, user.ID)
@@ -621,9 +837,10 @@ func (a *App) handleDeleteBlockedSender(w http.ResponseWriter, r *http.Request)
func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
rangeDays := mailStatsRangeDays(r.URL.Query().Get("days"))
args := []any{user.ID}
where := `mb.user_id=?`
if mailboxID != "" {
if mailboxID != "" && !isAllMailboxID(mailboxID) {
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
@@ -631,18 +848,55 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
where += ` AND mb.id=?`
args = append(args, mailboxID)
}
stats := MailStats{ByFolder: []MailStatsFolderCount{}}
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb LEFT JOIN messages m ON m.mailbox_id=mb.id WHERE `+where, args...)
if err := row.Scan(&stats.TotalMessages, &stats.UnreadMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
now := a.now().UTC()
stats := MailStats{
ByFolder: []MailStatsFolderCount{},
Trend: emptyMailStatsTrend(now, rangeDays),
Distribution: []MailStatsDistributionItem{},
TopContacts: []MailStatsContact{},
}
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),
COALESCE(SUM(CASE WHEN f.role NOT IN ('sent','drafts') THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='drafts' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb
LEFT JOIN messages m ON m.mailbox_id=mb.id
LEFT JOIN folders f ON f.id=m.folder_id
WHERE `+where, args...)
if err := row.Scan(&stats.TotalMessages, &stats.TotalIncoming, &stats.TotalOutgoing, &stats.UnreadMessages, &stats.DraftMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load stats")
return
}
if stats.TotalMessages > 0 {
stats.AverageMessageBytes = stats.StorageBytes / stats.TotalMessages
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return
}
if mailboxID != "" {
var attachmentMessageCount int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id) FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id WHERE `+where+` AND m.has_attachments=1`, args...).Scan(&attachmentMessageCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment message stats")
return
}
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
todayArgs := append(append([]any{}, args...), todayStart)
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+` AND f.role='sent' AND m.sent_at>=?`, todayArgs...).Scan(&stats.TodayOutgoing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load today stats")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(sq.id)
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id
WHERE `+where+` AND sq.status='failed'`, args...).Scan(&stats.FailedSends); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return
}
if mailboxID != "" && !isAllMailboxID(mailboxID) {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
@@ -652,6 +906,16 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
@@ -669,9 +933,165 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
}
stats.ByFolder = append(stats.ByFolder, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folder stats")
return
}
stats.Distribution = mailStatsDistribution(stats.ByFolder, attachmentMessageCount, stats.StarredMessages)
if err := a.loadMailStatsTrend(r.Context(), where, args, rangeDays, stats.Trend); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load trend stats")
return
}
topContacts, err := a.mailStatsTopContacts(r.Context(), where, args)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load contact stats")
return
}
stats.TopContacts = topContacts
respondJSON(w, http.StatusOK, stats)
}
func mailStatsRangeDays(raw string) int {
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days <= 0 {
return 30
}
switch days {
case 7, 30, 90, 365:
return days
default:
if days < 7 {
return 7
}
if days > 365 {
return 365
}
return days
}
}
func emptyMailStatsTrend(now time.Time, days int) []MailStatsTrendPoint {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
points := make([]MailStatsTrendPoint, 0, days)
for i := days - 1; i >= 0; i-- {
points = append(points, MailStatsTrendPoint{Date: today.AddDate(0, 0, -i).Format("2006-01-02")})
}
return points
}
func mailStatsDistribution(rows []MailStatsFolderCount, attachmentMessages, starred int64) []MailStatsDistributionItem {
roles := map[string]int64{}
for _, row := range rows {
roles[strings.ToLower(row.Role)] += row.Count
}
return []MailStatsDistributionItem{
{Key: "inbox", Label: "收件箱", Count: roles["inbox"]},
{Key: "archive", Label: "已归档", Count: roles["archive"]},
{Key: "spam", Label: "垃圾邮件", Count: roles["spam"]},
{Key: "trash", Label: "已删除", Count: roles["trash"]},
{Key: "attachments", Label: "有附件", Count: attachmentMessages},
{Key: "starred", Label: "已加旗标", Count: starred},
}
}
func (a *App) loadMailStatsTrend(ctx context.Context, where string, args []any, days int, trend []MailStatsTrendPoint) error {
start := ""
if len(trend) > 0 {
start = trend[0].Date + "T00:00:00Z"
}
queryArgs := append(append([]any{}, args...), start)
rows, err := a.db.QueryContext(ctx, `SELECT substr(CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END, 1, 10),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 0 ELSE 1 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+` AND f.role<>'drafts' AND (CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END)>=?
GROUP BY 1`, queryArgs...)
if err != nil {
return err
}
defer rows.Close()
byDate := map[string]*MailStatsTrendPoint{}
for i := range trend {
byDate[trend[i].Date] = &trend[i]
}
for rows.Next() {
var date string
var incoming, outgoing int64
if err := rows.Scan(&date, &incoming, &outgoing); err != nil {
return err
}
if point := byDate[date]; point != nil {
point.Incoming = incoming
point.Outgoing = outgoing
}
}
return rows.Err()
}
func (a *App) mailStatsTopContacts(ctx context.Context, where string, args []any) ([]MailStatsContact, error) {
rows, err := a.db.QueryContext(ctx, `SELECT f.role,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+`
ORDER BY m.received_at DESC LIMIT 2000`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int64{}
for rows.Next() {
var role, from, toJSON, ccJSON, bccJSON string
if err := rows.Scan(&role, &from, &toJSON, &ccJSON, &bccJSON); err != nil {
return nil, err
}
if strings.EqualFold(role, "sent") {
for _, email := range append(append(mailStatsEmailList(toJSON), mailStatsEmailList(ccJSON)...), mailStatsEmailList(bccJSON)...) {
if email != "" {
counts[email]++
}
}
continue
}
if email := normalizeEmail(from); email != "" && strings.Contains(email, "@") {
counts[email]++
}
}
if err := rows.Err(); err != nil {
return nil, err
}
items := make([]MailStatsContact, 0, len(counts))
for email, count := range counts {
items = append(items, MailStatsContact{Email: email, Count: count})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Count == items[j].Count {
return items[i].Email < items[j].Email
}
return items[i].Count > items[j].Count
})
if len(items) > 10 {
items = items[:10]
}
return items, nil
}
func mailStatsEmailList(raw string) []string {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) {
var req struct {
MailboxID string `json:"mailboxId"`
@@ -889,7 +1309,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at DESC`, userID, mailboxID)
if err != nil {
return
}
@@ -1059,10 +1479,10 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
}
out := []MailRuleAction{}
for _, item := range items {
typ := strings.TrimSpace(item.Type)
typ := strings.ToLower(strings.TrimSpace(item.Type))
value := strings.TrimSpace(item.Value)
labelID := strings.TrimSpace(item.LabelID)
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" {
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" && typ != "forward" {
continue
}
if typ == "label" && value == "" && labelID == "" {
@@ -1071,11 +1491,32 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
if typ == "move" && value == "" {
continue
}
if typ == "forward" && value == "" {
continue
}
out = append(out, MailRuleAction{Type: typ, Value: value, LabelID: labelID})
}
return out
}
func (a *App) cleanRuleActions(ctx context.Context, userID string, actions []MailRuleAction) ([]MailRuleAction, error) {
out := make([]MailRuleAction, 0, len(actions))
for _, action := range actions {
if action.Type == "forward" {
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
if err != nil {
return nil, err
}
if len(targets) == 0 {
continue
}
action.Value = strings.Join(targets, ", ")
}
out = append(out, action)
}
return out, nil
}
func legacyConditionValue(items []MailRuleCondition, field string) string {
for _, item := range items {
if item.Field == field && item.Operator == "contains" {
@@ -1315,6 +1756,10 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
if err := a.applyRuleLabel(ctx, mailboxID, messageID, action); err != nil {
return err
}
case "forward":
if err := a.processRuleForwarding(ctx, messageID, mailboxID, action); err != nil {
return err
}
}
}
return nil
@@ -1370,13 +1815,23 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
if err != nil {
return 0, err
}
messages := []ruleMessage{}
messageIDs := []string{}
var count int64
for rows.Next() {
var messageID string
if err := rows.Scan(&messageID); err != nil {
rows.Close()
return count, err
}
messageIDs = append(messageIDs, messageID)
}
if err := rows.Err(); err != nil {
rows.Close()
return count, err
}
rows.Close()
messages := []ruleMessage{}
for _, messageID := range messageIDs {
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
continue
@@ -1386,10 +1841,6 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
}
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
return count, err
}
rows.Close()
for _, msg := range messages {
if err := a.applyRuleActions(ctx, msg.MailboxID, msg.ID, rule.Actions); err != nil {
return count, err
+144 -9
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()
@@ -31,14 +32,25 @@ func (a *App) Router() http.Handler {
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/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)
@@ -53,6 +65,9 @@ func (a *App) Router() http.Handler {
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)
@@ -71,6 +86,10 @@ func (a *App) Router() http.Handler {
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.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
@@ -83,6 +102,8 @@ func (a *App) Router() http.Handler {
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)
@@ -107,6 +128,7 @@ func (a *App) Router() http.Handler {
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/bulk-move", a.handleBulkMove)
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)
@@ -115,6 +137,8 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
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)
@@ -147,6 +171,9 @@ func (a *App) Router() http.Handler {
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(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
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)
@@ -158,14 +185,45 @@ func (a *App) Router() http.Handler {
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 {
@@ -187,27 +245,55 @@ func (a *App) requireAuth(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, scopes, err := a.authenticateAPIToken(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "api token required")
return
}
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")
@@ -218,13 +304,59 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
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)
email = normalizeEmail(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 email=? LIMIT 1`, email)
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
}
@@ -232,6 +364,7 @@ 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
@@ -240,11 +373,12 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
}
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
}
@@ -252,6 +386,7 @@ 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
+27 -7
View File
@@ -25,8 +25,12 @@ const (
sendAuditRetry = "retry"
sendAuditCanceled = "canceled"
sendSourceWebmail = "webmail"
sendSourceSubmission = "submission"
sendSourceWebmail = "webmail"
sendSourceSubmission = "submission"
sendSourceOpenAPI = "open_api"
sendSourceForwarding = "forwarding"
sendSourceRuleForwarding = "rule_forwarding"
sendSourceForwardingVerification = "forwarding_verification"
sendQueueStaleAfter = 15 * time.Minute
sendQueueConcurrency = 4
@@ -63,7 +67,7 @@ type sendQueueItem struct {
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
@@ -145,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -364,10 +368,26 @@ func (a *App) recordSendAudit(ctx context.Context, event, status string, in send
if source == "" {
source = "unknown"
}
_, err := a.db.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(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano))
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)
}
}
@@ -376,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
+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
}
+145 -71
View File
@@ -40,6 +40,12 @@ type SystemSettings struct {
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type systemSettingsUpdate struct {
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotToken string `json:"telegramBotToken"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type PublicSettings struct {
@@ -100,15 +112,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, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
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()
@@ -126,12 +139,14 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
var req systemSettingsUpdate
if err := decodeJSON(r, &req); err != nil {
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"))
@@ -203,12 +218,36 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
next.TelegramMailEnabled = req.TelegramMailEnabled
if strings.TrimSpace(req.TelegramBotToken) != "" {
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
}
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
if next.TelegramMailEnabled {
if next.TelegramBotToken == "" {
badRequest(w, errors.New("Telegram Bot Token 未设置"))
return
}
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
return
}
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
return
}
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
previous := a.config()
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.cfg = next
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -218,7 +257,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
@@ -251,7 +290,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 {
@@ -285,41 +324,49 @@ 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"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
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) != "",
TelegramMailEnabled: cfg.TelegramMailEnabled,
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
}
}
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
@@ -332,79 +379,95 @@ 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":
a.cfg.ExternalIMAPEnabled = value == "true"
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
cfg.ExternalIMAPOutlookClientSecret = value
case "telegramMailEnabled":
cfg.TelegramMailEnabled = value == "true"
case "telegramBotToken":
cfg.TelegramBotToken = value
case "telegramPrivateChatId":
cfg.TelegramPrivateChatID = value
case "telegramBodyMode":
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
case "telegramMailboxIds":
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
case "telegramIncludeUnregistered":
cfg.TelegramIncludeUnregistered = value == "true"
}
}
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 {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
@@ -436,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
"telegramBotToken": cfg.TelegramBotToken,
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
@@ -449,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
return err
}
}
if clearPendingTelegram {
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
return err
}
}
return tx.Commit()
}
+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
}
+4 -4
View File
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
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)
@@ -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)
}
}
+992
View File
@@ -0,0 +1,992 @@
package app
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
nethtml "golang.org/x/net/html"
)
const (
telegramMailMaxAttempts = 8
telegramMessageBudget = 3800
telegramPairingTTL = 10 * time.Minute
)
type telegramPairing struct {
TokenFingerprint string
ExpiresAt time.Time
}
type telegramMailPayload struct {
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
Body string `json:"body"`
BodyMode string `json:"bodyMode"`
OTP string `json:"otp,omitempty"`
AttachmentNames []string `json:"attachmentNames,omitempty"`
AttachmentCount int `json:"attachmentCount,omitempty"`
}
type telegramCredentialsRequest struct {
BotToken string `json:"botToken"`
ChatID string `json:"chatId"`
PairingCode string `json:"pairingCode"`
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *struct {
Text string `json:"text"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"chat"`
} `json:"message"`
}
type telegramAPIError struct {
HTTPStatus int
ErrorCode int
Description string
RetryAfter time.Duration
}
func (e *telegramAPIError) Error() string {
description := strings.TrimSpace(e.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", e.HTTPStatus)
}
return "Telegram 发送失败: " + description
}
type telegramSentMessage struct {
MessageID int64 `json:"message_id"`
}
type telegramFormattedMessage struct {
HTML string
PlainText string
OTP string
}
func normalizeTelegramBodyMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "full") {
return "full"
}
return "summary"
}
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
var bot struct {
Username string `json:"username"`
}
if err := a.callTelegram(r.Context(), token, "getMe", map[string]any{}, &bot); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
if strings.TrimSpace(bot.Username) == "" {
respondError(w, http.StatusBadGateway, "Telegram 机器人没有可用的用户名")
return
}
code, err := newTelegramPairingCode()
if err != nil {
respondError(w, http.StatusInternalServerError, "无法生成 Telegram 绑定码")
return
}
expiresAt := a.now().UTC().Add(telegramPairingTTL)
a.telegramPairMu.Lock()
for value, pairing := range a.telegramPairs {
if !pairing.ExpiresAt.After(a.now().UTC()) {
delete(a.telegramPairs, value)
}
}
a.telegramPairs[code] = telegramPairing{TokenFingerprint: telegramTokenFingerprint(token), ExpiresAt: expiresAt}
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{
"code": code,
"botUsername": bot.Username,
"deepLink": "https://t.me/" + url.PathEscape(bot.Username) + "?start=" + url.QueryEscape(code),
"expiresAt": expiresAt.Format(time.RFC3339Nano),
})
}
func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
if token == "" || code == "" {
badRequest(w, errors.New("请先生成 Telegram 一次性绑定码"))
return
}
a.telegramPairMu.Lock()
pairing, ok := a.telegramPairs[code]
a.telegramPairMu.Unlock()
if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
badRequest(w, errors.New("Telegram 绑定码无效或已过期,请重新生成"))
return
}
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token, code)
if err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
a.telegramPairMu.Lock()
delete(a.telegramPairs, code)
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName})
}
func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token, chatID := a.telegramCredentials(req)
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
if !validTelegramPrivateChatID(chatID) {
badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID"))
return
}
now := a.now().Local().Format("2006-01-02 15:04:05 MST")
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html.EscapeString(now)
if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) {
cfg := a.config()
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(cfg.TelegramBotToken)
}
chatID := strings.TrimSpace(req.ChatID)
if chatID == "" {
chatID = strings.TrimSpace(cfg.TelegramPrivateChatID)
}
return token, chatID
}
func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCode string) (string, string, error) {
var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100,
"timeout": 0,
"allowed_updates": []string{"message"},
}, &updates); err != nil {
return "", "", err
}
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
continue
}
text := strings.TrimSpace(message.Text)
if text != pairingCode && text != "/start "+pairingCode {
continue
}
name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
if name == "" && message.Chat.Username != "" {
name = "@" + message.Chat.Username
}
return strconv.FormatInt(message.Chat.ID, 10), name, nil
}
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(raw)), nil
}
func telegramTokenFingerprint(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func telegramMailboxAllowed(cfg Config, mailboxID string) bool {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" {
return cfg.TelegramIncludeUnregistered
}
for _, id := range cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")) {
if id == mailboxID {
return true
}
}
return false
}
func (a *App) activeTelegramMailboxIDs(ctx context.Context, values []string) []string {
ids := cleanIDList(values)
active := make([]string, 0, len(ids))
for _, id := range ids {
var exists int
if err := a.db.QueryRowContext(ctx, `SELECT 1 FROM mailboxes WHERE id=? AND status='active'`, id).Scan(&exists); err == nil && exists == 1 {
active = append(active, id)
}
}
return active
}
func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) || !telegramMailboxAllowed(cfg, msg.MailboxID) {
return
}
recipient := normalizeEmail(msg.RecipientAddr)
if recipient == "" && len(msg.To) > 0 {
recipient = normalizeEmail(msg.To[0])
}
body := telegramMessageBody(msg)
otp := detectTelegramOTP(msg.Subject, body)
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
limit := 800
if mode == "full" {
limit = 2600
}
body, truncated := truncateRunes(body, limit)
if truncated {
body += "..."
}
if body == "" {
body = normalizeTelegramText(msg.Snippet)
}
from, _ := truncateRunes(strings.TrimSpace(msg.From), 254)
fromName, _ := truncateRunes(strings.TrimSpace(msg.FromName), 160)
subject, _ := truncateRunes(strings.TrimSpace(msg.Subject), 240)
names := make([]string, 0, min(len(attachments), 5))
for _, attachment := range attachments {
name := sanitizeTelegramAttachmentName(attachment.Filename)
if name != "" {
names = append(names, name)
}
if len(names) >= 5 {
break
}
}
payload := telegramMailPayload{
From: from,
FromName: fromName,
Recipient: recipient,
Subject: subject,
ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
Body: body,
BodyMode: mode,
OTP: otp,
AttachmentNames: names,
AttachmentCount: len(attachments),
}
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil {
a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err)
}
}
func telegramMessageBody(msg storedMessage) string {
text := strings.TrimSpace(msg.BodyText)
text, _ = truncateRunes(text, 128*1024)
if text != "" && looksLikeHTMLDocument(text) {
text = telegramHTMLToText(text)
}
if strings.TrimSpace(text) == "" {
text = telegramHTMLToText(msg.BodyHTML)
}
return stripTelegramQuotedContent(normalizeTelegramText(text))
}
func looksLikeHTMLDocument(value string) bool {
value, _ = truncateRunes(value, 128*1024)
value = strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(value, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
return true
}
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
return len(matches) >= 3
}
func telegramHTMLToText(value string) string {
value = strings.ToValidUTF8(value, "")
value, _ = truncateRunes(value, 128*1024)
doc, err := nethtml.Parse(strings.NewReader(value))
if err != nil {
return stripTags(value)
}
var out strings.Builder
var walk func(*nethtml.Node, bool)
walk = func(node *nethtml.Node, skipped bool) {
if node.Type == nethtml.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "head", "noscript", "svg":
skipped = true
case "br":
if !skipped {
out.WriteByte('\n')
}
}
}
if node.Type == nethtml.TextNode && !skipped {
out.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, skipped)
}
if node.Type == nethtml.ElementNode && !skipped {
switch strings.ToLower(node.Data) {
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
out.WriteByte('\n')
}
}
}
walk(doc, false)
return normalizeTelegramText(out.String())
}
func normalizeTelegramText(value string) string {
value = strings.ReplaceAll(strings.ToValidUTF8(value, ""), "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
lines := strings.Split(value, "\n")
out := make([]string, 0, len(lines))
empty := false
for _, line := range lines {
line = strings.TrimSpace(strings.Map(func(r rune) rune {
if r == '\t' {
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, line))
line = strings.Join(strings.Fields(line), " ")
if line == "" {
if !empty && len(out) > 0 {
out = append(out, "")
}
empty = true
continue
}
empty = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[:]|from[:].+|_{5,})$`)
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
func stripTelegramQuotedContent(value string) string {
lines := strings.Split(value, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
lines = lines[:i]
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func sanitizeTelegramAttachmentName(value string) string {
value = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.ToValidUTF8(value, "")))
value = strings.Join(strings.Fields(value), " ")
value, truncated := truncateRunes(value, 100)
if truncated {
value += "..."
}
return value
}
var (
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
telegramEmailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
)
func detectTelegramOTP(subject, body string) string {
text := normalizeTelegramText(strings.TrimSpace(subject) + "\n" + body)
keywords := telegramOTPKeywordRe.FindAllStringIndex(text, -1)
if len(keywords) == 0 {
return ""
}
type candidateScore struct {
value string
score int
count int
}
scores := map[string]candidateScore{}
subjectEnd := len(strings.TrimSpace(subject))
excludedRanges := append(telegramEmailRe.FindAllStringIndex(text, -1), telegramURLRe.FindAllStringIndex(text, -1)...)
for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) {
if telegramRangeOverlaps(match, excludedRanges) {
continue
}
if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) {
continue
}
if match[1] < len(text) && isTelegramOTPAlphaNumeric(rune(text[match[1]])) {
continue
}
value := strings.ToUpper(text[match[0]:match[1]])
hasDigit := false
for _, r := range value {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
continue
}
if isTelegramOTPNonCode(value) {
continue
}
best := 0
for _, keyword := range keywords {
distance := match[0] - keyword[1]
if distance < 0 {
distance = keyword[0] - match[1]
}
if distance < 0 {
distance = 0
}
score := 0
switch {
case distance <= 16:
score = 100
case distance <= 48:
score = 80
case distance <= 100:
score = 55
}
if match[0] <= subjectEnd {
score += 15
}
if score > best {
best = score
}
}
if best == 0 {
continue
}
current := scores[value]
current.value = value
current.count++
if best > current.score {
current.score = best
}
scores[value] = current
}
items := make([]candidateScore, 0, len(scores))
for _, item := range scores {
item.score += min(item.count-1, 2) * 5
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
if len(items) == 0 || items[0].score < 55 {
return ""
}
if len(items) > 1 && items[1].score >= items[0].score-25 {
return ""
}
return items[0].value
}
func telegramRangeOverlaps(candidate []int, ranges [][]int) bool {
for _, item := range ranges {
if len(item) == 2 && candidate[0] < item[1] && candidate[1] > item[0] {
return true
}
}
return false
}
func isTelegramOTPNonCode(value string) bool {
if len(value) == 4 {
if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 {
return true
}
}
if len(value) == 8 {
if _, err := time.Parse("20060102", value); err == nil {
return true
}
}
return false
}
func isTelegramOTPAlphaNumeric(r rune) bool {
return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r))
}
func (a *App) shouldNotifyTelegramMessage(ctx context.Context, messageID string) bool {
var folder string
if err := a.db.QueryRowContext(ctx, `SELECT lower(COALESCE(NULLIF(f.role,''),f.name,'')) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, messageID).Scan(&folder); err != nil {
return false
}
switch strings.TrimSpace(folder) {
case "spam", "junk", "trash", "deleted":
return false
default:
return true
}
}
func (a *App) telegramMailWorker(ctx context.Context) {
a.log.Info("Telegram mail notification worker started")
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.log.Warn("Telegram mail notification worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("Telegram mail notification worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
_, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil
}
nowText := a.now().UTC().Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
if err != nil {
return err
}
type queueItem struct {
id string
payload telegramMailPayload
attempt int
invalid bool
}
items := []queueItem{}
for rows.Next() {
var item queueItem
var raw string
if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil {
rows.Close()
return err
}
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
item.invalid = true
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
if item.invalid {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
return err
}
continue
}
now := a.now().UTC()
leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
continue
}
formatted := formatTelegramMailMessage(item.payload)
telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
now = a.now().UTC()
if err != nil {
attempts := item.attempt + 1
delay := sendRetryDelay(attempts)
var apiErr *telegramAPIError
if errors.As(err, &apiErr) {
if apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
code := apiErr.ErrorCode
if code == 0 {
code = apiErr.HTTPStatus
}
if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
attempts = telegramMailMaxAttempts
}
}
next := now.Add(delay)
if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
return updateErr
}
continue
}
stamp := now.Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=?,lease_until='',telegram_message_id=?,payload_json='{}' WHERE id=? AND delivered_at IS NULL`, stamp, stamp, telegramMessageID, item.id); err != nil {
return err
}
}
return nil
}
func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
subject, _ = truncateRunes(subject, 180)
from, _ = truncateRunes(from, 220)
recipient, _ := truncateRunes(strings.TrimSpace(payload.Recipient), 160)
lines := []string{
"📩 <b>新邮件通知</b>",
"",
"<b>主题:</b>" + escapeTelegramWithinBudget(subject, 420),
"<b>发件人:</b>" + escapeTelegramWithinBudget(from, 500),
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
"<b>收件时间:</b>" + html.EscapeString(timeText),
}
if payload.OTP != "" {
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
}
if len(payload.AttachmentNames) > 0 {
names := make([]string, 0, len(payload.AttachmentNames))
for _, name := range payload.AttachmentNames {
names = append(names, escapeTelegramWithinBudget(name, 180))
}
attachmentText := strings.Join(names, "、")
if payload.AttachmentCount > len(payload.AttachmentNames) {
attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames))
}
lines = append(lines, "", fmt.Sprintf("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
}
body := strings.TrimSpace(payload.Body)
if body != "" {
label := "正文摘要"
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
label = "邮件正文"
}
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
lines = []string{prefix + body + suffix}
}
htmlText := strings.Join(lines, "\n")
plain := formatTelegramMailPlainText(payload)
return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP}
}
func formatTelegramBodyHTML(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
appendEscaped := func(text string) bool {
for _, r := range text {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
return false
}
out.WriteString(escaped)
used += length
}
return true
}
last := 0
for _, match := range telegramURLRe.FindAllStringIndex(value, -1) {
if !appendEscaped(value[last:match[0]]) {
truncated = true
break
}
rawURL, trailing := trimTelegramURL(value[match[0]:match[1]])
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
if !appendEscaped(value[match[0]:match[1]]) {
truncated = true
break
}
last = match[1]
continue
}
display := rawURL
if utf8.RuneCountInString(display) > 72 {
display = "🔗 " + parsed.Hostname() + " 链接"
}
anchor := `<a href="` + html.EscapeString(rawURL) + `">` + html.EscapeString(display) + `</a>`
length := utf8.RuneCountInString(anchor)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(anchor)
used += length
if !appendEscaped(trailing) {
truncated = true
break
}
last = match[1]
}
if !truncated && last < len(value) && !appendEscaped(value[last:]) {
truncated = true
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func trimTelegramURL(value string) (string, string) {
trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】")
return trimmed, value[len(trimmed):]
}
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
_, err := a.sendTelegramPayload(ctx, token, map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": true,
})
return err
}
func (a *App) deliverTelegramMailMessage(ctx context.Context, token, chatID string, message telegramFormattedMessage) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"text": message.HTML,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
payload["reply_markup"] = markup
}
result, err := a.sendTelegramPayload(ctx, token, payload)
if err == nil {
return result.MessageID, nil
}
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != http.StatusBadRequest {
return 0, err
}
fallback := map[string]any{
"chat_id": chatID,
"text": message.PlainText,
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
fallback["reply_markup"] = markup
}
result, err = a.sendTelegramPayload(ctx, token, fallback)
if err != nil {
return 0, err
}
return result.MessageID, nil
}
func (a *App) sendTelegramPayload(ctx context.Context, token string, payload map[string]any) (telegramSentMessage, error) {
var result telegramSentMessage
err := a.callTelegram(ctx, token, "sendMessage", payload, &result)
return result, err
}
func telegramCopyMarkup(otp string) map[string]any {
otp = strings.TrimSpace(otp)
if otp == "" || utf8.RuneCountInString(otp) > 256 {
return nil
}
return map[string]any{"inline_keyboard": [][]map[string]any{{{
"text": "复制验证码",
"copy_text": map[string]string{"text": otp},
}}}}
}
func escapeTelegramWithinBudget(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
for _, r := range value {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(escaped)
used += length
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func formatTelegramMailPlainText(payload telegramMailPayload) string {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
parts := []string{"新邮件通知", "", "主题:" + subject, "发件人:" + from, "收件邮箱:" + payload.Recipient, "收件时间:" + timeText}
if payload.OTP != "" {
parts = append(parts, "", "验证码", payload.OTP)
}
if payload.AttachmentCount > 0 {
parts = append(parts, "", fmt.Sprintf("附件:%d 个", payload.AttachmentCount))
}
if body := strings.TrimSpace(payload.Body); body != "" {
parts = append(parts, "", "正文摘要", body)
}
text := normalizeTelegramText(strings.Join(parts, "\n"))
text, truncated := truncateRunes(text, telegramMessageBudget-3)
if truncated {
text += "..."
}
return text
}
func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error {
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, "/\\\r\n") {
return errors.New("Telegram Bot Token 无效")
}
base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/")
endpoint := base + "/bot" + url.PathEscape(token) + "/" + method
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0")
client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
return errors.New("Telegram 请求失败,请检查网络连接和机器人配置")
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
var apiResponse telegramAPIResponse
if err := json.Unmarshal(raw, &apiResponse); err != nil {
return fmt.Errorf("Telegram 返回了无效响应(HTTP %d", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK {
description := strings.TrimSpace(apiResponse.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return &telegramAPIError{HTTPStatus: resp.StatusCode, ErrorCode: apiResponse.ErrorCode, Description: description, RetryAfter: time.Duration(apiResponse.Parameters.RetryAfter) * time.Second}
}
if result != nil && len(apiResponse.Result) > 0 {
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
return err
}
}
return nil
}
+420
View File
@@ -0,0 +1,420 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
type sentMessage struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
var sent []sentMessage
var pairingCode atomic.Value
pairingCode.Store("")
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/bottest-token/getMe":
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
case "/bottest-token/getUpdates":
code, _ := pairingCode.Load().(string)
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
case "/bottest-token/sendMessage":
var message sentMessage
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
t.Fatalf("decode Telegram message: %v", err)
}
sent = append(sent, message)
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
default:
http.NotFound(w, r)
}
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
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("login code=%d body=%v", code, login)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
payload["telegramBotToken"] = "test-token"
payload["telegramPrivateChatId"] = "123456789"
payload["telegramBodyMode"] = "full"
var adminMailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
t.Fatal(err)
}
payload["telegramMailboxIds"] = []string{adminMailboxID}
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
}
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
t.Fatalf("unexpected Telegram settings: %+v", settings)
}
if a.config().TelegramBotToken != "test-token" {
t.Fatal("Telegram token was not persisted in runtime config")
}
var pairing struct {
Code string `json:"code"`
DeepLink string `json:"deepLink"`
}
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
}
pairingCode.Store(pairing.Code)
var discovered map[string]string
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
t.Fatalf("discover chat code=%d response=%v", code, discovered)
}
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
t.Fatalf("unexpected discovered chat: %v", discovered)
}
var testResult map[string]any
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
}
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
t.Fatalf("unexpected Telegram test message: %+v", sent)
}
sent = nil
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
MailboxID: adminMailboxID,
RecipientAddr: "admin@example.com",
Subject: "账单 <已生成>",
From: "billing@example.net",
FromName: "Billing & Support",
ReceivedAt: receivedAt,
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatalf("process Telegram mail queue: %v", err)
}
if len(sent) != 1 {
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
}
text := sent[0].Text
for _, expected := range []string{"新邮件通知", "Billing &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "&lt;VIP&gt; &amp; 续费信息"} {
if !strings.Contains(text, expected) {
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
}
}
if sent[0].ReplyMarkup == nil {
t.Fatal("Telegram OTP copy button was not included")
}
var delivered, storedPayload string
var telegramMessageID int64
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
}
if storedPayload != "{}" || telegramMessageID != 8 {
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
}
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
disablePayload := systemSettingsPayload(settings)
disablePayload["telegramMailEnabled"] = false
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
t.Fatalf("disable Telegram settings code=%d", code)
}
var pending int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
}
}
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
a := newTestApp(t)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
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("login code=%d", code)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
var body map[string]any
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
}
}
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
serverURL := server.URL
server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = serverURL
const token = "123456:secret-token-value"
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
if err == nil {
t.Fatal("expected Telegram network request to fail")
}
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
t.Fatalf("Telegram error exposed Bot Token: %v", err)
}
}
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
cleaned := stripTelegramQuotedContent(body)
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
t.Fatalf("unexpected OTP %q", otp)
}
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: strings.Repeat("R&D <team@example.com> ", 30),
Recipient: "admin@example.com",
Subject: strings.Repeat("超长主题 & <test> ", 50),
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
Body: strings.Repeat("正文内容 & <重要> ", 1000),
BodyMode: "full",
OTP: "846981",
AttachmentNames: []string{
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
},
AttachmentCount: 12,
})
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
t.Fatalf("Telegram HTML exceeds budget: %d", got)
}
if !strings.Contains(message.HTML, "&amp;") || !strings.Contains(message.HTML, "&lt;") || !strings.Contains(message.HTML, "<code>846981</code>") {
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
}
if markup := telegramCopyMarkup(message.OTP); markup == nil {
t.Fatal("copy_text markup missing")
}
}
func TestTelegramIQiyiOTPDetection(t *testing.T) {
subject := "825534 是您的动态安全验证码"
body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved"
otp := detectTelegramOTP(subject, body)
if otp != "825534" {
t.Fatalf("iQiyi OTP not detected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp})
if !strings.Contains(message.HTML, "<code>825534</code>") || telegramCopyMarkup(message.OTP) == nil {
t.Fatalf("iQiyi OTP section or copy button missing: %+v", message)
}
}
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
body := `---------- Forwarded message ---------
Date: 2026年8月6日周四 17:59
Subject: 登录验证码 (https://www.gate.com)
Gate 检测到您的账号正试图从此 IP 获得登录验证码
IP: 87.83.105.229
如为您本人登录请输入如下验证码完成操作
311665
如非本人操作请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
}
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
})
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
t.Fatalf("normal URL was not linkified: %s", message.HTML)
}
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
}
if strings.Contains(message.HTML, "&lt;a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
}
markup := telegramCopyMarkup(message.OTP)
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
t.Fatalf("copy OTP button missing: %#v", markup)
}
}
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
text := telegramMessageBody(storedMessage{BodyText: pseudo})
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
t.Fatalf("pseudo HTML was not cleaned: %q", text)
}
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
if err != nil {
t.Fatal(err)
}
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
tests := []struct {
name string
status int
response string
retryAfter time.Duration
}{
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.response))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
t.Fatalf("unexpected Telegram error: %#v", err)
}
})
}
}
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
cfg.TelegramMailboxIDs = mailboxID
})
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
}
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if msg.RecipientAddr != "admin@lanqin.local" {
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
return
}
if _, exists := payload["parse_mode"]; exists {
t.Fatal("plain-text fallback still included parse_mode")
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
if err != nil || messageID != 99 || calls.Load() != 2 {
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
}
}
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
})
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
t.Fatal(err)
}
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatal(err)
}
var badAttempts int
var delivered string
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
t.Fatal(err)
}
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
}
}
+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,
+7 -3
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")
}
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
if ip := normalizeRemoteIP(remoteIP); ip != "" {
form.Set("remoteip", ip)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
verifyURL := strings.TrimSpace(a.turnstileURL)
if verifyURL == "" {
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
+109 -8
View File
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
func newTwoFactorRecoveryCode() (string, error) {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "", err
}
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
if len(value) > 10 {
value = value[:10]
}
return value[:5] + "-" + value[5:], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return code
}
func totpProvisioningURI(issuer, account, secret string) string {
issuer = strings.TrimSpace(issuer)
account = strings.TrimSpace(account)
@@ -121,12 +140,64 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
}
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
return nil, err
}
now := a.now().UTC().Format(time.RFC3339Nano)
codes := make([]string, 0, 8)
for len(codes) < 8 {
code, err := newTwoFactorRecoveryCode()
if err != nil {
return nil, err
}
normalized := normalizeRecoveryCode(code)
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
newID("rcv"), userID, hashToken(normalized), now)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
continue
}
return nil, err
}
codes = append(codes, code)
}
return codes, nil
}
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
normalized := normalizeRecoveryCode(code)
if len(normalized) < 8 {
return false, nil
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var id string
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
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,6 +205,7 @@ 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
@@ -142,7 +214,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -172,12 +244,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
}
@@ -210,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
@@ -219,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
}
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
@@ -248,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
+75 -21
View File
@@ -3,18 +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"`
Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
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 {
@@ -23,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"`
@@ -44,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"`
}
@@ -209,15 +237,24 @@ type BlockedSender struct {
}
type MailStats struct {
TotalMessages int64 `json:"totalMessages"`
UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
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 {
@@ -228,6 +265,23 @@ type MailStatsFolderCount struct {
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"`
+86
View File
@@ -14,12 +14,19 @@ 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.AllowElements("html", "head", "body", "center", "font")
@@ -149,6 +156,70 @@ 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 cleanPrimaryEmail(value string) (string, error) {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") {
return "", errors.New("邮箱地址无效")
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", errors.New("邮箱地址无效")
}
if len([]rune(email)) > 254 {
return "", errors.New("邮箱地址不能超过 254 个字符")
}
return email, nil
}
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
@@ -215,6 +286,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
+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

@@ -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"
+29 -24
View File
@@ -1,12 +1,13 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, 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 {
@@ -27,13 +28,14 @@ import {
} from "@/components/ui/sidebar"
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "用户", icon: <Users />, 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: <Copy />, permissions: ["admin.aliases.view"] },
{ 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"] },
]
@@ -64,21 +66,24 @@ 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>
{isAdminRoute && visibleAdminSections.length > 0 && (
@@ -106,7 +111,7 @@ function ProtectedContent() {
<span className="truncate text-xs text-muted-foreground">{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>
@@ -125,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 ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
</div>
</div>
<Outlet />
@@ -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 }
+102 -24
View File
@@ -5,39 +5,51 @@
@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%;
--app-sidebar-width: 18rem;
--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 +83,72 @@
}
}
@layer components {
.mail-shell-grid {
display: grid;
grid-template-columns: var(--app-sidebar-width, 18rem) 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;
}
+70 -8
View File
@@ -47,14 +47,15 @@ export type PermissionKey =
| "admin.templates.update"
| "admin.templates.reset"
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
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; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: 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; 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 }
@@ -120,10 +121,42 @@ export type MailSignature = { id: string; mailboxId: string; name: string; conte
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"; 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 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; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: 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"
@@ -150,6 +183,25 @@ export type MaildirSyncHealth = {
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
@@ -181,10 +233,20 @@ export type SystemSettings = {
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
telegramMailEnabled: boolean
telegramBotTokenSet: boolean
telegramPrivateChatId: string
telegramBodyMode: "summary" | "full"
telegramMailboxIds: string[]
telegramIncludeUnregistered: boolean
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
export type TelegramPrivateChat = { chatId: string; displayName: string }
export type TelegramPairing = { code: string; botUsername: string; deepLink: string; expiresAt: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
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 }
export type TwoFactorEnableResponse = { user: User; recoveryCodes: string[] }
export type BulkMoveResult = { ok: boolean; moved: number; failed: number; message: string; items: { id: string; mailboxId?: string; ok: boolean; message: string }[] }
+120 -16
View File
@@ -1,9 +1,47 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } 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, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } 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,8 +97,12 @@ 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 }) }),
enableTwoFactor: (code: string) => request<TwoFactorEnableResponse>("/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 }) }),
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
@@ -53,14 +115,29 @@ 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) }),
@@ -77,8 +154,8 @@ export const api = {
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: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: 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"),
@@ -86,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: "user"; 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"),
@@ -114,10 +191,15 @@ export const api = {
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 }),
createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
@@ -141,28 +223,52 @@ export const api = {
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) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
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 || "" }) })
},
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, q, cursor })
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; messageIds?: 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)
params.messageIds?.forEach((id) => query.append("messageId", id))
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 }),
@@ -192,8 +298,6 @@ export const api = {
addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }),
removeLabel: (id: string, labelID: string) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels/${labelID}`, { method: "DELETE" }),
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
bulkMove: (ids: string[], folder: string) => request<BulkMoveResult>("/api/mail/messages/bulk-move", { method: "POST", body: JSON.stringify({ ids, folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
}
+7
View File
@@ -69,6 +69,7 @@ const exactTranslations: Record<string, Translation> = {
"暂无标签": { "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" },
@@ -179,6 +180,12 @@ const exactTranslations: Record<string, Translation> = {
"还没有可用邮箱": { "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" },
+10 -7
View File
@@ -6,14 +6,15 @@ 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 /> },
@@ -31,7 +32,9 @@ 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>
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -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,7 +49,7 @@ export function LoginPage() {
{!challengeToken ? (
<>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="space-y-2">
@@ -59,8 +59,8 @@ export function LoginPage() {
</>
) : (
<div className="space-y-2">
<Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label>
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-center text-lg tracking-[0.35em]" />
<Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label>
<Input id="twoFactorCode" name="twoFactorCode" autoComplete="one-time-code" minLength={6} required className="h-11 text-center text-lg" />
</div>
)}
{!challengeToken && turnstileRequired && (
@@ -85,4 +85,3 @@ export function LoginPage() {
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -26
View File
@@ -24,32 +24,28 @@ export function RegisterPage() {
const [domainId, setDomainId] = React.useState("")
const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || []
const selectedDomain = domains.find((d) => d.id === domainId)
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
if (domainId && !domains.some((domain) => domain.id === domainId)) setDomainId(domains[0]?.id || "")
}, [domainId, domains])
const register = useMutation({
mutationFn: (form: FormData) => {
const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
validatePasswordConfirm(password, confirmPassword)
if (domainId && selectedDomain) {
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
return api.register({
email: `${localPart}@${selectedDomain.name}`,
displayName: String(form.get("displayName") || ""),
password,
turnstileToken,
domainId,
localPart,
})
}
// Fallback: no domains available, use email directly
const displayName = String(form.get("displayName") || "").trim()
if (!displayName) throw new Error("请输入显示名称")
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
if (!domainId || !selectedDomain) throw new Error("请选择邮箱域名")
return api.register({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
email: `${localPart}@${selectedDomain.name}`,
displayName,
password,
turnstileToken,
domainId,
localPart,
})
},
onSuccess: async () => {
@@ -65,7 +61,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">
@@ -102,25 +98,23 @@ export function RegisterPage() {
</div>
</div>
) : (
<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" />
</div>
<div className="rounded-md bg-muted/40 px-4 py-3 text-center text-sm text-muted-foreground"></div>
)}
<div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label>
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
<p className="text-xs leading-5 text-muted-foreground"></p>
</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}>
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading || domains.length === 0}>
{register.isPending ? "注册中..." : "注册"}
{!register.isPending && <ArrowRight className="h-4 w-4" />}
</Button>
+2
View File
@@ -8,6 +8,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+54 -8
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
# =========================
# 对外访问地址
@@ -35,9 +51,14 @@ LANQIN_TLS_KEY_FILE=
# =========================
# 初始管理员
# =========================
# 第一次启动时会创建这个管理员账号。
# 第一次启动时会创建唯一管理员账号和真实管理员邮箱
# 网页前台和后台统一使用完整主登录邮箱 + 密码登录。
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
# 旧版本兼容字段,网页登录不再使用;新安装脚本会写入管理员邮箱前缀。
LANQIN_ADMIN_USERNAME=
# 生产环境必须改掉默认密码。
LANQIN_ADMIN_PASSWORD=ChangeMe123!
@@ -97,6 +118,16 @@ 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=
@@ -152,9 +183,24 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
# =========================
# Telegram 私聊邮件通知
# =========================
# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。
# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。
LANQIN_TELEGRAM_MAIL_ENABLED=false
# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。
LANQIN_TELEGRAM_BOT_TOKEN=
# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。
LANQIN_TELEGRAM_PRIVATE_CHAT_ID=
# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。
LANQIN_TELEGRAM_BODY_MODE=summary
# =========================
# 系统
# =========================
# 容器时区。
TZ=Asia/Shanghai
+91 -11
View File
@@ -1,4 +1,33 @@
# 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
sudo newszxcn-email reset-2fa
```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式和邮件服务器域名,自动检测邮箱地址域名,再选择默认 `admin` 前缀或自行创建管理员邮箱前缀,最后输入密码并选择 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。例如服务器域名 `mail.newszxcn.com`、选择默认前缀会创建 `admin@newszxcn.com`;自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置唯一管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。唯一管理员 2FA 锁死时可使用 `sudo newszxcn-email reset-2fa` 应急关闭。
## 最简单部署:单容器镜像版
@@ -7,7 +36,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_MAIL_DOMAIN / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
docker compose pull
docker compose up -d
```
@@ -55,12 +84,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
```
如果拉取时报:
@@ -119,13 +148,40 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
配置完成后点击“检测”。
## Telegram 通知
### 私聊新邮件通知
每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知:
1. 使用 `@BotFather` 创建机器人并填写 Bot Token。
2. 在 Telegram 中打开该机器人并发送 `/start`
3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。
4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。
5. 测试成功后开启“私聊新邮件通知”并保存。
Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。
手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED``LANQIN_TELEGRAM_BOT_TOKEN``LANQIN_TELEGRAM_PRIVATE_CHAT_ID``LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。
### GitHub Release 版本频道通知
版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets
```text
TELEGRAM_RELEASE_BOT_TOKEN
TELEGRAM_RELEASE_CHAT_ID
```
`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。
## 邮件服务边界
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
- 第三方客户端可使用 IMAP SSL `993`、POP3 SSL `995`、SMTP SSL `465` 或 Submission `587`
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- 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 索引。
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
@@ -135,6 +191,13 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
## 邮件客户端 TLS 证书
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
此时可在 `.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` 指向证书文件:
@@ -155,7 +218,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
```
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
@@ -174,6 +237,24 @@ LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false
```
如需把上游服务商或 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 配置错误。优先检查:
@@ -194,4 +275,3 @@ docker compose logs --tail=200 lanqin-email
- 建议在服务器或边缘网关配置 HTTPS。
- 云厂商通常默认封禁 25 端口,需要单独申请解封。
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
+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 node:20-bookworm-slim AS web-build
WORKDIR /src
@@ -16,7 +20,7 @@ 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=""
+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;
}
}
+5 -1
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 \
+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:
+5 -5
View File
@@ -1,6 +1,6 @@
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}
@@ -23,7 +23,7 @@ services:
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:
@@ -39,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
@@ -54,7 +54,7 @@ services:
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
@@ -69,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
Regular → Executable
+2 -32
View File
@@ -1,34 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
set -Eeuo pipefail
cd "$(dirname "$0")"
if ! command -v docker >/dev/null 2>&1; then
echo "未检测到 docker,请先安装 Docker Engine / Docker Compose。" >&2
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "未检测到 docker compose,请先安装 Docker Compose v2。" >&2
exit 1
fi
if [ ! -f .env ]; then
cp .env.example .env
echo "已生成 deploy/.env。请先编辑域名和管理员密码:"
echo " LANQIN_PUBLIC_HOSTNAME"
echo " LANQIN_PUBLIC_BASE_URL"
echo " LANQIN_ADMIN_EMAIL"
echo " LANQIN_ADMIN_PASSWORD"
echo
echo "编辑完成后再次执行:./install.sh"
exit 0
fi
echo "拉取镜像..."
docker compose pull
echo "启动服务..."
docker compose up -d
echo "完成。查看日志:docker compose logs -f lanqin-email"
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/install.sh" "$@"
+1
View File
@@ -3,6 +3,7 @@ server {
server_name _;
location /api/ {
client_max_body_size 50m;
proxy_pass http://api:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;

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