Compare commits

...

22 Commits

Author SHA1 Message Date
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
78 changed files with 5799 additions and 872 deletions
+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 -4
View File
@@ -289,18 +289,20 @@ jobs:
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} {html.escape(title)}</b>"
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"
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>'
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
+2
View File
@@ -231,6 +231,8 @@ pnpm install
pnpm run dev
```
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
提交前建议运行:
```bash
+2
View File
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
## 更新与回滚
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
### 后台页面更新
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
+1 -1
View File
@@ -1 +1 @@
1.2.15
1.2.39
+148 -56
View File
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.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 +64,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 +101,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 +143,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 +168,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 +214,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 +288,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 +344,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 +442,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 +583,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 +594,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 +610,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 +630,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 +645,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 +659,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 +681,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 +726,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 +738,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 +770,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 +794,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 +1171,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 +1244,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
+80 -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
}
@@ -1331,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 {
@@ -1509,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)
@@ -1531,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
}
@@ -1652,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
}
@@ -1867,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"
@@ -1901,6 +1963,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
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")
+466 -14
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)
}
@@ -1050,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())
@@ -1389,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"`
}
@@ -2293,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 {
@@ -2302,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 {
@@ -2613,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)
@@ -2756,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")
@@ -2766,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)
}
}
@@ -2952,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"`
}
@@ -2982,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)
@@ -4480,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)
@@ -4517,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 {
@@ -4927,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)
@@ -6089,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{
@@ -6165,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,448 @@
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)
}
}
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")),
}
}
+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 {
+101 -15
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) {
@@ -2232,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
@@ -2535,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
+73 -22
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
+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
+51 -46
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,9 +904,8 @@ 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 {
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
}
@@ -906,17 +913,6 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
@@ -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 {
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 {
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 {
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)
+43 -5
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
}
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 {
+46 -1
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 {
+3
View File
@@ -23,6 +23,7 @@ type AdminUser struct {
User
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>
</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</main>
)
}
+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>
+3 -6
View File
@@ -1,11 +1,10 @@
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, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
@@ -36,6 +35,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 +57,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
if (isMailRoute || isProfileRoute) {
return <Outlet />
@@ -110,9 +110,6 @@ 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>
+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 -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}
+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("请求超时")
}
+78 -22
View File
@@ -5,34 +5,38 @@
@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%;
--compose-send: 217 89% 43%;
--compose-send-hover: 216 94% 32%;
}
* { @apply border-border; }
@@ -61,10 +65,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 +86,22 @@
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--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);
@@ -140,12 +158,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);
}
}
+9 -3
View File
@@ -52,12 +52,12 @@ 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 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 }
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 +202,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
+10 -4
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,12 @@ 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"
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,9 +36,12 @@ 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">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -82,6 +88,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
+5 -3
View File
@@ -13,6 +13,7 @@ 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"
export function RegisterPage() {
const me = useMe()
@@ -57,8 +58,10 @@ 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">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -103,7 +106,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 +132,6 @@ export function RegisterPage() {
</div>
)}
</div>
</div>
</main>
)
}
+29 -4
View File
@@ -1,8 +1,12 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { defineConfig, loadEnv } from "vite"
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
return {
plugins: [react()],
resolve: {
alias: {
@@ -12,8 +16,29 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/healthz": "http://localhost:8080",
"/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,
},
],
},
},
},
},
}
})
+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"
+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://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/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` 加密备份,备份密码仍应单独保管。
+26 -19
View File
@@ -42,12 +42,12 @@
| 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 | 发布 |
| NSX-20260807-009 | 2026-08-07 | 待验收 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 发布 |
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 待发布 |
| NSX-20260807-011 | 2026-08-07 | 待验收 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 发布 |
| NSX-20260807-012 | 2026-08-07 | 待验收 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 发布 |
| NSX-20260807-013 | 2026-08-07 | 待验收 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 发布 |
| 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
@@ -289,14 +289,14 @@
| --- | --- |
| 编号 | 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 发布。 |
### 历史
@@ -304,6 +304,7 @@
| --- | --- |
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-009
@@ -311,7 +312,7 @@
| --- | --- |
| 编号 | NSX-20260807-009 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 部署运维/安装;文档;质量复核 |
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
@@ -319,7 +320,7 @@
| 兼容性 | 保持既有菜单编号 `112``ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -327,6 +328,7 @@
| --- | --- |
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-010
@@ -334,16 +336,16 @@
| --- | --- |
| 编号 | 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、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 待发布。 |
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
### 历史
@@ -351,6 +353,8 @@
| --- | --- |
| 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
@@ -358,14 +362,14 @@
| --- | --- |
| 编号 | NSX-20260807-011 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 后端/通知;验证码识别;质量复核 |
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02``newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -373,6 +377,7 @@
| --- | --- |
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-012
@@ -380,13 +385,13 @@
| --- | --- |
| 编号 | 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 发布。 |
### 历史
@@ -394,6 +399,7 @@
| --- | --- |
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-013
@@ -401,14 +407,14 @@
| --- | --- |
| 编号 | NSX-20260807-013 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -416,3 +422,4 @@
| --- | --- |
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
+298 -3
View File
@@ -28,6 +28,7 @@ NewSzxcn Email 管理命令
menu 显示安装与运维菜单
install 首次安装;已有安装会先完整备份再重新安装
restore 从完整备份目录或压缩包恢复到新服务器
update 备份数据库并更新到最新版
repair 检查并修复现有安装
status 查看容器与健康状态
@@ -161,6 +162,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
@@ -1077,6 +1097,277 @@ do_install() {
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() {
require_installation
ensure_docker
@@ -1503,7 +1794,8 @@ render_uninstalled_menu() {
prompt_text '状态:尚未安装\n'
prompt_text '--------------------------------------------------\n'
prompt_text '1. 一键安装 NewSzxcn Email\n'
prompt_text '0. 退出\n'
prompt_text '2. 备份恢复\n'
prompt_text '3. 退出\n'
prompt_text '==================================================\n'
}
@@ -1541,10 +1833,11 @@ do_menu() {
local default_choice="2" public_url="" choice status version
if ! installation_configured; then
render_uninstalled_menu
choice="$(prompt_menu_choice "1" "1")" || return 1
choice="$(prompt_menu_choice "1" "3")" || return 1
case "${choice}" in
0) success "已退出,未作任何修改。" ;;
3) success "已退出,未作任何修改。" ;;
1) do_install ;;
2) do_restore_menu ;;
esac
return
fi
@@ -1581,6 +1874,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
fi
if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_command
ensure_cli_alias
fi
@@ -1588,6 +1882,7 @@ 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 ;;
repair) require_root; require_curl; do_repair_install ;;
status) require_root; require_curl; do_status ;;
+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
+152 -3
View File
@@ -159,6 +159,8 @@ test_menu_rendering() (
[[ "${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"
@@ -186,12 +188,15 @@ test_menu_dispatch() (
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"
if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then
fail_test "uninstalled menu accepted unavailable update action"
fi
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"
@@ -430,6 +435,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)"
@@ -707,6 +850,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