Compare commits

...

35 Commits

Author SHA1 Message Date
zxyszx 7cbae154bf chore: configure Gitea repository source
CI / Check web and api (push) Waiting to run
2026-08-14 06:08:44 +08:00
云逸 bd6edf353d Merge pull request #2 from zxyszx/codex/release-v1.2.43
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
release: v1.2.43
2026-08-14 05:29:58 +08:00
zxyszx 14ec1979b2 release: prepare v1.2.43 2026-08-14 05:27:03 +08:00
zxyszx 6fa98cec97 refactor: remove settings about section 2026-08-14 05:25:19 +08:00
zxyszx 71746bdfba feat: refine admin dashboard and mail experience 2026-08-14 05:19:03 +08:00
zxyszx f15a3d3222 release: prepare v1.2.42
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-13 18:42:27 +08:00
zxyszx 7fc91f2ed9 release: prepare v1.2.41
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-13 02:09:04 +08:00
zxyszx c482e5b896 release: prepare v1.2.40
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-13 01:39:33 +08:00
zxyszx 07e62e9c2d test: wait for manual backup completion 2026-08-13 01:38:59 +08:00
zxyszx 5797b472d9 release: prepare v1.2.39
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-13 01:34:19 +08:00
zxyszx 3caee11e38 release: prepare v1.2.38
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-13 01:24:12 +08:00
zxyszx 2dd3647923 release: prepare v1.2.37
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-13 01:11:44 +08:00
zxyszx ccb8ce01d9 release: prepare v1.2.36
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-13 00:55:10 +08:00
zxyszx 4e3b69608f release: prepare v1.2.35
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-12 18:41:49 +08:00
zxyszx 1dbc33b0dc release: prepare v1.2.34
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-12 17:58:48 +08:00
zxyszx 48a1d53133 release: prepare v1.2.33
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-12 16:59:20 +08:00
zxyszx ff5578368a fix: satisfy installer shellcheck
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-12 16:17:38 +08:00
zxyszx fc3a3462cf release: prepare v1.2.32 2026-08-12 16:15:19 +08:00
zxyszx 9a572e0100 release: prepare v1.2.31
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-12 06:01:56 +08:00
zxyszx c92140c9cc release: prepare v1.2.30
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-12 05:34:49 +08:00
zxyszx 2fd37bf635 release: prepare v1.2.29
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-12 04:25:17 +08:00
zxyszx 3d3a251af2 release: prepare v1.2.28
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-12 01:15:42 +08:00
zxyszx 6ad9164be3 release: prepare v1.2.27
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-11 23:40:55 +08:00
zxyszx ce98978ebd release: prepare v1.2.26
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-11 14:09:08 +08:00
zxyszx ee38990ea1 release: prepare v1.2.25
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-10 22:30:39 +08:00
zxyszx 9cb3f13b02 fix: remove duplicate verified email entry
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-10 21:16:34 +08:00
zxyszx d2cfad3cc4 feat: streamline forwarding email verification
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-10 20:24:02 +08:00
zxyszx dbd5b95143 fix: support SMTP LOGIN authentication
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-10 15:42:05 +08:00
zxyszx 131421a0f1 release: prepare v1.2.21
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-10 02:55:49 +08:00
云逸 2a26a3b127 [codex] 合并账号级与邮箱单独转发 (#1)
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
* fix: merge account and mailbox forwarding

* docs: prepare v1.2.20 release

---------

Co-authored-by: zxyszx <299979470+zxyszx@users.noreply.github.com>
2026-08-09 11:33:41 +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
99 changed files with 7278 additions and 1238 deletions
+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 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+8
View File
@@ -0,0 +1,8 @@
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
+5
View File
@@ -0,0 +1,5 @@
- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。
- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。
- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。
- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。
- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。
+5
View File
@@ -0,0 +1,5 @@
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
+3
View File
@@ -0,0 +1,3 @@
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
+9
View File
@@ -0,0 +1,9 @@
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
+7
View File
@@ -0,0 +1,7 @@
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
+6
View File
@@ -0,0 +1,6 @@
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
+8
View File
@@ -0,0 +1,8 @@
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
+13
View File
@@ -0,0 +1,13 @@
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
+9
View File
@@ -0,0 +1,9 @@
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
+7
View File
@@ -0,0 +1,7 @@
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
+11
View File
@@ -0,0 +1,11 @@
- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。
- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。
- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。
- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。
- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。
- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。
- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。
- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。
- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。
**完整更新日志**[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32)
+9
View File
@@ -0,0 +1,9 @@
- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。
- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。
- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。
- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。
- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。
- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。
- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。
**完整更新日志**[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33)
+9
View File
@@ -0,0 +1,9 @@
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
**完整更新日志**[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
+7
View File
@@ -0,0 +1,7 @@
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
**完整更新日志**[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
+11
View File
@@ -0,0 +1,11 @@
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
**完整更新日志**[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
+4
View File
@@ -0,0 +1,4 @@
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
**完整更新日志**[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
+6
View File
@@ -0,0 +1,6 @@
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
**完整更新日志**[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
+4
View File
@@ -0,0 +1,4 @@
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
**完整更新日志**[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
+6
View File
@@ -0,0 +1,6 @@
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
**完整更新日志**[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
+3
View File
@@ -0,0 +1,3 @@
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
**完整更新日志**[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
+4
View File
@@ -0,0 +1,4 @@
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
**完整更新日志**[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
+6
View File
@@ -0,0 +1,6 @@
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
**完整更新日志**[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
+3 -2
View File
@@ -44,9 +44,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash -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
+15 -12
View File
@@ -31,9 +31,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash -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
@@ -288,17 +289,21 @@ jobs:
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[,;。]", entry, maxsplit=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>"
section = f"<b>{index:02d} · {html.escape(title)}</b>"
if description:
section += "\n" + html.escape(description.rstrip("。") + "。")
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新版本现已发布\n\n<b>本次更新</b>\n\n"
available = max(0, 3600 - len(prefix))
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:
@@ -309,20 +314,18 @@ jobs:
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)
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}" \
--arg release_url "${RELEASE_URL}" \
--rawfile text telegram-release-message.txt \
'{
chat_id:$chat_id,
text:$text,
parse_mode:"HTML",
disable_web_page_preview:true,
reply_markup:{inline_keyboard:[[{text:"查看本次更新 ↗",url:$release_url}]]}
disable_web_page_preview:true
}' > telegram-release-payload.json
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
+3 -3
View File
@@ -2,7 +2,7 @@
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)
[Releases](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [Chinese README](README.md)
## Features
@@ -18,7 +18,7 @@ NewSzxcn Email is a self-hosted mail server with a complete Webmail client and a
Debian and Ubuntu on `amd64` or `arm64` are supported.
```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/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.
@@ -55,7 +55,7 @@ Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Publ
## Manual source deployment
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
git clone https://gitea.xzys.me/szx/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
+63 -13
View File
@@ -2,12 +2,7 @@
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
[![Release](https://img.shields.io/github/v/release/zxyszx/NewSzxcn-Email?display_name=tag&sort=semver)](https://github.com/zxyszx/NewSzxcn-Email/releases)
[![Docker Release](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) · [MIT License](LICENSE)
## 主要功能
@@ -26,17 +21,69 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
支持 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
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员
邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
### 管理面板
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:尚未安装
--------------------------------------------------
1. 一键安装 NewSzxcn Email
0. 退出
==================================================
请选择 [1]
```
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:运行中
版本:v1.2.19(示例,以实际安装版本为准)
地址:https://mail.example.com
--------------------------------------------------
安装与维护
1. 重新安装(完整备份,失败自动恢复)
2. 更新系统(自动备份,失败自动回滚)
3. 检查并修复现有安装
服务管理
4. 查看运行状态
5. 重启服务
6. 查看实时日志
证书与恢复
7. 管理 SSL 证书
8. 回滚到上次更新前版本
账号与帮助
9. 邮箱后台配置指南
10. 查看管理员登录信息
11. 重置管理员登录密码
危险操作
12. 卸载服务(保留数据)
0. 退出
==================================================
请选择 [2]
```
容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
@@ -44,7 +91,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
- 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 分开确认邮件服务器域名和邮箱地址域名创建唯一管理员邮箱默认 `admin@邮箱地址域名`回车自动生成 12 位密码,自定义密码最少 6 位
- 自动检测并确认邮箱地址域名创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
@@ -87,6 +134,7 @@ 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
@@ -141,7 +189,7 @@ sudo newszxcn-email uninstall
需要自行控制 Compose 配置时:
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy
cp .env.example .env
# 编辑 .env
@@ -163,7 +211,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
- 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
- 部署:Docker、Docker Compose、GHCR
## 本地开发
@@ -178,6 +226,8 @@ pnpm install
pnpm run dev
```
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
提交前建议运行:
```bash
+7 -5
View File
@@ -2,7 +2,7 @@
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
[版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能
@@ -21,13 +21,13 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
支持 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
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
```
脚本会自动完成:
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
## 更新与回滚
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
### 后台页面更新
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
@@ -121,7 +123,7 @@ sudo newszxcn-email uninstall
需要自行控制 Compose 配置时:
```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy
cp .env.example .env
# 编辑 .env
@@ -143,7 +145,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
- 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
- 部署:Docker、Docker Compose、GHCR
## 本地开发
+1 -1
View File
@@ -1 +1 @@
1.2.15
1.2.43
+171 -66
View File
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
Messages int64 `json:"messages"`
UnreadMessages int64 `json:"unreadMessages"`
StorageBytes int64 `json:"storageBytes"`
TodaySent int64 `json:"todaySent"`
TodayReceived int64 `json:"todayReceived"`
SendDelivered int64 `json:"sendDelivered"`
SendFailed int64 `json:"sendFailed"`
QueueMessages int64 `json:"queueMessages"`
}
now := a.now().UTC()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
queries := []struct {
q string
dest *int64
args []any
}{
{`SELECT COUNT(*) FROM users`, &out.Users},
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
{`SELECT COUNT(*) FROM domains`, &out.Domains},
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
{`SELECT COUNT(*) FROM messages`, &out.Messages},
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
{q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
{q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
{q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
{q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
{q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
{q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
{q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
{q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
{q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
}
for _, item := range queries {
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load overview")
return
}
@@ -49,9 +62,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.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), '')
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.storage_quota_mb,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.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,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")
@@ -64,7 +77,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users")
return
}
@@ -101,6 +114,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB int `json:"storageQuotaMb"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -142,6 +156,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
storageQuotaMB := req.StorageQuotaMB
if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if storageQuotaMB == 0 {
storageQuotaMB = defaultUserStorageQuotaMB
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
@@ -159,11 +181,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), 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,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
var primaryDomainID string
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
badRequest(w, err)
return
}
} else if !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "failed to load account domain")
return
}
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
@@ -194,6 +227,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB *int `json:"storageQuotaMb"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -267,6 +301,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
var storageQuotaMB int
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
return
}
if req.StorageQuotaMB != nil {
storageQuotaMB = *req.StorageQuotaMB
}
if storageQuotaMB < 100 {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
@@ -311,8 +357,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
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 _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用"))
return
@@ -409,11 +455,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if a.isDefaultAdminUser(target) {
badRequest(w, errors.New("default administrator cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
} else if target.Role == "admin" {
badRequest(w, errors.New("administrator accounts cannot be deleted"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
@@ -553,6 +596,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -563,7 +607,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
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"`
@@ -580,20 +623,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
if role == "" {
role = "user"
}
if role != "user" && role != "admin" {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
userID := strings.TrimSpace(req.UserID)
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
@@ -611,15 +643,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
userID := strings.TrimSpace(req.UserID)
displayName := req.DisplayName
if displayName == "" {
displayName = address
}
var disabled, ownerStorageQuotaMB int
var passwordHash, ownerRole string
if userID != "" {
var disabled int
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -627,11 +658,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
return
}
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
} else {
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
} else {
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
@@ -641,21 +672,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if hashErr != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
}
_, 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 {
passwordHash = string(hash)
ownerRole = "user"
ownerStorageQuotaMB = defaultUserStorageQuotaMB
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
@@ -664,16 +694,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
return
}
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
m, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -699,8 +739,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName is required"))
return
}
if req.QuotaMB <= 0 {
req.QuotaMB = 1024
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
status := strings.TrimSpace(req.Status)
if status == "" {
@@ -710,13 +751,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid status"))
return
}
existingMailbox, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if existingMailbox.Primary && status != existingMailbox.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
userID := strings.TrimSpace(req.UserID)
if userID == "" {
badRequest(w, errors.New("userId is required"))
return
}
if existingMailbox.Primary && userID != existingMailbox.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
var disabled int
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -728,8 +783,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("owner user is disabled"))
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if ownerRole == "admin" {
req.QuotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -749,6 +807,14 @@ 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")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -1118,15 +1184,15 @@ 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.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), '')
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.storage_quota_mb,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.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,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.storage_quota_mb,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.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err
}
item.Disabled = intBool(disabled)
@@ -1191,9 +1257,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
return nil, err
}
m.CreatedAt = parseTime(created)
if err := a.markMailboxPrimary(ctx, &m); err != nil {
return nil, err
}
return &m, nil
}
func markPrimaryMailboxes(items []Mailbox) {
primaryByUser := make(map[string]int)
for i := range items {
candidate, ok := primaryByUser[items[i].UserID]
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
primaryByUser[items[i].UserID] = i
}
}
for _, index := range primaryByUser {
items[index].Primary = true
}
}
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
var primaryID string
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE mb.user_id=?
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
if err != nil {
return err
}
mailbox.Primary = mailbox.ID == primaryID
return nil
}
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
mailbox, err := a.mailboxByID(ctx, id)
if err != nil {
return err
}
if mailbox.Primary {
return errors.New("用户默认邮箱不能删除")
}
return nil
}
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
row := 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 LIMIT 1`, userID)
var m Mailbox
+152 -7
View File
@@ -38,8 +38,17 @@ type App struct {
telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex
backupMu sync.Mutex
backupJob *backupJob
backupTransfers map[string]*backupTransfer
}
const (
defaultUserStorageQuotaMB = 100
defaultAdminStorageQuotaMB = 1024
minimumStorageQuotaMB = 100
)
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
@@ -75,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
}
db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
@@ -97,6 +106,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close()
return nil, err
@@ -120,6 +133,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
return a, nil
}
@@ -168,6 +182,7 @@ func (a *App) migrate(ctx context.Context) error {
two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER,
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -317,6 +332,7 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'folder',
sort_order INTEGER NOT NULL DEFAULT 0,
uid_validity INTEGER NOT NULL DEFAULT 0,
uid_next INTEGER NOT NULL DEFAULT 1,
@@ -672,6 +688,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err
}
if err := a.migrateUserStorageQuota(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
@@ -690,6 +709,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateFolderSortOrder(ctx); err != nil {
return err
}
if err := a.migrateFolderIcons(ctx); err != nil {
return err
}
if err := a.migrateExternalIMAP(ctx); err != nil {
return err
}
@@ -705,6 +727,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateTelegramNotifications(ctx); err != nil {
return err
}
if err := a.migrateDefaultMailLabels(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
@@ -722,6 +747,48 @@ func (a *App) migrateTelegramNotifications(ctx context.Context) error {
return nil
}
func (a *App) migrateDefaultMailLabels(ctx context.Context) error {
const marker = "defaultMailLabelsInitialized"
var initialized int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil {
return err
}
if initialized > 0 {
return nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`)
if err != nil {
return err
}
var mailboxIDs []string
for rows.Next() {
var mailboxID string
if err := rows.Scan(&mailboxID); err != nil {
rows.Close()
return err
}
mailboxIDs = append(mailboxIDs, mailboxID)
}
if err := rows.Close(); err != nil {
return err
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
for _, mailboxID := range mailboxIDs {
if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil {
return err
}
return tx.Commit()
}
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
var mailboxSettingExists int
@@ -1286,6 +1353,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
return err
}
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
return err
}
hasColumn := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "storage_quota_mb" {
hasColumn = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasColumn {
return nil
}
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
return err
}
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
@@ -1464,8 +1564,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1486,7 +1586,7 @@ func (a *App) seed(ctx context.Context) error {
}
// Create mailbox for admin
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
if err != nil {
return err
}
@@ -1607,7 +1707,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
if !errors.Is(err, sql.ErrNoRows) {
return err
}
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
if err != nil {
return err
}
@@ -1769,6 +1869,30 @@ func defaultFolderDefs() []struct{ name, role string } {
}
}
type defaultMailLabel struct {
name string
color string
}
func defaultMailLabelDefs() []defaultMailLabel {
return []defaultMailLabel{
{name: "个人", color: "#10b981"},
{name: "家人", color: "#ec4899"},
{name: "朋友", color: "#06b6d4"},
{name: "工作", color: "#3b82f6"},
{name: "重要", color: "#f59e0b"},
}
}
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
for _, label := range defaultMailLabelDefs() {
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
return err
}
}
return nil
}
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -1798,8 +1922,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
if localPart == "" {
return "", errors.New("invalid local part")
}
if quotaMB <= 0 {
quotaMB = 1024
if quotaMB < 0 {
return "", errors.New("quotaMb must be zero or greater")
}
var ownerRole string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
return "", err
}
if ownerRole == "admin" {
quotaMB = 0
}
if status == "" {
status = "active"
@@ -1826,9 +1957,23 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
return "", err
}
}
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
return "", err
}
return id, nil
}
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
SET quota_mb=0, updated_at=?
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
return err
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
+577 -17
View File
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox
}
func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(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("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID)
if err != nil {
t.Fatal(err)
}
if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB {
t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB)
}
domainID := mustDefaultDomainID(t, a)
var secondary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "admin-secondary",
"userId": adminUser.ID,
}, &secondary); code != http.StatusCreated {
t.Fatalf("create admin secondary mailbox code=%d", code)
}
if secondary.QuotaMB != 0 {
t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB)
}
var primaryHash, secondaryHash string
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil {
t.Fatal(err)
}
if primaryHash != secondaryHash {
t.Fatal("admin secondary mailbox did not inherit the owner password")
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "owner@lanqin.local",
"displayName": "Owner",
"password": "OwnerPassword123!",
"role": "user",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular owner code=%d", code)
}
if regular.MailboxCount != 1 {
t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount)
}
if regular.StorageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB)
}
if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil {
t.Fatalf("new account primary mailbox missing: %v", err)
}
var primaryStatusErr map[string]any
primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{
"userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled",
}, &primaryStatusErr); code != http.StatusBadRequest {
t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr)
}
var regularMailbox Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "owner-secondary",
"userId": regular.ID,
}, &regularMailbox); code != http.StatusCreated {
t.Fatalf("create regular secondary mailbox code=%d", code)
}
if regularMailbox.QuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB)
}
var userHash, mailboxHash string
if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil {
t.Fatal(err)
}
if userHash != mailboxHash {
t.Fatal("regular secondary mailbox did not inherit the owner password")
}
}
func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(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("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
var errBody map[string]any
if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator account delete code=%d body=%v", code, errBody)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody)
}
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99,
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody)
}
var updatedAdmin AdminUser
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100,
}, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 {
t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin)
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular user code=%d user=%+v", code, regular)
}
primary, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody)
}
secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID})
if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK {
t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody)
}
}
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
return createTestAPITokenWithScopes(t, client, name, nil)
}
@@ -549,16 +690,26 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
var labels struct {
Items []MailLabel `json:"items"`
}
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 {
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != len(defaultMailLabelDefs()) {
t.Fatalf("labels code=%d items=%+v", code, labels.Items)
}
var importantLabel MailLabel
for _, label := range labels.Items {
if label.Name == "重要" {
importantLabel = label
break
}
}
if importantLabel.ID == "" || importantLabel.MessageCount != 1 {
t.Fatalf("important label missing or count is wrong: %+v", labels.Items)
}
var labeled struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+importantLabel.ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+importantLabel.ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels)
}
var starred struct {
@@ -1040,6 +1191,191 @@ func TestMailRulesForwardingAction(t *testing.T) {
}
}
func TestMailRulesExactSenderCustomFolderAndStopProcessing(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 code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-exact-sender", "Sender With Name", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil)
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var bad map[string]any
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}},
"actions": []map[string]string{{"type": "archive"}},
}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad)
}
createRule := func(name string, action map[string]string, stop bool) {
t.Helper()
var rule MailRule
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"name": name,
"conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}},
"actions": []map[string]string{action},
"stopProcessing": stop,
}, &rule); code != http.StatusCreated {
t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule)
}
}
createRule("fallback archive", map[string]string{"type": "archive"}, false)
createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true)
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "Netflix code", "text": "123456"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d sent=%+v", code, sent)
}
var custom struct {
Items []MailMessage `json:"items"`
}
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 {
t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items)
}
var archived struct {
Items []MailMessage `json:"items"`
}
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items)
}
var icon string
if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" {
t.Fatalf("rule-created folder icon=%q err=%v", icon, err)
}
}
func TestFolderIconForName(t *testing.T) {
tests := []struct {
name string
requested string
want string
}{
{name: "Netflix 验证码", requested: "auto", want: "netflix"},
{name: "ChatGPT 通知", want: "chatgpt"},
{name: "OpenAI 账单", want: "chatgpt"},
{name: "项目归档", want: "briefcase"},
{name: "其他", want: "folder"},
{name: "Netflix", requested: "heart", want: "heart"},
{name: "Netflix", requested: "unknown", want: "folder"},
{name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},
{name: "Egypt archive", want: "folder"},
{name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"},
{name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"},
}
for _, tt := range tests {
t.Run(tt.name+"/"+tt.requested, func(t *testing.T) {
if got := folderIconForName(tt.name, tt.requested); got != tt.want {
t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want)
}
})
}
}
func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
admin := &testClient{t: t, server: httptest.NewServer(a.Router())}
defer admin.server.Close()
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 code=%d", code)
}
domainID := createTestDomain(t, admin, "manual-icon.test")
mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil)
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil {
t.Fatalf("create custom folder: %v", err)
}
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil {
t.Fatalf("reuse custom folder: %v", err)
}
var icon string
if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" {
t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err)
}
}
func TestMailRuleApplyExistingWhenDisabledExcludesSent(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 code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-existing-sender", "Existing Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil)
subject := "same inbound and sent subject"
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var incomingSend MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated {
t.Fatalf("incoming send code=%d", code)
}
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var outgoing MailMessage
if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated {
t.Fatalf("outgoing send code=%d", code)
}
var rule MailRule
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"name": "existing disabled",
"conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}},
"actions": []map[string]string{{"type": "star"}},
"applyToExisting": true,
"enabled": false,
}, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled {
t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule)
}
var inboundStarred, sentStarred int
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil {
t.Fatal(err)
}
if inboundStarred != 1 || sentStarred != 0 {
t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred)
}
}
func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) {
msg := ruleMessage{AttachmentNames: "notes.txt"}
if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) {
t.Fatal("attachment condition must not match MIME type or unrelated extension")
}
if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) {
t.Fatal("attachment condition should match filename")
}
}
func TestMailRulesMailboxIsolation(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1379,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User)
}
var storageQuotaMB int
if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil {
t.Fatal(err)
}
if storageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB)
}
var me struct {
User User `json:"user"`
}
@@ -2020,6 +2363,16 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
insertMessage("msg_multi_primary_read", primary.ID, primaryInboxID, "primary read", 1)
insertMessage("msg_multi_primary_archived", primary.ID, primaryArchiveID, "primary archived unread", 0)
insertMessage("msg_multi_secondary_unread", secondary.ID, secondaryInboxID, "secondary unread", 0)
var primaryImportantID, secondaryImportantID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, primary.ID).Scan(&primaryImportantID); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, secondary.ID).Scan(&secondaryImportantID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?),(?,?,?)`, "msg_multi_primary_unread_1", primaryImportantID, now, "msg_multi_secondary_unread", secondaryImportantID, now); err != nil {
t.Fatal(err)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
@@ -2031,6 +2384,28 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
}
var allLabels struct {
Items []MailLabel `json:"items"`
}
if code := userClient.do("GET", "/api/mail/labels?mailboxId=all", nil, &allLabels); code != http.StatusOK || len(allLabels.Items) != len(defaultMailLabelDefs()) {
t.Fatalf("all labels code=%d items=%+v", code, allLabels.Items)
}
var allImportant MailLabel
for _, label := range allLabels.Items {
if label.Name == "重要" {
allImportant = label
break
}
}
if allImportant.ID == "" || allImportant.MailboxID != "" || allImportant.MessageCount != 2 {
t.Fatalf("aggregated important label=%+v", allImportant)
}
var importantMessages struct {
Items []MailMessage `json:"items"`
}
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&labelId="+url.QueryEscape(allImportant.ID), nil, &importantMessages); code != http.StatusOK || len(importantMessages.Items) != 2 {
t.Fatalf("all important messages code=%d items=%+v", code, importantMessages.Items)
}
unreadByAddress := map[string]int{}
for _, item := range mine.Items {
unreadByAddress[item.Address] = item.UnreadCount
@@ -2251,7 +2626,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
}
var custom MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" {
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
}
var folders struct {
@@ -2260,6 +2635,15 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
}
foundIcon := ""
for _, folder := range folders.Items {
if folder.Name == "客户归档" {
foundIcon = folder.Icon
}
}
if foundIcon != "netflix" {
t.Fatalf("folder icon=%q, want netflix", foundIcon)
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
@@ -2571,6 +2955,25 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
}
}
func TestForwardingVerificationPageDoesNotLinkToMailbox(t *testing.T) {
a := newTestApp(t)
recorder := httptest.NewRecorder()
a.renderForwardingVerificationPage(recorder, http.StatusOK, true, "friend@example.test", "该邮箱已通过转发验证")
body := recorder.Body.String()
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
for _, forbidden := range []string{`href="/"`, "返回邮箱", "登录"} {
if strings.Contains(body, forbidden) {
t.Fatalf("verification page contains forbidden navigation %q: %s", forbidden, body)
}
}
if !strings.Contains(body, "可以关闭此页面") {
t.Fatalf("verification page is missing close guidance: %s", body)
}
}
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
@@ -2714,8 +3117,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
}
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
}
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
@@ -2724,8 +3139,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
t.Fatal(err)
}
if queueCount != 2 {
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
if queueCount != 3 {
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
}
}
@@ -2910,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
}
var owner AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!",
}, &owner); code != http.StatusCreated {
t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner)
}
var mailbox Mailbox
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
"domainId": domain.ID,
"localPart": "api-user",
"displayName": "API User",
"password": "Password123!",
"password": "DifferentPassword123!",
"quotaMb": 256,
"userId": owner.ID,
}, &mailbox); code != http.StatusCreated {
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
}
ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email)
if err != nil {
t.Fatal(err)
}
var protectedMailboxErr map[string]any
if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest {
t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr)
}
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
t.Fatalf("mailbox=%+v", mailbox)
}
var ownerPasswordHash, mailboxPasswordHash string
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil {
t.Fatal(err)
}
if mailboxPasswordHash != ownerPasswordHash {
t.Fatal("open api mailbox did not inherit the owner password")
}
var mailboxes struct {
Items []Mailbox `json:"items"`
}
@@ -2940,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
t.Fatalf("updated mailbox=%+v", updated)
}
if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 {
t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated)
}
var ok map[string]any
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
@@ -4438,6 +4881,49 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
}
}
func TestSubmissionLoginAuthenticationWithAndWithoutInitialResponse(t *testing.T) {
a := newTestApp(t)
for _, withInitialResponse := range []bool{false, true} {
t.Run(fmt.Sprintf("initial-response-%t", withInitialResponse), func(t *testing.T) {
session := &submissionSession{app: a}
if mechanisms := strings.Join(session.AuthMechanisms(), " "); mechanisms != "PLAIN LOGIN" {
t.Fatalf("submission auth mechanisms=%q", mechanisms)
}
server, err := session.Auth(sasl.Login)
if err != nil {
t.Fatal(err)
}
var response []byte
if withInitialResponse {
response = []byte("admin@lanqin.local")
}
challenge, done, err := server.Next(response)
if err != nil || done {
t.Fatalf("initial LOGIN response err=%v done=%t", err, done)
}
if !withInitialResponse {
if string(challenge) != "Username:" {
t.Fatalf("username challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("admin@lanqin.local"))
if err != nil || done {
t.Fatalf("username response err=%v done=%t", err, done)
}
}
if string(challenge) != "Password:" {
t.Fatalf("password challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("ChangeMe123!"))
if err != nil || !done || challenge != nil {
t.Fatalf("password response challenge=%q err=%v done=%t", challenge, err, done)
}
if session.user == nil || session.mailbox == nil {
t.Fatal("LOGIN authentication did not populate submission session")
}
})
}
}
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
@@ -4475,7 +4961,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil {
t.Fatal(err)
}
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
@@ -4760,6 +5246,72 @@ func TestDNSRecords(t *testing.T) {
}
}
func TestDefaultMailLabelsBackfillOrderAndDeletion(t *testing.T) {
a := newTestApp(t)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`DELETE FROM system_settings WHERE key='defaultMailLabelsInitialized'`); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
t.Fatal(err)
}
labels, err := a.labelsForMailbox(context.Background(), mailboxID)
if err != nil {
t.Fatal(err)
}
defaults := defaultMailLabelDefs()
if len(labels) != len(defaults) {
t.Fatalf("labels=%+v", labels)
}
for index, expected := range defaults {
if labels[index].Name != expected.name || labels[index].Color != expected.color {
t.Fatalf("label %d=%+v want name=%q color=%q", index, labels[index], expected.name, expected.color)
}
}
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE id=?`, labels[1].ID); err != nil {
t.Fatal(err)
}
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
t.Fatal(err)
}
labels, err = a.labelsForMailbox(context.Background(), mailboxID)
if err != nil {
t.Fatal(err)
}
if len(labels) != len(defaults)-1 {
t.Fatalf("deleted default label was restored: %+v", labels)
}
}
func TestCheckDKIMRecordRequiresMatchingPublicKey(t *testing.T) {
tests := []struct {
name string
records []string
key string
ok bool
message string
}{
{name: "matching", records: []string{"v=DKIM1; k=rsa; p=ABC123"}, key: "ABC123", ok: true, message: "DKIM 公钥匹配"},
{name: "split whitespace", records: []string{"v=DKIM1; k=rsa; p=ABC 123\n456"}, key: "ABC123456", ok: true, message: "DKIM 公钥匹配"},
{name: "wrong key", records: []string{"v=DKIM1; k=rsa; p=WRONG"}, key: "EXPECTED", ok: false, message: "DKIM 公钥与后台生成的记录不一致"},
{name: "unrelated TXT", records: []string{"google-site-verification=token"}, key: "EXPECTED", ok: false, message: "未找到 DKIM 记录"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status := checkDKIMRecord(tt.records, tt.key)
if status.OK != tt.ok || status.Message != tt.message {
t.Fatalf("status=%+v", status)
}
})
}
}
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -4819,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
}
var regularUpdateErr map[string]any
var updatedRegular PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Changed Regular",
"description": "Should not change",
"permissions": []string{PermissionAdminOverview},
}, &regularUpdateErr); code != http.StatusForbidden {
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
"permissions": regularUserDefaultPermissions(),
"limits": defaultPermissionLimits(),
}, &updatedRegular); code != http.StatusOK {
t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular)
}
if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" {
t.Fatalf("regular system permission group identity changed: %+v", updatedRegular)
}
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
if err != nil {
t.Fatal(err)
}
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -5981,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
ctx := context.Background()
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil {
t.Fatal(err)
}
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
@@ -6057,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats)
}
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
var aliceStorageQuotaMB int64
if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil {
t.Fatal(err)
}
if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats)
}
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
+3 -3
View File
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
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 _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -186,7 +186,7 @@ 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 _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
}
}
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
if err != nil {
t.Fatal(err)
}
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
t.Fatalf("unexpected groups: %+v", groups)
}
}
func TestGoogleDriveResumableRequest(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
if err != nil {
t.Fatal(err)
}
if size != int64(len("encrypted backup")) {
t.Fatalf("upload size = %d", size)
}
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
}
var metadata struct {
Name string `json:"name"`
Parents []string `json:"parents"`
}
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
t.Fatal(err)
}
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
t.Fatalf("metadata = %+v", metadata)
}
}
func TestBackupProgressReaderReportsBytes(t *testing.T) {
var updates []int64
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
updates = append(updates, uploaded)
}}
raw, err := io.ReadAll(reader)
if err != nil || string(raw) != "encrypted backup" {
t.Fatalf("read = %q, %v", raw, err)
}
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
t.Fatalf("progress updates = %v", updates)
}
}
func TestGoogleDriveUploadMessage(t *testing.T) {
tests := []struct {
status int
body string
want string
}{
{http.StatusUnauthorized, `{}`, "授权已失效"},
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
{http.StatusForbidden, `{}`, "无上传权限"},
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
}
for _, test := range tests {
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
if !strings.Contains(message, test.want) {
t.Fatalf("message %q does not contain %q", message, test.want)
}
}
}
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "large-backup.tar.zst.enc")
size := int64(googleDriveUploadChunkSize + 3)
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(size); err != nil {
t.Fatal(err)
}
_ = file.Close()
var ranges []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ranges = append(ranges, r.Header.Get("Content-Range"))
_, _ = io.Copy(io.Discard, r.Body)
if len(ranges) == 1 {
w.WriteHeader(http.StatusPermanentRedirect)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
if !a.startBackupTransfer("googleDrive", path) {
t.Fatal("failed to start transfer")
}
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
t.Fatal(err)
}
wantRanges := []string{
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
}
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
}
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
if transfer == nil || transfer.Uploaded != size {
t.Fatalf("transfer = %+v", transfer)
}
}
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
})
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
t.Fatal("backup password encryption succeeded without a deployment secret")
}
}
func TestBackupPasswordValidation(t *testing.T) {
for _, valid := range []string{"12345678", "Restore Password 123!"} {
if !validBackupPassword(valid) {
t.Errorf("valid password rejected: %q", valid)
}
}
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
if validBackupPassword(invalid) {
t.Errorf("invalid password accepted: %q", invalid)
}
}
}
func TestBackupPasswordHint(t *testing.T) {
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
t.Fatalf("password hint = %q", got)
}
if got := backupPasswordHint("ab"); got != "ab" {
t.Fatalf("two-character password hint = %q", got)
}
if got := backupPasswordHint(""); got != "" {
t.Fatalf("empty password hint = %q", got)
}
}
func TestSavedBackupPasswordAndHint(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("A23456789Z")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "A23456789Z" {
t.Fatalf("saved password = %q, %v", password, err)
}
schedule, err := a.loadBackupSchedule(context.Background())
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
t.Fatalf("schedule password state = %+v, %v", schedule, err)
}
}
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
now := a.now().UTC().Format(time.RFC3339Nano)
for key, value := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
t.Fatal(err)
}
}
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
t.Fatalf("password update code=%d body=%v", code, response)
}
if response["passwordHint"] != "N••••••••••9" {
t.Fatalf("password hint = %v", response["passwordHint"])
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "NewSharedPassword9" {
t.Fatalf("saved password = %q, %v", password, err)
}
for key, want := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
var got string
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
}
}
}
func TestManualBackupReusesSavedPassword(t *testing.T) {
dir := t.TempDir()
deployDir := filepath.Join(dir, "deploy")
if err := os.MkdirAll(deployDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
t.Fatalf("manual backup code=%d body=%v", code, response)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "SharedBackupPassword9" {
t.Fatalf("saved password changed: %q, %v", password, err)
}
deadline := time.Now().Add(10 * time.Second)
for {
a.backupMu.Lock()
status := a.backupJob.Status
a.backupMu.Unlock()
if status != "running" {
break
}
if time.Now().After(deadline) {
t.Fatal("manual backup did not finish before timeout")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestPublicServerIPValidation(t *testing.T) {
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
if !isPublicIP(net.ParseIP(value)) {
t.Errorf("public IP rejected: %s", value)
}
}
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
if isPublicIP(net.ParseIP(value)) {
t.Errorf("non-public IP accepted: %s", value)
}
}
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
t.Fatalf("literal public IP = %q", got)
}
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
t.Fatalf("literal private IP = %q", got)
}
}
func TestWriteRuntimeBackupEnv(t *testing.T) {
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
path := filepath.Join(t.TempDir(), ".env")
if err := writeRuntimeBackupEnv(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
contents := string(raw)
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
if !strings.Contains(contents, expected) {
t.Errorf("backup environment missing %q: %s", expected, contents)
}
}
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
if strings.Contains(contents, excluded) {
t.Fatalf("backup environment included excluded value %q", excluded)
}
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
}
}
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
dir := t.TempDir()
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
})
stopTestWorkers(a)
if !a.backupAssetsAvailable() {
t.Fatal("bundled compose did not enable complete backups")
}
if err := os.Remove(compose); err != nil {
t.Fatal(err)
}
if a.backupAssetsAvailable() {
t.Fatal("missing bundled compose incorrectly enabled complete backups")
}
}
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
if err != nil || ciphertext == "BackupPassword123!" {
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
}
plain, err := a.decryptBackupPassword(ciphertext)
if err != nil || plain != "BackupPassword123!" {
t.Fatalf("password decryption = %q, %v", plain, err)
}
if !validTelegramPrivateChatID("-1001234567890") {
t.Fatal("private Telegram group chat ID was rejected")
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
report, err := a.backupTelegramReport(context.Background(), path, info)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
if !strings.Contains(report, expected) {
t.Errorf("report missing %q: %s", expected, report)
}
}
if strings.Contains(report, "newszxcn.com(管理员)") {
t.Fatal("domain list incorrectly contains account role")
}
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
t.Fatal("report leaked a password")
}
}
+4
View File
@@ -67,6 +67,8 @@ type Config struct {
ReleaseAPIURL string
UpdateServiceURL string
UpdateServiceToken string
BackupSourceDir string
BackupDir string
}
func LoadConfig() Config {
@@ -131,6 +133,8 @@ func LoadConfig() Config {
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", ""),
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
}
}
+37 -1
View File
@@ -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 {
+1 -4
View File
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
if err != nil {
return nil, "", "", err
}
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
if len(targets) == 0 {
return nil, userID, mailboxAddress, nil
}
+8 -4
View File
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
return
}
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
}
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
title := "邮箱转发验证"
heading := "验证失败"
color := "#dc2626"
statusMark := "!"
closingMessage := "请联系验证发起人重新发送链接"
if ok {
heading = "验证完成"
color = "#2563eb"
color = "#16a34a"
statusMark = "&#10003;"
closingMessage = "验证结果已记录,可以关闭此页面"
}
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)
_, _ = 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:8px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><div aria-hidden="true" style="display:grid;place-items:center;width:44px;height:44px;margin:0 0 20px;border-radius:50%%;background:%s;color:white;font-size:24px;font-weight:700">%s</div><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 24px;font-size:15px;color:#64748b;word-break:break-all">%s</p><p style="margin:0;padding-top:20px;border-top:1px solid #e2e8f0;font-size:15px;color:#64748b">%s</p></section></main></body></html>`,
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
}
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
+4
View File
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
return nil
}
func (a *App) migrateFolderIcons(ctx context.Context) error {
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
+126 -23
View File
@@ -1,12 +1,14 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image/png"
"io"
"net/http"
"net/textproto"
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
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 {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
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,
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
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)
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
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 {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -266,6 +269,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -280,6 +284,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("system folder already exists"))
return
}
icon := folderIconForName(name, req.Icon)
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)
@@ -308,12 +313,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
return
}
for _, mailboxID := range mailboxIDs {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); 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})
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
return
}
mb, err := a.mailboxForCurrentUser(r)
@@ -321,7 +326,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
@@ -527,8 +532,88 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request,
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)
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
var existingID string
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
return existingID, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
id, err := a.ensureFolder(ctx, mailboxID, name)
if err != nil {
return "", err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
return id, err
}
func folderIconForName(name, requested string) string {
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
return normalizeFolderIcon(icon)
}
value := strings.ToLower(strings.TrimSpace(name))
for _, match := range []struct {
icon string
terms []string
}{
{"netflix", []string{"netflix", "奈飞", "网飞"}},
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
{"star", []string{"重要", "紧急", "important", "urgent"}},
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
{"bell", []string{"提醒", "通知", "remind", "notification"}},
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
} {
for _, term := range match.terms {
if folderNameContainsTerm(value, term) {
return match.icon
}
}
}
return "folder"
}
func folderNameContainsTerm(value, term string) bool {
if term != "gpt" {
return strings.Contains(value, term)
}
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == term {
return true
}
}
return false
}
func normalizeFolderIcon(raw string) string {
icon := strings.TrimSpace(raw)
const customPrefix = "data:image/png;base64,"
if strings.HasPrefix(icon, customPrefix) {
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
config, configErr := png.DecodeConfig(bytes.NewReader(data))
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
return icon
}
return "folder"
}
icon = strings.ToLower(icon)
switch icon {
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
return icon
default:
return "folder"
}
}
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
@@ -546,11 +631,12 @@ 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 != "" {
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
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 WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
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")
@@ -2231,14 +2317,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
var f MailFolder
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
return nil, err
}
return &f, nil
@@ -2534,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
return nil
}
var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); err != nil {
var userID string
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&quotaMB, &userID); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
@@ -2605,7 +2692,7 @@ 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
}
@@ -2622,13 +2709,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
}
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
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 l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)`, userID)
GROUP BY lower(l.name)
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
if err != nil {
return nil, err
}
@@ -2648,7 +2735,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
}
@@ -2679,7 +2766,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
}
@@ -2697,6 +2784,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 == "" {
@@ -2740,6 +2835,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
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 {
@@ -122,8 +122,17 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
args = append(args, labelID)
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")
}
+74 -23
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if strings.TrimSpace(msg.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
translatedHTML := ""
if strings.TrimSpace(msg.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
}
translatedHTML := <-translatedHTMLResult
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
translatedHTML := ""
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
}
translatedHTML := <-translatedHTMLResult
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
}
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
if err != nil {
return "", err
}
type translationJob struct {
node *html.Node
original string
text string
}
remaining := maxChars
var translateNode func(*html.Node) error
translateNode = func(n *html.Node) error {
jobs := make([]translationJob, 0)
var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
return nil
return
}
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
limited, _ := truncateRunes(text, remaining)
remaining -= utf8.RuneCountInString(limited)
translated, _, err := googleFreeTranslate(ctx, limited, target)
if err != nil {
return err
}
n.Data = strings.Replace(n.Data, text, translated, 1)
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if err := translateNode(c); err != nil {
return err
}
collect(c)
}
return nil
}
for _, n := range nodes {
if err := translateNode(n); err != nil {
return "", err
}
collect(n)
}
results := make([]string, len(jobs))
jobIndexes := make(chan int)
errCh := make(chan error, 1)
workers := min(4, len(jobs))
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobIndexes {
translated, _, translateErr := translator(ctx, jobs[index].text, target)
if translateErr != nil {
select {
case errCh <- translateErr:
default:
}
continue
}
results[index] = translated
}
}()
}
for index := range jobs {
jobIndexes <- index
}
close(jobIndexes)
wg.Wait()
select {
case translateErr := <-errCh:
return "", translateErr
default:
}
for index, job := range jobs {
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
}
var b bytes.Buffer
for _, n := range nodes {
+21 -1
View File
@@ -1,6 +1,10 @@
package app
import "testing"
import (
"context"
"strings"
"testing"
)
func TestParseGoogleTranslateResponse(t *testing.T) {
raw := []any{
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
}
}
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
translator := func(_ context.Context, text, target string) (string, string, error) {
return strings.ToUpper(text) + "-" + target, "en", nil
}
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
t.Fatalf("translated HTML = %q", got)
}
if !strings.Contains(got, `<pre>keep me</pre>`) {
t.Fatalf("code block was translated: %q", got)
}
}
func TestTruncateRunes(t *testing.T) {
got, truncated := truncateRunes("你好world", 4)
if got != "你好wo" || !truncated {
+7 -4
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
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 !mailboxes.Items[0].Primary {
t.Fatal("administrator mailbox should be marked as primary")
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("delete primary mailbox code=%d", code)
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
}
var me map[string]any
+9
View File
@@ -21,6 +21,7 @@ import (
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex"
"golang.org/x/text/encoding/simplifiedchinese"
)
type maildirMailbox struct {
@@ -822,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
if charset == "utf-8" || charset == "us-ascii" {
return input, nil
}
// GB2312 is commonly used as a label for GBK-compatible mail content.
// ianaindex does not consistently resolve these real-world aliases.
switch charset {
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
case "gb18030":
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
}
enc, err := ianaindex.IANA.Encoding(charset)
if err != nil {
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
+51 -6
View File
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
respondMailboxOwnerError(w, err)
return
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
var ownerPasswordHash, ownerRole string
var ownerStorageQuotaMB int
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
displayName = current.DisplayName
}
quotaMB := req.QuotaMB
if quotaMB <= 0 {
if quotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
if quotaMB == 0 {
quotaMB = current.QuotaMB
}
status := strings.TrimSpace(req.Status)
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
if userID == "" {
userID = current.UserID
}
if current.Primary && userID != current.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
if current.Primary && status != current.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
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)
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
if ownerRole == "admin" {
quotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
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)
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
return userID, err
}
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
var existingName, existingDescription string
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if intBool(existingSystem) {
if intBool(existingSystem) && id != PermissionGroupRegular {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return
}
name := strings.TrimSpace(req.Name)
if id == PermissionGroupRegular {
name = existingName
req.Description = existingDescription
}
if name == "" {
badRequest(w, errors.New("name is required"))
return
+64 -59
View File
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
quotaMB := defaultUserStorageQuotaMB
if user.Role == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
return
}
appliedCount := int64(0)
if req.ApplyToExisting && enabled {
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
if req.ApplyToExisting {
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
return
}
}
row := a.db.QueryRowContext(r.Context(), `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=?`, id)
item, err := scanRule(row)
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
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")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
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
}
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).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
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
if !ruleMatches(rule, msg) {
continue
}
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
continue
}
if rule.StopProcessing {
break
}
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
parts = append(parts, filename)
}
return strings.Join(parts, " ")
}
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
if operator == "" {
operator = "contains"
}
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
if !validRuleConditionOperator(field, operator) {
return MailRuleCondition{}, false
}
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func validRuleConditionOperator(field, operator string) bool {
switch field {
case "size":
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
case "date":
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
default:
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
}
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
for _, action := range normalizeRuleActions(actions, "") {
switch action.Type {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "move":
target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
target, err := normalizeFolderNameForUser(action.Value)
if err != nil {
return err
}
var folderID string
if isSystemFolderName(target) {
folderID, err = a.ensureFolder(ctx, mailboxID, target)
} else {
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
}
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "star":
starred := true
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
return err
}
func ruleTargetFolder(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "inbox":
return "Inbox"
case "archive":
return "Archive"
case "spam":
return "Spam"
case "trash":
return "Trash"
default:
return "Archive"
}
}
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
args := []any{userID}
where := `mb.user_id=?`
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(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 `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
if err != nil {
return 0, err
}
+14
View File
@@ -139,6 +139,20 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate)
r.Get("/admin/backups", a.handleListBackups)
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
r.Post("/admin/backups", a.handleCreateBackup)
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
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)
+44 -6
View File
@@ -121,21 +121,59 @@ type submissionSession struct {
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
return []string{sasl.Plain, sasl.Login}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
authenticate := func(username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
switch {
case strings.EqualFold(mech, sasl.Plain):
return sasl.NewPlainServer(func(_, username, password string) error {
return authenticate(username, password)
}), nil
case strings.EqualFold(mech, sasl.Login):
return &submissionLoginServer{authenticate: authenticate}, nil
default:
return nil, smtpserver.ErrAuthUnknownMechanism
}
}
type submissionLoginServer struct {
authenticate func(username, password string) error
username string
step int
}
func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) {
switch s.step {
case 0:
if response == nil {
s.step = 1
return []byte("Username:"), false, nil
}
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 1:
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 2:
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
s.step = 3
return nil, true, nil
default:
return nil, false, sasl.ErrUnexpectedClientResponse
}
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
+146 -2
View File
@@ -71,6 +71,7 @@ type telegramUpdate struct {
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0
return err == nil && id != 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
type telegramDiscoveredChat struct {
ChatID string `json:"chatId"`
DisplayName string `json:"displayName"`
}
func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, 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 nil, err
}
found := make([]telegramDiscoveredChat, 0)
seen := make(map[int64]bool)
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
continue
}
text := strings.TrimSpace(message.Text)
fields := strings.Fields(text)
matches := strings.EqualFold(text, pairingCode)
if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
matches = strings.EqualFold(fields[1], pairingCode)
}
if !matches {
continue
}
if seen[message.Chat.ID] {
continue
}
seen[message.Chat.ID] = true
name := strings.TrimSpace(message.Chat.Title)
if name == "" {
name = "Telegram 群组"
}
found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
}
if len(found) == 0 {
return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
}
return found, nil
}
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
@@ -473,6 +518,8 @@ func sanitizeTelegramAttachmentName(value string) string {
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 {
@@ -488,7 +535,11 @@ func detectTelegramOTP(subject, body string) string {
}
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
}
@@ -506,6 +557,9 @@ func detectTelegramOTP(subject, body string) string {
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
continue
}
if isTelegramOTPNonCode(value) {
continue
}
best := 0
for _, keyword := range keywords {
distance := match[0] - keyword[1]
@@ -557,6 +611,29 @@ func detectTelegramOTP(subject, body string) string {
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))
}
@@ -723,7 +800,7 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes
}
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
body = escapeTelegramWithinBudget(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
lines = []string{prefix + body + suffix}
}
htmlText := strings.Join(lines, "\n")
@@ -731,6 +808,73 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes
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,
+81
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -223,6 +224,55 @@ func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
}
}
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})
@@ -306,6 +356,37 @@ func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
}
}
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
subject := "验证 Apple 账户电子邮件地址"
body := "你的 Apple 验证码是 978534"
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
if err != nil {
t.Fatal(err)
}
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
if err != nil {
t.Fatal(err)
}
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
"To: admin@example.com\r\n" +
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
"Content-Type: text/plain; charset=gb2312\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
base64.StdEncoding.EncodeToString(encodedBody))
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if msg.Subject != subject {
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
}
if msg.BodyText != body {
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+5 -2
View File
@@ -21,8 +21,9 @@ type User struct {
type AdminUser struct {
User
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
StorageQuotaMB int `json:"storageQuotaMb"`
}
type APIToken struct {
@@ -71,6 +72,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
Primary bool `json:"primary"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -88,6 +90,7 @@ type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
+1 -1
View File
@@ -36,7 +36,7 @@
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.12",
"dompurify": "3.4.13",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
+7 -3
View File
@@ -1,15 +1,19 @@
import React from "react"
import { Navigate, useLocation } from "react-router-dom"
import { useMe, isTimeoutError } from "@/hooks/use-me"
import { useMe } from "@/hooks/use-me"
import { AuthLoading, AuthError } from "@/components/auth-states"
import { isUnauthorizedError } from "@/lib/api"
export function AuthGuard({ children }: { children: React.ReactNode }) {
const me = useMe()
const location = useLocation()
if (me.isLoading) return <AuthLoading />
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) {
const from = `${location.pathname}${location.search}${location.hash}`
return <Navigate to="/login" replace state={{ from }} />
}
return <>{children}</>
}
+5 -5
View File
@@ -1,17 +1,17 @@
import { Button } from "@/components/ui/button"
export function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
return <main className="grid min-h-screen place-items-center text-muted-foreground">...</main>
}
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
</main>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { Mail } from "lucide-react"
import { cn } from "@/lib/utils"
export function BrandMark({ className }: { className?: string }) {
return (
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
<Mail className="size-6 stroke-[1.8]" />
</span>
)
}
+2 -2
View File
@@ -26,12 +26,12 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <div className="text-sm text-muted-foreground">{description}</div>}
<DialogFooter>
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelText}
</Button>
+7 -11
View File
@@ -1,13 +1,13 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, 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 { BrandMark } from "@/components/brand-mark"
import { hasAnyPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types"
import {
@@ -28,7 +28,7 @@ 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: "overview", label: "仪表盘", icon: <LayoutDashboard />, 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"] },
@@ -36,6 +36,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
{ 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: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
]
@@ -57,7 +58,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
if (isMailRoute || isProfileRoute) {
return <Outlet />
@@ -72,9 +73,7 @@ function ProtectedContent() {
<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>
<BrandMark className="size-8 rounded-md [&>svg]:size-5" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">NewSzxcn </span>
</div>
@@ -110,15 +109,12 @@ function ProtectedContent() {
<span className="truncate font-semibold">{user.displayName}</span>
<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" ? "管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className="p-2">
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
<Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />退
</Button>
</div>
+5 -5
View File
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
+5 -2
View File
@@ -8,8 +8,9 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card"
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
+1 -1
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
+4 -4
View File
@@ -519,7 +519,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[active=true]:hover:bg-[hsl(var(--sidebar-active))] data-[active=true]:hover:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -621,7 +621,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))] md:opacity-0",
className
)}
{...props}
@@ -639,7 +639,7 @@ const SidebarMenuBadge = React.forwardRef<
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))]",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
"data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
+4 -1
View File
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
>(({ className, ...props }, ref) => (
<tr
ref={ref}
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+6 -6
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
className
)}
{...props}
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
default: "border-border/80 bg-popover text-popover-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
props.onClick?.(event)
}}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
className
)}
toast-close=""
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
className={cn("break-words text-sm font-semibold leading-5", className)}
{...props}
/>
))
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
{...props}
/>
))
+2 -2
View File
@@ -14,11 +14,11 @@ export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
<ToastProvider duration={5000} swipeDirection="right">
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
<div className="min-w-0 flex-1 space-y-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
+2 -6
View File
@@ -1,5 +1,5 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { api, isUnauthorizedError } from "@/lib/api"
import type { User } from "@/lib/api"
type MeResponse = { user: User }
@@ -10,11 +10,7 @@ export function useMe(
return useQuery({
queryKey: ["me"],
queryFn: api.me,
retry: 1,
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
...options,
})
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.includes("请求超时")
}
+2 -2
View File
@@ -5,8 +5,8 @@ import type {
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000
type ToasterToast = ToastProps & {
id: string
+89 -22
View File
@@ -5,34 +5,41 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 224 44% 12%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 12%;
--primary-foreground: 0 0% 98%;
--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%;
--action-primary: 0 0% 12%;
--action-primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 12%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 36%;
--accent: 0 0% 95%;
--accent-foreground: 0 0% 12%;
--destructive: 358 88% 61%;
--destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%;
--border: 0 0% 89.8%;
--input: 0 0% 82%;
--ring: 0 0% 32%;
--radius: 0.5rem;
--app-sidebar-width: 18rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%;
--sidebar-foreground: 0 0% 16%;
--sidebar-primary: 0 0% 12%;
--sidebar-primary-foreground: 0 0% 98%;
--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%;
--sidebar-accent: 0 0% 95%;
--sidebar-accent-foreground: 0 0% 12%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 0 0% 42%;
--sidebar-active: 207 100% 92%;
--sidebar-active-foreground: 202 100% 30%;
--mail-selected: 210 100% 96%;
--compose-send: 217 89% 43%;
--compose-send-hover: 216 94% 32%;
}
* { @apply border-border; }
@@ -61,10 +68,12 @@
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--action-primary: 240 4% 24%;
--action-primary-foreground: 0 0% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--muted-foreground: 240 5% 72%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
@@ -80,10 +89,25 @@
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-active: 210 48% 24%;
--sidebar-active-foreground: 210 100% 88%;
--mail-selected: 210 38% 20%;
--compose-send: 217 89% 52%;
--compose-send-hover: 214 94% 60%;
}
}
@layer components {
.compose-send-button.compose-send-button {
background: hsl(var(--compose-send));
color: white;
}
.compose-send-button.compose-send-button:hover {
background: hsl(var(--compose-send-hover));
color: white;
}
.mail-shell-grid {
display: grid;
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
@@ -106,6 +130,11 @@
max-width: 384px;
}
.mail-selected-row {
background-color: hsl(var(--mail-selected));
}
@media (max-width: 767px) {
.mail-content-grid {
grid-template-columns: minmax(0, 1fr);
@@ -140,12 +169,50 @@
}
[data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent));
background: hsl(var(--sidebar-accent) / 0.82);
color: hsl(var(--sidebar-accent-foreground));
box-shadow: inset 2px 0 0 hsl(var(--foreground));
}
[data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground));
color: hsl(var(--foreground));
}
.admin-page [data-slot="card"] {
border-radius: 0.5rem;
box-shadow: none;
}
.admin-page [data-slot="card-header"] {
padding: 1.25rem;
}
.admin-page [data-slot="card-content"] {
padding: 0 1.25rem 1.25rem;
}
.admin-page [data-slot="card-title"] {
font-size: 0.9375rem;
line-height: 1.4;
}
.admin-page [data-slot="table-header"] {
background: hsl(var(--muted) / 0.48);
}
.admin-page [data-slot="table-head"] {
height: 2.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.75rem;
}
.admin-page [data-slot="table-cell"] {
padding: 0.75rem;
}
.admin-page [data-slot="table-row"]:hover {
background: hsl(var(--muted) / 0.32);
}
}
+14 -4
View File
@@ -52,12 +52,16 @@ export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
export type AdminOverview = {
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
aliases: number; messages: number; unreadMessages: number; storageBytes: number
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: 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; unreadCount?: number; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; 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 MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
@@ -202,6 +206,12 @@ export type SystemUpdateResult = {
targetVersion: string
message: string
}
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
export type BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus; transfers: BackupTransfer[] }
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
+40 -10
View File
@@ -1,9 +1,20 @@
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"
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, BackupList, 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 class ApiError extends Error {
constructor(message: string, readonly status: number) {
super(message)
this.name = "ApiError"
}
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401
}
export type MailSearchParams = {
q?: string
from?: string
@@ -45,27 +56,34 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs, ...requestInit } = init
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
let timedOut = false
const timeout = window.setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs || REQUEST_TIMEOUT_MS)
const externalSignal = requestInit.signal
const abortFromExternalSignal = () => controller.abort()
if (externalSignal) {
if (externalSignal.aborted) controller.abort()
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
}
try {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
throw new ApiError(message, res.status)
}
return res.json() as Promise<T>
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("请求超时,请检查后端服务是否正常")
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
}
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
throw error instanceof Error ? error : new Error("网络请求失败")
} finally {
window.clearTimeout(timeout)
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
}
}
@@ -154,8 +172,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: "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) }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => 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; storageQuotaMb?: 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"),
@@ -163,7 +181,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: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
createMailbox: (payload: { domainId: string; localPart: string; displayName?: 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"),
@@ -193,6 +211,18 @@ export const api = {
},
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
backups: () => request<BackupList>("/api/admin/backups"),
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }),
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }),
verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }),
sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }),
sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }),
deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }),
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) }),
@@ -215,9 +245,9 @@ export const api = {
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
},
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
+4
View File
@@ -0,0 +1,4 @@
export function safeReturnPath(value: unknown, fallback = "/"): string {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
return value
}
+3
View File
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import "./index.css"
@@ -15,6 +16,8 @@ const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ d
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 })))
applyTheme(getInitialTheme())
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { Link, Navigate } from "react-router-dom"
import { Link, Navigate, useLocation } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
import { api } from "@/lib/api"
@@ -10,9 +10,13 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
import { safeReturnPath } from "@/lib/navigation"
import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function LoginPage() {
const me = useMe()
const location = useLocation()
const qc = useQueryClient()
const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
@@ -33,11 +37,15 @@ export function LoginPage() {
onError: (e) => toast({ title: "登录失败", description: e.message }),
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
if (me.data?.user) return <Navigate to={returnPath} replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main 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">
<div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<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">
@@ -82,6 +90,6 @@ export function LoginPage() {
</div>
)}
</div>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
export function NotFoundPage() {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
@@ -24,6 +24,6 @@ export function NotFoundPage() {
</Button>
</div>
</div>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -13,6 +13,8 @@ import { useToast } from "@/hooks/use-toast"
import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation"
import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function RegisterPage() {
const me = useMe()
@@ -57,10 +59,13 @@ export function RegisterPage() {
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main 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">
<div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<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">
@@ -103,7 +108,6 @@ export function RegisterPage() {
<div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label>
<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>
@@ -130,6 +134,6 @@ export function RegisterPage() {
</div>
)}
</div>
</div>
</main>
)
}
+38 -13
View File
@@ -1,19 +1,44 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { defineConfig, loadEnv } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/healthz": "http://localhost:8080",
server: {
port: 5173,
proxy: {
"/api": apiTarget,
"/healthz": apiTarget,
},
},
},
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "prosemirror",
test: /node_modules[\\/]prosemirror-/,
priority: 30,
},
{
name: "tiptap",
test: /node_modules[\\/]@tiptap/,
priority: 20,
},
],
},
},
},
},
}
})
+3 -3
View File
@@ -5,7 +5,7 @@
推荐直接使用仓库根目录的管理脚本:
```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
```
后续操作:
@@ -23,7 +23,7 @@ sudo newszxcn-email reset-2fa
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式邮件服务器域名邮箱地址域名、管理员邮箱/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。管理员邮箱默认 `admin@邮箱地址域名`自定义管理员密码最少 6 位,留空则生成 12 位密码。
首次安装会依次询问防火墙模式邮件服务器域名,自动检测邮箱地址域名,再选择默认 `admin` 前缀或自行创建管理员邮箱前缀,最后输入密码并选择 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。例如服务器域名 `mail.newszxcn.com`、选择默认前缀会创建 `admin@newszxcn.com`自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
@@ -181,7 +181,7 @@ TELEGRAM_RELEASE_CHAT_ID
- 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` 去重。
+2 -1
View File
@@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
ca-certificates tzdata sqlite3 supervisor nginx \
postfix postfix-sqlite \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
rspamd
rspamd zstd openssl
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
@@ -43,6 +43,7 @@ COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
COPY deploy/postfix/main.cf /etc/postfix/main.cf
COPY deploy/postfix/master.cf /etc/postfix/master.cf
+1
View File
@@ -21,5 +21,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
EXPOSE 8080 465 587
CMD ["lanqin-api"]
+5
View File
@@ -5,6 +5,8 @@ services:
environment:
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
LANQIN_BACKUP_SOURCE_DIR: /backup-source
LANQIN_BACKUP_DIR: /backups
ports:
- "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_SMTP_BIND:-25}:25"
@@ -17,6 +19,9 @@ services:
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- ./certs:/certs:ro
- ./.env:/backup-source/.env:ro
- ./docker-compose.yml:/backup-source/docker-compose.yml:ro
- ./backups:/backups
labels:
com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email"
+23 -2
View File
@@ -13,8 +13,20 @@ chown_dkim_dir() {
fi
}
reload_rspamd() {
if command -v rspamadm >/dev/null 2>&1 && rspamadm control reload >/dev/null 2>&1; then
echo "Rspamd reloaded after DKIM key update"
return 0
fi
if command -v pkill >/dev/null 2>&1 && pkill -HUP -x rspamd 2>/dev/null; then
echo "Rspamd reloaded after DKIM key update"
fi
}
sync_keys() {
changed_marker="$LANQIN_RSPAMD_DKIM_DIR/.reload-required.$$"
mkdir -p "$LANQIN_RSPAMD_DKIM_DIR"
rm -f "$changed_marker"
if [ ! -f "$LANQIN_DB_PATH" ]; then
chown_dkim_dir
return 0
@@ -24,13 +36,22 @@ sync_keys() {
[ -n "$domain" ] || continue
[ -n "$selector" ] || selector="lanqin"
keyfile="$LANQIN_RSPAMD_DKIM_DIR/${domain}.${selector}.key"
tmpfile="${keyfile}.tmp"
tmpfile="${keyfile}.tmp.$$"
printf '%s' "$private_key" | base64 -d > "$tmpfile"
chmod 0640 "$tmpfile"
mv "$tmpfile" "$keyfile"
if [ -f "$keyfile" ] && cmp -s "$tmpfile" "$keyfile"; then
rm -f "$tmpfile"
else
mv "$tmpfile" "$keyfile"
: > "$changed_marker"
fi
done
chown_dkim_dir
if [ -f "$changed_marker" ]; then
rm -f "$changed_marker"
reload_rspamd
fi
}
if [ "${1:-}" = "--once" ]; then
+71
View File
@@ -0,0 +1,71 @@
# NewSzxcn 完整备份与灾难恢复
## 后台创建并推送到 Telegram
1. 使用系统管理员登录 NewSzxcn 后台。
2. 在“系统设置 -> 通知”绑定 Telegram Bot Token 和私聊 Chat ID,并发送测试通知。
3. 打开“备份与恢复”。
4. 在“定时备份与 Telegram 推送”中选择每 3、5、7、30 天,或填写 1 至 365 天的自定义周期。
5. 备份 Chat ID 留空时沿用邮件通知私聊;也可以填写一个仅管理员可见的私有群组 Chat ID,将邮件通知与备份文件分开。Bot 必须已经加入该群组。
6. 填写服务器公网 IP。备份密码可以自己输入,也可以点击“生成 24 位”;必须另外保存到 1Password 等密码管理器。
7. 开启“自动创建并推送”,保存设置。
8. 首次配置建议点击“创建备份”,勾选“完成后发送到 Telegram”,确认机器人能收到说明消息和 `.tar.zst.enc` 加密附件。
Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名、管理员账号、普通用户账号、邮箱账号、文件大小、SHA-256 和恢复步骤。已有域名只列域名,不附加账号身份。
消息不会包含管理员密码、用户密码或备份密码。数据库只保存登录密码哈希,不能反向读取明文;恢复后账号继续使用原登录密码。备份密码与加密附件也不应保存在同一个 Telegram 会话中。
## 原服务器失联后的恢复
准备一台新的 Debian 或 Ubuntu 服务器。先把 Telegram 中的加密附件原样上传到新服务器的 `/root/` 目录,请不要解压、修改或固定填写某个示例文件名。备份日期和版本号每次可能不同。
确认文件已经上传后,首次执行官方脚本:
```bash
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
```
脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。
在“尚未安装”菜单选择:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:尚未安装
--------------------------------------------------
1. 一键安装 NewSzxcn Email
2. 备份恢复
3. 退出
==================================================
```
进入备份恢复菜单后,输入 `1` 选择“本地上传”。脚本会自动扫描 `/root/newszxcn-backup-*`:只有一份时直接选中;多份时按日期从新到旧显示为 `1、2、3` 等序号,输入对应序号,例如输入 `1` 恢复第 1 份。没有找到时才要求手动输入完整路径。选定后输入备份密码,脚本会检查压缩包路径、SQLite 完整性和必要目录,再启动服务。
恢复完成后:
1. 如果新服务器 IP 改变,更新邮件主机的 A/AAAA、邮件域名的 MX/SPF,以及服务商处的 PTR 记录。
2. 检查 DKIM 和 DMARCDKIM 私钥已随备份恢复,但 DNS 仍应核对。
3. 检查 TLS 证书是否适用于当前主机名,必要时重新签发。
4. 登录网页并测试收信、发信、IMAP、POP3 和 SMTP Submission。
5. 打开“备份与恢复”,重新测试 Telegram 推送。
## 备份内容
完整备份包括 SQLite 数据库、附件、Maildir 原始邮件、DKIM 私钥、TLS 证书、`.env`、Compose 配置、版本清单和 SHA-256 校验文件。备份使用 Zstandard 压缩,并以 AES-256-CBC、PBKDF2 200000 次迭代和 SHA-256 加密。
Telegram 适合保存体积较小的应急副本,不应作为唯一备份位置。超过 Telegram 发送上限的文件请从后台下载,并保存到 Google 云端硬盘、另一台服务器、对象存储或离线磁盘。
## Google 云端硬盘
后台“备份与恢复”支持将同一份加密备份保存到 Google 云端硬盘。系统只申请 `drive.file` 权限,只能管理由 NewSzxcn 创建的文件,不会读取云端硬盘中的其他文件。
1. 在 Google Cloud Console 创建项目并启用 Google Drive API。
2. 配置 OAuth 同意屏幕,再创建“Web 应用”类型的 OAuth 客户端。
3. 授权重定向 URI 填写 `https://你的邮局域名/api/admin/backups/google-drive/callback`,必须与后台系统设置中的公开访问地址一致。
4. 在后台填写 OAuth 客户端 ID、客户端密钥和云端文件夹名称,先保存或直接点击“连接 Google”。
5. 在 Google 授权页面确认后会自动返回“备份与恢复”,状态显示“已连接”。
6. 可开启“用于定时备份”,也可在创建备份或已有备份菜单中单独上传。
OAuth 客户端密钥和刷新令牌会使用服务器内部密钥加密保存。Google 云端硬盘中只保存 `.tar.zst.enc` 加密备份,备份密码仍应单独保管。
+4 -4
View File
@@ -1,4 +1,4 @@
# NewSzxcn 邮箱指南
# NewSzxcn 邮箱后台配置指南
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
@@ -7,10 +7,10 @@
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
```
安装脚本会依次询问防火墙配置邮件服务器域名邮箱地址域名、管理员邮箱和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装脚本会依次询问防火墙配置邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
@@ -170,4 +170,4 @@ sudo newszxcn-email reset-2fa
- [项目说明](../README.md)
- [Docker 部署说明](../deploy/README.md)
- [API 文档](API.md)
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
- [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases)
+171
View File
@@ -41,6 +41,13 @@
| NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 |
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
## NSX-20260804-001
@@ -252,3 +259,167 @@
| 2026-08-06 | 用户确认继续修改,并明确保留现有机器人 Token。 |
| 2026-08-06 | 完成实现和自动化回归,状态流转为待验收。 |
| 2026-08-06 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |
## NSX-20260806-007
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-007 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 前端/UI;后端/通知;部署运维/CI;质量复核 |
| 现象 | 邮箱选择器展开层超过侧栏边框;含日期年份的验证码邮件未显示复制按钮;邮件长链接难以阅读;Release 通知底部按钮需改为正文文字链接。 |
| 根因 | 展开层固定为 21rem,未跟随触发按钮;年份与真实验证码同时进入评分后触发歧义保护;正文仅转义未生成显式链接;Release 工作流使用 inline keyboard。 |
| 实现 | 展开层宽度跟随触发按钮;排除年份和紧凑日期候选;正文 URL 安全转义并生成链接,长追踪地址缩短显示;Release 移除按钮并在正文末尾加入“查看本次更新”链接。 |
| 目标版本 | v1.2.18 |
| 测试结果 | Gate 转发邮件验证码与链接专项测试、Telegram 全部竞态测试、Go 全量测试和 vet、前端 check/build、工作流 YAML、差异格式检查均通过;桌面端“全部邮箱”和具体邮箱状态下触发按钮与下拉层均为 263px,左右边界一致且无控制台错误。 |
| 发布状态 | 随 v1.2.18 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户提供三张截图并确认本批修改范围,问题进入处理中。 |
| 2026-08-06 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 |
| 2026-08-06 | v1.2.18 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-008
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-008 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 |
| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 |
| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 |
| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 |
| 目标版本 | v1.2.19 |
| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-009
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-009 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 部署运维/安装;文档;质量复核 |
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
| 实现 | 未安装服务器只显示一键安装和退出;已安装服务器按安装维护、服务管理、证书恢复、账号帮助、危险操作分组,动态显示运行状态、镜像版本和访问地址;安装残缺时默认进入修复;新增 `repair` 命令;服务命令统一校验 `.env` 与 Compose 文件;日志、状态、回滚和卸载的 Docker 检查收回各自函数;修复成功文案和缺失版本标签显示已纠正;README 加入两套主菜单示例。 |
| 兼容性 | 保持既有菜单编号 `112``ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-010
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-010 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
## NSX-20260807-011
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-011 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 后端/通知;验证码识别;质量复核 |
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02``newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-012
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-012 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 前端/UI;域名管理;质量复核 |
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local``v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-013
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-013 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
+485 -79
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPOSITORY="zxyszx/NewSzxcn-Email"
RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
DEFAULT_RAW_BASE="https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main"
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
RAW_BASE="${LANQIN_RAW_BASE:-}"
if [[ -z "${RAW_BASE}" && -s "${SOURCE_URL_FILE}" ]]; then
RAW_BASE="$(tr -d '\r\n' < "${SOURCE_URL_FILE}")"
fi
RAW_BASE="${RAW_BASE:-${DEFAULT_RAW_BASE}}"
COMMAND="${1:-menu}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
@@ -28,13 +33,15 @@ NewSzxcn Email 管理命令
menu 显示安装与运维菜单
install 首次安装;已有安装会先完整备份再重新安装
restore 从完整备份目录或压缩包恢复到新服务器
update 备份数据库并更新到最新版
repair 检查并修复现有安装
status 查看容器与健康状态
logs 持续查看运行日志
restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次更新前版本
guide 显示并更新 NewSzxcn 邮箱指南
guide 显示并更新邮箱后台配置指南
credentials 查看管理员登录信息和记录密码
reset-password 重置管理员统一登录密码(含名下邮箱)
reset-2fa 应急关闭唯一管理员双因素认证
@@ -133,6 +140,8 @@ apply_staged_assets() {
install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
printf '%s\n' "${RAW_BASE}" > "${SOURCE_URL_FILE}" || return 1
chmod 0644 "${SOURCE_URL_FILE}" || return 1
rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
}
@@ -160,6 +169,25 @@ ensure_cli_alias() {
success "快捷命令已创建:输入 ns 可打开管理菜单。"
}
ensure_cli_command() {
local source_dir tmp
[[ -x "${CLI_PATH}" ]] && return 0
install -d -m 0755 "$(dirname "${CLI_PATH}")"
source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* && -f "${source_dir}/install.sh" ]]; then
install -m 0755 "${source_dir}/install.sh" "${CLI_PATH}"
else
tmp="$(mktemp)"
if ! curl -fsSL "${RAW_BASE}/install.sh" -o "${tmp}" || ! bash -n "${tmp}"; then
rm -f "${tmp}"
warn "未能安装管理命令;完成安装后可重新运行官方脚本修复。"
return 0
fi
install -m 0755 "${tmp}" "${CLI_PATH}"
rm -f "${tmp}"
fi
}
refresh_assets() {
stage_assets
apply_staged_assets
@@ -202,6 +230,18 @@ env_value() {
sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1
}
installation_configured() {
[[ -f "${INSTALL_DIR}/.env" ]]
}
installation_complete() {
installation_configured && [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]
}
require_installation() {
installation_complete || fail "尚未完成安装,请先运行 newszxcn-email install;如果配置残缺,请运行 newszxcn-email repair。"
}
prompt_value() {
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
local value="${!variable:-}"
@@ -318,8 +358,8 @@ prompt_mail_domain() {
suggestion="$(suggest_mail_domain "${hostname}")"
value="${LANQIN_MAIL_DOMAIN:-}"
if [[ -z "${value}" ]] && has_tty; then
prompt_text "[提示] 邮件服务器域名${hostname}邮箱地址域名可以使用 ${suggestion},请确认。\n"
read -r -p "邮箱地址域名 [${suggestion}]: " value </dev/tty
prompt_text "[检测] 邮件服务器域名${hostname}\n[检测] 邮箱地址域名@${suggestion}\n"
read -r -p "邮箱地址域名 [${suggestion}](直接回车确认): " value </dev/tty
fi
value="${value:-${suggestion}}"
if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -z "${admin_email}" ]] && ! has_tty; then
@@ -342,10 +382,10 @@ prompt_admin_email() {
return
fi
if has_tty; then
prompt_text "\n创建管理员邮箱 [1]:\n1. 默认 admin,自动创建 admin@${mail_domain}\n2. 自定义前缀\n"
prompt_text "\n检测到邮箱地址域名:@${mail_domain}\n创建管理员邮箱 [1]:\n1. 使用默认前缀 admin\n2. 自定义管理员邮箱前缀\n"
choice="$(prompt_choice LANQIN_ADMIN_EMAIL_MODE "请选择 [1]: " "1" "2")"
if [[ "${choice}" == "2" ]]; then
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱前缀" "admin")"
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱账号前缀" "admin")"
else
prefix="admin"
fi
@@ -353,7 +393,9 @@ prompt_admin_email() {
prefix="${LANQIN_ADMIN_PREFIX:-admin}"
fi
valid_mail_local_part "${prefix}" || fail "管理员邮箱前缀格式不正确。"
printf '%s@%s' "$(lowercase "${prefix}")" "${mail_domain}"
email="$(lowercase "${prefix}")@${mail_domain}"
prompt_text "[提示] 将创建管理员邮箱:${email}\n"
printf '%s' "${email}"
}
ensure_admin_email_config() {
@@ -974,42 +1016,67 @@ restore_update_snapshot() {
}
do_repair_install() {
installation_configured || fail "尚未安装,无法执行修复。"
local snapshot_created="false"
ensure_docker
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
snapshot_created="true"
else
warn "安装缺少 docker-compose.yml,将保留现有配置和数据并重新生成运行文件。"
fi
stage_assets
clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
fi
fail "修复准备失败,原配置和数据未删除。"
fi
if ! (configure_firewall && prepare_directories); then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
fi
fail "修复环境准备失败,原配置和数据未删除。"
fi
log "正在拉取并修复 NewSzxcn Email 服务..."
if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
fail "修复镜像拉取失败,原配置和数据未删除。"
fi
log "正在启动服务..."
if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后容器启动失败,请查看实时日志;原配置和数据未删除。"
fi
if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后健康检查失败,请查看实时日志;原配置和数据未删除。"
fi
if ! (configure_web_mode); then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
fi
fail "Web 配置修复失败,原配置和数据未删除。"
fi
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
generate_guide >/dev/null || warn "修复成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "修复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
}
do_install() {
@@ -1031,14 +1098,285 @@ do_install() {
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "安装成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
}
validate_restore_source() {
local source="$1"
[[ -f "${source}/.env" ]] || { warn "备份缺少 .env。"; return 1; }
[[ -f "${source}/docker-compose.yml" ]] || { warn "备份缺少 docker-compose.yml。"; return 1; }
[[ -s "${source}/data/lanqin.db" ]] || { warn "备份缺少数据库 data/lanqin.db。"; return 1; }
[[ -d "${source}/mail" ]] || { warn "备份缺少 mail 邮件目录。"; return 1; }
[[ -d "${source}/dkim" ]] || { warn "备份缺少 dkim 密钥目录。"; return 1; }
[[ -d "${source}/certs" ]] || { warn "备份缺少 certs 证书目录。"; return 1; }
}
validate_restore_database() {
local database="$1" result
if ! command -v sqlite3 >/dev/null 2>&1; then
log "正在安装 SQLite 校验工具..."
install_packages sqlite3
fi
result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
[[ "${result}" == "ok" ]] || { warn "备份数据库完整性检查未通过。"; return 1; }
}
locate_extracted_restore_root() {
local root="$1" candidate
if validate_restore_source "${root}" >/dev/null 2>&1; then
printf '%s' "${root}"
return 0
fi
candidate="$(find "${root}" -mindepth 1 -maxdepth 2 -type f -name .env -print -quit 2>/dev/null || true)"
[[ -n "${candidate}" ]] || return 1
candidate="$(dirname "${candidate}")"
validate_restore_source "${candidate}" >/dev/null 2>&1 || return 1
printf '%s' "${candidate}"
}
render_restore_menu() {
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 备份恢复\n'
prompt_text '==================================================\n'
prompt_text '1. 本地上传\n'
prompt_text '2. 返回上一级\n'
prompt_text '==================================================\n'
prompt_text '请先将原始加密备份上传到新服务器的 /root/ 目录,不要解压。\n'
prompt_text '系统会自动检测 /root/ 目录中的 NewSzxcn 备份文件。\n'
}
do_restore_menu() {
local choice
render_restore_menu
choice="$(prompt_menu_choice "1" "2")" || return 1
case "${choice}" in
1) do_restore_backup ;;
2) success "已返回,未作任何修改。" ;;
esac
}
prompt_restore_password() {
local password="${LANQIN_RESTORE_PASSWORD:-}"
if [[ -z "${password}" ]] && has_tty; then
read -r -s -p "备份密码: " password </dev/tty
printf '\n' >/dev/tty
fi
[[ -n "${password}" ]] || fail "加密备份必须提供备份密码。"
(( ${#password} >= 8 && ${#password} <= 1024 )) || fail "备份密码必须为 8 至 1024 个字符。"
[[ "${password}" != *$'\n'* && "${password}" != *$'\r'* ]] || fail "备份密码不能包含换行。"
printf '%s' "${password}"
}
discover_restore_backups() {
local search_dir="${LANQIN_RESTORE_SEARCH_DIR:-/root}" path
local -a matches=()
[[ -d "${search_dir}" ]] || return 0
while IFS= read -r path; do
case "${path}" in
*.tar.zst.enc|*.tar.zst|*.tar.gz|*.tgz|*.tar) matches+=("${path}") ;;
esac
done < <(find "${search_dir}" -maxdepth 1 -type f -name 'newszxcn-backup-*' -print 2>/dev/null | LC_ALL=C sort -r)
(( ${#matches[@]} > 0 )) || return 0
printf '%s\n' "${matches[@]}"
}
select_restore_source() {
local selection="${LANQIN_RESTORE_SELECTION:-}" path index
local -a backups=()
while IFS= read -r path; do
[[ -n "${path}" ]] && backups+=("${path}")
done < <(discover_restore_backups)
if (( ${#backups[@]} == 1 )); then
prompt_text "[检测] 已找到备份:${backups[0]}\n"
printf '%s' "${backups[0]}"
return 0
fi
if (( ${#backups[@]} > 1 )); then
prompt_text "[检测] 在 /root/ 找到 ${#backups[@]} 份备份:\n"
for index in "${!backups[@]}"; do
prompt_text "$((index + 1)). ${backups[index]}\n"
done
prompt_text "$(( ${#backups[@]} + 1 )). 手动输入其他路径\n"
if [[ -z "${selection}" ]] && has_tty; then
read -r -p "请输入要恢复的备份序号 [1]: " selection </dev/tty
fi
selection="${selection:-1}"
if [[ "${selection}" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#backups[@]} )); then
prompt_text "[选择] 将使用第 ${selection} 份备份开始恢复。\n"
printf '%s' "${backups[selection-1]}"
return 0
fi
[[ "${selection}" == "$(( ${#backups[@]} + 1 ))" ]] || fail "备份序号无效。"
else
prompt_text "[提示] /root/ 目录没有检测到 NewSzxcn 备份,请手动输入路径。\n"
fi
if has_tty; then
read -r -p "备份文件完整路径: " path </dev/tty
else
path="${LANQIN_RESTORE_SOURCE:-}"
fi
printf '%s' "${path}"
}
archive_has_unsafe_paths() {
awk '
BEGIN { bad=0 }
{
if (substr($0, 1, 1) == "/") bad=1
count=split($0, parts, "/")
for (i=1; i<=count; i++) if (parts[i] == "..") bad=1
}
END { exit bad ? 0 : 1 }
'
}
archive_has_unsafe_types() {
awk '
BEGIN { bad=0 }
/^[[:space:]]*$/ { next }
{
type=substr($0, 1, 1)
if (type != "-" && type != "d") bad=1
}
END { exit bad ? 0 : 1 }
'
}
extract_restore_archive() {
local source="$1" destination="$2" password decrypted
case "${source}" in
*.tar.zst.enc)
command -v openssl >/dev/null 2>&1 || install_packages openssl
command -v zstd >/dev/null 2>&1 || install_packages zstd
password="$(prompt_restore_password)"
decrypted="${destination}/backup.tar.zst"
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -in "${source}" -out "${decrypted}" -pass fd:3 3<<<"${password}" 2>/dev/null; then
fail "备份密码错误或加密备份已损坏。"
fi
if zstd -dc "${decrypted}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if zstd -dc "${decrypted}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
zstd -dc "${decrypted}" 2>/dev/null | tar -xf - -C "${destination}" \
|| fail "加密备份无法解压,请检查文件和密码。"
rm -f "${decrypted}"
;;
*.tar.zst)
command -v zstd >/dev/null 2>&1 || install_packages zstd
if zstd -dc "${source}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if zstd -dc "${source}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
zstd -dc "${source}" 2>/dev/null | tar -xf - -C "${destination}" \
|| fail "Zstandard 备份无法解压。"
;;
*.tar|*.tar.gz|*.tgz)
if tar -tf "${source}" | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if tar -tvf "${source}" | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
tar -xf "${source}" -C "${destination}" || fail "备份压缩包无法解压。"
;;
*)
fail "不支持的备份格式;请选择 .tar.zst.enc、.tar.zst、.tar.gz、.tgz 或 .tar。"
;;
esac
}
do_restore_backup() {
local source="${LANQIN_RESTORE_SOURCE:-}" extracted="" restore_root staging image_ref image nginx_backup=""
! installation_configured || fail "当前服务器已经存在安装配置;为防止覆盖运行数据,只能在空白新服务器执行完整恢复。"
[[ -n "${source}" ]] || source="$(select_restore_source)"
[[ -n "${source}" ]] || fail "请提供备份目录或备份压缩包路径。"
source="$(readlink -f "${source}" 2>/dev/null || true)"
[[ -e "${source}" ]] || fail "备份不存在:${source}"
if [[ -d "${source}" ]]; then
restore_root="${source}"
else
command -v tar >/dev/null 2>&1 || install_packages tar
extracted="$(mktemp -d)"
extract_restore_archive "${source}" "${extracted}"
restore_root="$(locate_extracted_restore_root "${extracted}" || true)"
fi
if [[ -z "${restore_root}" ]] || ! validate_restore_source "${restore_root}"; then
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
fail "这不是可恢复的 NewSzxcn 完整备份。"
fi
if ! validate_restore_database "${restore_root}/data/lanqin.db"; then
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
fail "备份数据库已损坏,未写入任何 NewSzxcn 数据。"
fi
staging="${INSTALL_DIR}.restore-staging-$(date -u +%Y%m%dT%H%M%SZ)"
[[ ! -e "${INSTALL_DIR}" || -z "$(find "${INSTALL_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] \
|| fail "${INSTALL_DIR} 已有文件,已取消恢复以免覆盖数据。"
rm -rf "${staging}"
install -d -m 0700 "${staging}"
cp -a "${restore_root}/." "${staging}/"
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
rm -rf "${INSTALL_DIR}"
mv "${staging}" "${INSTALL_DIR}"
chmod 0600 "${INSTALL_DIR}/.env"
if [[ -f "${NGINX_CONFIG}" ]]; then
nginx_backup="$(mktemp)"
cp -a "${NGINX_CONFIG}" "${nginx_backup}"
fi
if ! (
refresh_assets
ensure_update_token
ensure_admin_email_config
configure_runtime_bindings
ensure_docker
configure_firewall
prepare_directories
log "正在拉取恢复所需的 NewSzxcn Email 镜像..."
compose pull
image_ref="$(env_value LANQIN_IMAGE || true)"
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
image="$(docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null || true)"
[[ -n "${image}" ]] || fail "无法检查恢复数据库:镜像不存在。"
sqlite_integrity_check "${INSTALL_DIR}/data/lanqin.db" "${image}" || fail "备份数据库完整性检查未通过,服务未启动。"
log "备份检查通过,正在启动服务..."
compose up -d --remove-orphans
wait_for_health 90 || fail "恢复后的服务未通过健康检查,请执行 newszxcn-email logs。"
configure_web_mode
); then
warn "恢复未完成,正在清理本次未成功的安装。"
compose down --remove-orphans >/dev/null 2>&1 || true
rm -rf "${INSTALL_DIR}"
if [[ -n "${nginx_backup}" && -f "${nginx_backup}" ]]; then
cp -a "${nginx_backup}" "${NGINX_CONFIG}"
elif [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}"
fi
rm -f "${nginx_backup}"
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx >/dev/null 2>&1 || true
fi
fail "恢复失败,原始备份文件未修改;修复问题后可重新执行备份恢复。"
fi
rm -f "${nginx_backup}"
ensure_cli_alias
generate_guide >/dev/null || warn "数据已恢复,但配置指南生成失败,可稍后执行 newszxcn-email guide。"
success "备份恢复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "如果服务器 IP 已更换,请更新 A、MX、SPF、PTR,并重新检查 TLS 证书。"
}
do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
require_installation
ensure_docker
create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
stage_assets
@@ -1063,11 +1401,12 @@ do_update() {
fail "更新失败,已恢复到更新前版本。"
fi
ensure_cli_alias
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "更新成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
require_installation
[[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
if [[ -z "${confirm}" ]] && has_tty; then
@@ -1085,7 +1424,7 @@ do_rollback() {
}
reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
require_installation
ensure_docker
compose restart lanqin-email >/dev/null
if [[ -f "${NGINX_CONFIG}" ]]; then
@@ -1100,14 +1439,14 @@ do_restart() {
}
do_certificate() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
reload_services
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "证书已应用,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。"
}
@@ -1140,7 +1479,7 @@ generate_guide() {
tmp="$(mktemp)"
cat > "${tmp}" <<EOF
==================================================
NewSzxcn 邮箱指南
NewSzxcn 邮箱后台配置指南
==================================================
【安装信息】
@@ -1192,14 +1531,14 @@ SSL 证书:有效期至 ${certificate_expiry}
重置管理员密码:newszxcn-email reset-password
公开教程:
https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md
https://gitea.xzys.me/szx/NewSzxcn-Email/src/branch/main/docs/GUIDE.md
EOF
install -m 0600 "${tmp}" "${GUIDE_FILE}"
rm -f "${tmp}"
}
do_guide() {
generate_guide || fail "尚未安装,无法生成邮箱指南。"
generate_guide || fail "尚未安装,无法生成邮箱后台配置指南。"
cat "${GUIDE_FILE}"
success "指南已更新并保存到 ${GUIDE_FILE}"
}
@@ -1233,7 +1572,7 @@ generate_admin_password_hash() {
}
do_reset_admin_password() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
ensure_docker
ensure_admin_email_config
@@ -1277,7 +1616,7 @@ do_reset_admin_password() {
}
do_reset_admin_two_factor() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email user_id image timestamp backup result user_changes recovery_changes challenge_changes
ensure_docker
ensure_admin_email_config
@@ -1306,7 +1645,8 @@ do_reset_admin_two_factor() {
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
compose ps
if wait_for_health 1; then
success "Web 与 API 健康检查正常。"
@@ -1315,8 +1655,15 @@ do_status() {
fi
}
do_logs() {
require_installation
ensure_docker
compose logs -f --tail=200 lanqin-email updater
}
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
@@ -1415,58 +1762,114 @@ do_backup_reinstall() {
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}"
}
do_menu() {
local installed="false" default_choice="1" public_url="" choice
if [[ -f "${INSTALL_DIR}/.env" ]]; then
installed="true"
default_choice="2"
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
menu_service_status() {
local container_id
if ! installation_complete; then
printf '安装不完整'
return
fi
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 一键安装与管理\n'
prompt_text '==================================================\n'
if [[ "${installed}" == "true" ]]; then
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
printf '状态未知'
return
fi
container_id="$(compose ps -q lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
printf '运行中'
else
prompt_text ' 状态:未安装\n'
printf '已停止'
fi
prompt_text '--------------------------------------------------\n'
prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复)\n'
prompt_text ' 2. 更新系统(数据库备份,失败自动回滚)\n'
prompt_text ' 3. 检查并修复现有安装\n'
prompt_text ' 4. 查看运行状态\n'
prompt_text ' 5. 重启服务\n'
prompt_text ' 6. 查看实时日志\n'
prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
prompt_text ' 8. 回滚到上次更新前版本\n'
prompt_text ' 9. NewSzxcn 邮箱指南\n'
prompt_text ' 10. 查看管理员登录信息\n'
prompt_text ' 11. 重置管理员统一登录密码\n'
prompt_text ' 12. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n'
}
menu_installed_version() {
local image version
if ! installation_complete || ! command -v docker >/dev/null 2>&1; then
printf '未知'
return
fi
image="$(current_image_id 2>/dev/null || true)"
if [[ -n "${image}" ]]; then
version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
fi
[[ "${version:-}" == "<no value>" ]] && version=""
printf '%s' "${version:-未知}"
}
render_uninstalled_menu() {
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 管理面板\n'
prompt_text '==================================================\n'
prompt_text '状态:尚未安装\n'
prompt_text '--------------------------------------------------\n'
prompt_text '1. 一键安装 NewSzxcn Email\n'
prompt_text '2. 备份恢复\n'
prompt_text '3. 退出\n'
prompt_text '==================================================\n'
}
choice="$(prompt_menu_choice "${default_choice}" "12")"
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。"
render_installed_menu() {
local status="$1" version="$2" public_url="$3"
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 管理面板\n'
prompt_text '==================================================\n'
prompt_text "状态:${status}\n"
prompt_text "版本:${version}\n"
prompt_text "地址:${public_url:-未配置}\n"
prompt_text '--------------------------------------------------\n'
prompt_text '安装与维护\n'
prompt_text '1. 重新安装(完整备份,失败自动恢复)\n'
prompt_text '2. 更新系统(自动备份,失败自动回滚)\n'
prompt_text '3. 检查并修复现有安装\n\n'
prompt_text '服务管理\n'
prompt_text '4. 查看运行状态\n'
prompt_text '5. 重启服务\n'
prompt_text '6. 查看实时日志\n\n'
prompt_text '证书与恢复\n'
prompt_text '7. 管理 SSL 证书\n'
prompt_text '8. 回滚到上次更新前版本\n\n'
prompt_text '账号与帮助\n'
prompt_text '9. 邮箱后台配置指南\n'
prompt_text '10. 查看管理员登录信息\n'
prompt_text '11. 重置管理员登录密码\n\n'
prompt_text '危险操作\n'
prompt_text '12. 卸载服务(保留数据)\n\n'
prompt_text '0. 退出\n'
prompt_text '==================================================\n'
}
do_menu() {
local default_choice="2" public_url="" choice status version
if ! installation_configured; then
render_uninstalled_menu
choice="$(prompt_menu_choice "1" "3")" || return 1
case "${choice}" in
3) success "已退出,未作任何修改。" ;;
1) do_install ;;
2) do_restore_menu ;;
esac
return
fi
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
status="$(menu_service_status)"
version="$(menu_installed_version)"
[[ "${status}" == "安装不完整" ]] && default_choice="3"
render_installed_menu "${status}" "${version}" "${public_url}"
choice="$(prompt_menu_choice "${default_choice}" "12")" || return 1
case "${choice}" in
0) success "已退出,未作任何修改。" ;;
1) do_install ;;
2) do_update ;;
3) do_repair_install ;;
4) ensure_docker; do_status ;;
4) do_status ;;
5) do_restart ;;
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
6) do_logs ;;
7) do_certificate ;;
8) ensure_docker; do_rollback ;;
8) do_rollback ;;
9) do_guide ;;
10) do_show_admin_credentials ;;
11) do_reset_admin_password ;;
12) ensure_docker; do_uninstall ;;
12) do_uninstall ;;
esac
}
@@ -1478,6 +1881,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
fi
if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_command
ensure_cli_alias
fi
@@ -1485,17 +1889,19 @@ case "${COMMAND}" in
help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;;
install) require_root; require_curl; do_install ;;
restore) require_root; require_curl; do_restore_menu ;;
update) require_root; require_curl; do_update ;;
status) require_root; require_curl; ensure_docker; do_status ;;
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
repair) require_root; require_curl; do_repair_install ;;
status) require_root; require_curl; do_status ;;
logs) require_root; require_curl; do_logs ;;
restart) require_root; require_curl; do_restart ;;
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
rollback) require_root; require_curl; do_rollback ;;
guide) require_root; require_curl; do_guide ;;
credentials) require_root; require_curl; do_show_admin_credentials ;;
reset-password) require_root; require_curl; do_reset_admin_password ;;
reset-2fa) require_root; require_curl; do_reset_admin_two_factor ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
uninstall) require_root; require_curl; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
+5 -5
View File
@@ -81,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.12
version: 3.4.12
specifier: 3.4.13
version: 3.4.13
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -1022,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -2447,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.12:
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TEMP_DIR}"' EXIT
fail_test() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
mkdir -p "${TEMP_DIR}/bin" "${TEMP_DIR}/keys"
touch "${TEMP_DIR}/lanqin.db" "${TEMP_DIR}/reload.log"
cat > "${TEMP_DIR}/bin/sqlite3" <<'EOF'
#!/bin/sh
printf 'example.com|lanqin|%s\n' "$(cat "${FAKE_PRIVATE_KEY_FILE}")"
EOF
cat > "${TEMP_DIR}/bin/id" <<'EOF'
#!/bin/sh
exit 1
EOF
cat > "${TEMP_DIR}/bin/rspamadm" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "${FAKE_RELOAD_LOG}"
EOF
cat > "${TEMP_DIR}/bin/pkill" <<'EOF'
#!/bin/sh
printf 'unexpected pkill fallback\n' >&2
exit 1
EOF
chmod 0755 "${TEMP_DIR}/bin/sqlite3" "${TEMP_DIR}/bin/id" "${TEMP_DIR}/bin/rspamadm" "${TEMP_DIR}/bin/pkill"
export PATH="${TEMP_DIR}/bin:${PATH}"
export LANQIN_DB_PATH="${TEMP_DIR}/lanqin.db"
export LANQIN_RSPAMD_DKIM_DIR="${TEMP_DIR}/keys"
export FAKE_PRIVATE_KEY_FILE="${TEMP_DIR}/private-key.b64"
export FAKE_RELOAD_LOG="${TEMP_DIR}/reload.log"
printf 'first-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "first-private-key" ]] || fail_test "initial DKIM key was not exported"
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "initial DKIM key did not reload Rspamd"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "unchanged DKIM key reloaded Rspamd"
printf 'second-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "second-private-key" ]] || fail_test "changed DKIM key was not exported"
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "2" ]] || fail_test "changed DKIM key did not reload Rspamd"
printf 'DKIM sync tests passed.\n'
+296
View File
@@ -46,6 +46,10 @@ test_password_validation() {
test_mail_domain_and_admin_email_validation() {
assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion"
assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion"
assert_eq "newszxcn.com" "$(suggest_mail_domain "mail.newszxcn.com")" "NewSzxcn mail host domain suggestion"
assert_eq "admin@newszxcn.com" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.com")" "NewSzxcn default administrator email"
assert_eq "newszxcn.cm" "$(suggest_mail_domain "mail.newszxcn.cm")" "two-label suffix mail host domain suggestion"
assert_eq "admin@newszxcn.cm" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.cm")" "matching administrator email for entered domain"
LANQIN_MAIL_DOMAIN="example.com"
LANQIN_ADMIN_EMAIL="admin@example.com"
assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain"
@@ -122,6 +126,24 @@ test_compose_configuration() {
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
}
test_source_url_persistence() (
local temp_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
CLI_PATH="${temp_dir}/newszxcn-email"
RAW_BASE="https://gitea.example.test/szx/NewSzxcn-Email/raw/branch/main"
mkdir -p "${INSTALL_DIR}"
printf 'services: {}\n' > "${INSTALL_DIR}/.docker-compose.yml.new"
printf 'LANQIN_IMAGE=test\n' > "${INSTALL_DIR}/.env.example.new"
printf '#!/usr/bin/env bash\nexit 0\n' > "${INSTALL_DIR}/.install.sh.new"
apply_staged_assets
assert_eq "${RAW_BASE}" "$(cat "${SOURCE_URL_FILE}")" "persisted installer source URL"
[[ -x "${CLI_PATH}" ]] || fail_test "installed CLI is not executable"
)
test_legacy_configuration_is_preserved() {
local temp_dir
temp_dir="$(mktemp -d)"
@@ -147,6 +169,128 @@ test_menu_choice() {
unset LANQIN_MENU_ACTION
}
test_menu_rendering() (
local output
prompt_text() { printf '%b' "$1"; }
output="$(render_uninstalled_menu)"
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
[[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing"
[[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing"
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
output="$(render_installed_menu "运行中" "v1.2.19" "https://mail.example.com")"
for expected in \
'状态:运行中' \
'版本:v1.2.19' \
'地址:https://mail.example.com' \
'安装与维护' \
'服务管理' \
'证书与恢复' \
'账号与帮助' \
'危险操作' \
'9. 邮箱后台配置指南' \
'12. 卸载服务(保留数据)'; do
[[ "${output}" == *"${expected}"* ]] || fail_test "installed menu item missing: ${expected}"
done
)
test_menu_dispatch() (
local temp_dir action_file LANQIN_MENU_ACTION=1
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
action_file="${temp_dir}/action"
mkdir -p "${INSTALL_DIR}"
prompt_text() { :; }
do_install() { printf 'install\n' > "${action_file}"; }
do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; }
do_menu
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
LANQIN_MENU_ACTION=2
do_menu
grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore"
LANQIN_MENU_ACTION=3
do_menu >/dev/null
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
menu_service_status() { printf '运行中'; }
menu_installed_version() { printf 'v1.2.19'; }
do_update() { printf 'update\n' > "${action_file}"; }
LANQIN_MENU_ACTION=2
do_menu
grep -Fq 'update' "${action_file}" || fail_test "installed menu did not dispatch update"
unset LANQIN_MENU_ACTION
)
test_menu_runtime_metadata() (
local temp_dir running="true" image_version="v1.2.19"
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
compose() {
if [[ "$*" == 'ps -q lanqin-email' ]]; then
printf 'container-id\n'
fi
}
current_image_id() { printf 'sha256:test-image\n'; }
docker() {
if [[ "$*" == 'compose version' ]]; then
return 0
fi
if [[ "$*" == *'.State.Running'* ]]; then
printf '%s\n' "${running}"
return 0
fi
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
printf '%s\n' "${image_version}"
fi
}
assert_eq "运行中" "$(menu_service_status)" "running menu service status"
assert_eq "v1.2.19" "$(menu_installed_version)" "installed menu version"
running="false"
assert_eq "已停止" "$(menu_service_status)" "stopped menu service status"
image_version="<no value>"
assert_eq "未知" "$(menu_installed_version)" "missing image version label"
)
test_incomplete_install_defaults_to_repair() (
local temp_dir action_file LANQIN_MENU_ACTION=3
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
action_file="${temp_dir}/action"
mkdir -p "${INSTALL_DIR}"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
prompt_text() { :; }
menu_installed_version() { printf '未知'; }
do_repair_install() { printf 'repair\n' > "${action_file}"; }
do_menu
grep -Fq 'repair' "${action_file}" || fail_test "incomplete installation did not dispatch repair"
unset LANQIN_MENU_ACTION
)
test_service_commands_require_complete_installation() (
local temp_dir command_name
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
# Invoked indirectly by the service command functions under test.
# shellcheck disable=SC2317,SC2329
ensure_docker() { fail_test "service command checked Docker before installation"; }
for command_name in do_update do_status do_logs do_restart do_certificate do_rollback do_reset_admin_password do_reset_admin_two_factor do_uninstall; do
if ("${command_name}" >/dev/null 2>&1); then
fail_test "${command_name} accepted missing installation"
fi
done
)
test_admin_credentials() (
local temp_dir output
temp_dir="$(mktemp -d)"
@@ -186,6 +330,7 @@ LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
LANQIN_ADMIN_PASSWORD=old-password
EOF
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
@@ -226,6 +371,7 @@ LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
EOF
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
@@ -307,6 +453,144 @@ test_cli_alias_safety() (
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
)
test_restore_source_validation() (
local temp_dir
temp_dir="$(mktemp -d)"
mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs"
printf 'config\n' > "${temp_dir}/.env"
printf 'services: {}\n' > "${temp_dir}/docker-compose.yml"
sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected"
validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected"
printf 'damaged\n' > "${temp_dir}/data/lanqin.db"
if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then
fail_test "damaged restore database accepted"
fi
rm -f "${temp_dir}/data/lanqin.db"
if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then
fail_test "restore source without database accepted"
fi
)
test_restore_menu_rendering_and_dispatch() (
local output action_file LANQIN_MENU_ACTION=1
action_file="$(mktemp)"
prompt_text() { printf '%b' "$1"; }
output="$(render_restore_menu)"
[[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing"
[[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing"
[[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing"
[[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing"
prompt_text() { :; }
do_restore_backup() { printf 'restore\n' > "${action_file}"; }
do_restore_menu
grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload"
LANQIN_MENU_ACTION=2
do_restore_menu >/dev/null
unset LANQIN_MENU_ACTION
)
test_restore_backup_discovery() (
local temp_dir output selected
temp_dir="$(mktemp -d)"
LANQIN_RESTORE_SEARCH_DIR="${temp_dir}"
touch "${temp_dir}/unrelated.tar.zst.enc"
output="$(discover_restore_backups)"
[[ -z "${output}" ]] || fail_test "unrelated archive was discovered"
touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc"
selected="$(select_restore_source)"
assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup"
touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc"
touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc"
output="$(discover_restore_backups)"
assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering"
LANQIN_RESTORE_SELECTION=2
selected="$(select_restore_source)"
assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup"
)
test_encrypted_restore_archive() (
local temp_dir source_dir archive extracted password='RestorePassword123!'
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source/newszxcn-email"
archive="${temp_dir}/newszxcn-backup.tar.zst.enc"
extracted="${temp_dir}/extracted"
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}"
printf 'config\n' > "${source_dir}/.env"
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
zstd() {
if [[ "$*" == '-q -c' ]]; then
gzip -c
elif [[ "$1" == '-dc' ]]; then
gzip -dc "$2"
else
return 1
fi
}
tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}"
validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed"
)
test_failed_full_restore_cleans_partial_install() (
local temp_dir source_dir archive password='RestorePassword123!'
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source/newszxcn-backup"
archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc"
INSTALL_DIR="${temp_dir}/install"
NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf"
CERT_DIR="${temp_dir}/certs"
LANQIN_RESTORE_SOURCE="${archive}"
LANQIN_RESTORE_PASSWORD="${password}"
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env"
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
zstd() {
if [[ "$*" == '-q -c' ]]; then
gzip -c
elif [[ "$1" == '-dc' ]]; then
gzip -dc "$2"
else
return 1
fi
}
tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
refresh_assets() { return 0; }
ensure_update_token() { return 0; }
ensure_admin_email_config() { return 0; }
configure_runtime_bindings() { return 0; }
ensure_docker() { return 0; }
configure_firewall() { return 0; }
prepare_directories() { return 0; }
compose() {
case "$1" in
pull) return 1 ;;
down) return 0 ;;
esac
return 0
}
if (do_restore_backup >/dev/null 2>&1); then
fail_test "failed full restore unexpectedly succeeded"
fi
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation"
[[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup"
)
test_restore_archive_path_validation() (
printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected"
printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted"
printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted"
printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected"
printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected"
printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted"
)
test_compose_runtime_image_pin() (
local temp_dir calls
temp_dir="$(mktemp -d)"
@@ -569,8 +853,14 @@ test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "fals
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_nginx_configuration
test_compose_configuration
test_source_url_persistence
test_legacy_configuration_is_preserved
test_menu_choice
test_menu_rendering
test_menu_dispatch
test_menu_runtime_metadata
test_incomplete_install_defaults_to_repair
test_service_commands_require_complete_installation
test_admin_credentials
test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account
@@ -579,6 +869,12 @@ test_offline_database_backup
test_guide_generation
test_acme_cron_detection
test_cli_alias_safety
test_restore_source_validation
test_restore_menu_rendering_and_dispatch
test_restore_backup_discovery
test_encrypted_restore_archive
test_failed_full_restore_cleans_partial_install
test_restore_archive_path_validation
test_compose_runtime_image_pin
test_update_snapshot_restore
test_snapshot_restores_absent_optional_files