Compare commits

...

36 Commits

Author SHA1 Message Date
zxyszx 942605b2b6 fix: refine Telegram links and mailbox selector
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 18:53:21 +08:00
zxyszx 635ab02b29 feat: harden Telegram mail notifications
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 17:13:08 +08:00
zxyszx 4c90c1de44 ci: improve Telegram release notification layout 2026-08-06 02:37:47 +08:00
zxyszx 1fa029370d docs: mark v1.2.16 notification release 2026-08-06 02:31:30 +08:00
zxyszx 79f920bf0b feat: add Telegram mail and release notifications
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 02:17:11 +08:00
zxyszx c77f63b5af fix: remove mailbox selector empty slot
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 01:38:10 +08:00
zxyszx 86773c64ca feat: unify email identity and administrator security
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-05 02:55:36 +08:00
zxyszx a11e1cd2f1 fix: refine mail export and mailbox deletion
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 20:57:41 +08:00
zxyszx 94efc2c62b ci: publish concise release notes 2026-08-04 19:41:01 +08:00
zxyszx cefbee4f7f fix: restore mail folder and export workflows
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 19:07:21 +08:00
zxyszx 06388465a2 fix: default mailbox selector while loading
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:45:49 +08:00
zxyszx e6504ef492 fix: expand all-mailbox selector
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:22:37 +08:00
zxyszx 39ff9ce01d fix: support batched mail imports
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:08:37 +08:00
zxyszx df50f8b3ef fix: reset mailbox view on refresh
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 12:55:21 +08:00
zxyszx 397ce51800 fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 04:14:36 +08:00
zxyszx e789cf9b14 chore: prepare v1.2.6 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 03:44:14 +08:00
zxyszx 7e480795fc feat: add administrator password recovery menu 2026-08-04 03:40:34 +08:00
zxyszx a2eb1fbc52 test: support GNU stat permissions 2026-08-04 02:28:21 +08:00
zxyszx cab71c0539 fix: make snapshot validation shellcheck-safe 2026-08-04 02:26:55 +08:00
zxyszx 6301182cfc feat: harden installer management menu 2026-08-04 02:23:46 +08:00
zxyszx 6d0147b639 docs: add NewSzxcn mailbox guide 2026-08-04 01:44:14 +08:00
zxyszx 497aefb903 fix: finalize v1.2.5 release notes
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 00:13:57 +08:00
zxyszx 1af4b7250e feat: link mailbox self-service setup guidance 2026-08-04 00:03:11 +08:00
zxyszx 1ce75ef241 fix: unify empty mailbox management action 2026-08-03 23:57:10 +08:00
zxyszx 99b8b7dee8 fix: guide admins to mailbox management 2026-08-03 23:52:42 +08:00
zxyszx 697ed236cc feat: add unified installer management menu 2026-08-03 23:29:02 +08:00
zxyszx b55c298ef4 feat: add safe fresh reinstall option 2026-08-03 23:23:41 +08:00
zxyszx 800d482c77 fix: prompt before modifying existing installs 2026-08-03 23:09:59 +08:00
zxyszx ccd5c4efd5 chore: prepare v1.2.4
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 22:44:01 +08:00
zxyszx da888234b9 feat: add guided mail server installer 2026-08-03 22:39:51 +08:00
zxyszx 2f7494e5e6 ui: rebalance system version dialog
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 20:41:50 +08:00
zxyszx 1350611908 ci: authenticate GHCR releases with package token 2026-08-03 20:12:57 +08:00
zxyszx 9a489992ed fix: harden runtime and remove placeholder features 2026-08-03 19:51:27 +08:00
zxyszx 18f8d870e8 feat: use usernames for administrator accounts
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 17:27:29 +08:00
zxyszx 6059954596 fix: honor custom HTTP bind in health checks 2026-08-03 16:49:33 +08:00
zxyszx 65bc16bd92 fix: support proxied deployments during updates 2026-08-03 16:47:29 +08:00
86 changed files with 7300 additions and 1955 deletions
+13
View File
@@ -0,0 +1,13 @@
## 本次更新
### 优化邮箱选择器动态宽度
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
+13
View File
@@ -0,0 +1,13 @@
## 本次更新
### 移除“加载邮箱”过渡文案
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
+33
View File
@@ -0,0 +1,33 @@
## 本次更新
### 修复文件夹管理
- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。
- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。
- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。
### 修复导入邮件的存储显示
- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`
- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。
- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。
### 改进邮件下载
- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。
- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。
- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。
### 优化设置与后台切换
- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。
- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。
- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。
### 验证
- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
- 已通过安装脚本语法和自动化回归测试。
- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。
**完整更新日志**[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12)
+30
View File
@@ -0,0 +1,30 @@
## 本次更新
### 修复选中邮件下载
- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。
- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。
### 改进 EML 文件信息
- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。
- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。
- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。
### 调整邮箱与设置交互
- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。
- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。
- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。
### 修复最后一个邮箱删除
- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。
- 删除邮箱流程的错误提示改为简体中文。
### 验证
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。
**完整更新日志**[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13)
+35
View File
@@ -0,0 +1,35 @@
## 本次更新
### 统一邮箱登录与注册
- 前台和后台统一使用主登录邮箱与密码登录,不再接受旧用户名登录。
- 自助注册会一次性创建账号和邮箱,并要求填写显示名称、邮箱前缀、域名和密码确认。
- 只有主登录邮箱可以登录网页,账号下的其他邮箱不会成为额外登录入口。
- 网页修改或管理员重置密码时,会同步更新该账号全部邮箱的 SMTP/IMAP 密码。
### 加强管理员与安全管理
- 系统只保留一名安装时创建的管理员,后台和 API 均不能新增或提升第二名管理员。
- 内置“管理员”和“普通用户”权限配置不可修改或删除,自定义权限配置仍可正常创建和分配。
- 双因素认证新增恢复码,服务器菜单支持重置管理员密码和管理员 2FA。
- 保留 Cloudflare Turnstile 登录与注册防刷验证。
### 修复邮件批量移动与界面布局
- “全部邮箱”模式可以将跨邮箱选中的邮件批量移动到各邮箱对应的自定义文件夹。
- 固定邮箱选择器、复制按钮和展开菜单宽度,切换具体邮箱时不再缩窄。
- 邮箱页与设置页统一侧栏宽度,点击设置时分隔线和内容区域不再左右跳动。
### 改进安装与升级兼容
- 新安装默认创建 `admin@邮箱域名` 管理员邮箱,并支持自定义管理员邮箱前缀。
- 旧数据库升级时自动保留现有管理员、邮箱、密码和双因素认证数据,不会生成错误的占位域名账号。
- 管理员在后台修改主登录邮箱后,服务重启不会恢复成安装时的旧邮箱。
- 安装指南、环境变量示例和管理员恢复命令已同步更新。
### 验证
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查和生产构建。
- 已通过安装脚本测试、ShellCheck,以及桌面端和移动端浏览器验证。
**完整更新日志**[v1.2.13...v1.2.14](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.13...v1.2.14)
+14
View File
@@ -0,0 +1,14 @@
## 本次更新
### 修复邮箱选择器右侧空白
- “全部邮箱”状态下移除复制按钮的永久空白占位,选择器恢复为整行宽度。
- 选择具体邮箱时,复制按钮覆盖显示在选择器右侧,不再挤压或缩窄选择器。
- 下拉箭头、未读数量和复制按钮保持独立位置,长邮箱地址仍会正确截断。
### 验证
- 已通过前端 TypeScript 检查、shadcn/ui 检查和生产构建。
- 已在桌面端和 390×844 移动端验证“全部邮箱”与具体邮箱两种状态,无横向溢出或控制台错误。
**完整更新日志**[v1.2.14...v1.2.15](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.14...v1.2.15)
+3
View File
@@ -0,0 +1,3 @@
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
+6
View File
@@ -0,0 +1,6 @@
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
+4
View File
@@ -0,0 +1,4 @@
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
+33
View File
@@ -0,0 +1,33 @@
## 本次更新
### 一键部署与运维
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
### 邮箱与账号体验
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
### 自助申请邮箱
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
- 管理员可通过“前往设置”直接进入后台邮件设置页。
- 普通用户无法自行申请时会提示联系管理员处理。
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
- 补充简体中文、繁体中文和英文界面文案。
### 兼容与验证
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
**完整更新日志**[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
+36
View File
@@ -0,0 +1,36 @@
## 本次更新
### 邮箱管理跳转
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
### 管理员密码恢复
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
### 一键安装与运维安全
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
### 邮箱指南与快捷命令
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
### 兼容与验证
- 本次更新不删除现有邮件、域名、账号、证书和配置。
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
**完整更新日志**[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
+21
View File
@@ -0,0 +1,21 @@
## 本次更新
### 修复网页更新出现 502
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
- 前端遇到更新期间的 `502``503``504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
### 更新说明
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
-`v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
### 验证
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
+18
View File
@@ -0,0 +1,18 @@
## 本次更新
### 修复邮箱选择器默认状态
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
### 修复邮箱下拉菜单宽度
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
+25
View File
@@ -0,0 +1,25 @@
## 本次更新
### 修复邮件导入 413
- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。
- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。
- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。
### 支持大批量导入
- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。
- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。
- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。
### 保留历史邮件时间
- 导入时继续保留邮件头中的原始 `Date` 时间。
- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。
- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。
### 验证
- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9)
+8
View File
@@ -40,6 +40,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
+124 -59
View File
@@ -27,6 +27,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -136,8 +144,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
- name: Prepare image name
id: image
@@ -161,7 +169,7 @@ jobs:
type=raw,value=latest
type=sha,prefix=sha-
labels: |
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
- name: Build and push
@@ -197,77 +205,134 @@ jobs:
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
version="${{ needs.release.outputs.version }}"
repo="${GITHUB_REPOSITORY}"
repo_url="https://github.com/${repo}"
image_base="${REGISTRY}/${repo}"
image_base="${image_base,,}"
current_commit="$(git rev-list -n 1 "${tag}")"
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
version_notes=".github/release-notes/${tag}.md"
if [[ -f "${version_notes}" ]]; then
cp "${version_notes}" generated-release-notes.md
else
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
fi
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
{
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
echo
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
echo
echo
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
else
echo "- 首个公开版本。"
echo
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
fi
} > generated-release-notes.md
fi
fi
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
{
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
echo
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
echo
echo
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
else
echo "- 首个公开版本。"
echo
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
fi
} > generated-release-notes.md
fi
cat > release-notes.md <<EOF
# LanQin Email ${tag}
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
## 注意
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
## 使用文档
- [项目文档](${repo_url}#readme)
- [开源协议](${repo_url}/blob/main/LICENSE)
## Docker 镜像
| 组件 | 镜像 |
|------|------|
| All-in-one | \`${image_base}:${tag}\` |
| API | \`${image_base}-api:${tag}\` |
| Web | \`${image_base}-web:${tag}\` |
| Postfix | \`${image_base}-postfix:${tag}\` |
| Dovecot | \`${image_base}-dovecot:${tag}\` |
| Rspamd | \`${image_base}-rspamd:${tag}\` |
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
EOF
{
echo
cat generated-release-notes.md
} >> release-notes.md
cp generated-release-notes.md release-notes.md
- name: Create or update GitHub release
id: release_result
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
title="LanQin Email ${tag}"
title="NewSzxcn Email ${tag}"
if gh release view "${tag}" >/dev/null 2>&1; then
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
echo "created=false" >> "$GITHUB_OUTPUT"
else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
echo "created=true" >> "$GITHUB_OUTPUT"
fi
- name: Notify Telegram release channel
if: steps.release_result.outputs.created == 'true'
continue-on-error: true
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_URL: ${{ needs.release.outputs.release_url }}
shell: bash
run: |
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
echo "::notice::Telegram release notification is not configured; skipping."
exit 0
fi
python3 - <<'PY'
import html
import os
import re
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
entries = []
for raw in notes.splitlines():
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
line = re.sub(r"^[-*+]\s+", "", line)
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
line = re.sub(r"`([^`]+)`", r"\1", line)
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
if line:
entries.append(line)
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} {html.escape(title)}</b>"
if description:
section += "\n" + html.escape(description.rstrip("。") + "。")
sections.append(section)
tag = os.environ["RELEASE_TAG"]
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</b>\n\n"
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
footer = f'\n\n🔗 <a href="{release_url}">查看本次更新</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
for section in sections:
added = len(section) + (2 if visible_sections else 0)
if used + added > available:
break
visible_sections.append(section)
used += added
body = "\n\n".join(visible_sections)
if len(visible_sections) < len(sections):
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
PY
jq -n \
--arg chat_id "${TELEGRAM_CHAT_ID}" \
--rawfile text telegram-release-message.txt \
'{
chat_id:$chat_id,
text:$text,
parse_mode:"HTML",
disable_web_page_preview:true
}' > telegram-release-payload.json
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
-o telegram-release-response.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
--data-binary @telegram-release-payload.json \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
exit 1
fi
echo "::notice::Telegram release notification sent."
+5 -1
View File
@@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
## Update
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
@@ -39,10 +41,12 @@ Useful commands:
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
## Required ports
+34 -7
View File
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能
@@ -16,7 +16,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
@@ -29,10 +29,24 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员
邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员邮箱和密码
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -48,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新
@@ -56,21 +72,31 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
sudo newszxcn-email update
```
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash
sudo newszxcn-email rollback
```
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
常用运维命令:
```bash
sudo ns
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口
@@ -104,10 +130,11 @@ sudo newszxcn-email uninstall
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|-- data/ # SQLite、附件和更新前备份
|-- mail/ # Maildir 邮件原文
`-- dkim/ # DKIM 私钥
|-- dkim/ # DKIM 私钥
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+17 -5
View File
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员邮箱和密码
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 移除容器不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
`uninstall` 移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
├── docker-compose.yml # 邮箱主服务与内部更新服务
├── data/ # SQLite、附件和更新前备份
├── mail/ # Maildir 邮件原文
── dkim/ # DKIM 私钥
── dkim/ # DKIM 私钥
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+1 -1
View File
@@ -1 +1 @@
1.2.1
1.2.15
+8 -8
View File
@@ -3,9 +3,14 @@ module lanqin-email-api
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/go-chi/chi/v5 v5.3.0
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.26.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1
)
@@ -13,19 +18,14 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
+8 -8
View File
@@ -10,8 +10,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+71 -45
View File
@@ -52,7 +52,7 @@ 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), '')
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
ORDER BY u.created_at DESC`)
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
@@ -108,14 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
actor := currentUser(r)
loginName, err := cleanLoginName(req.LoginName, req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail, err := cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = loginName
badRequest(w, errors.New("displayName is required"))
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
@@ -125,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" && (actor == nil || actor.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -137,8 +142,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -155,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
@@ -183,6 +188,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
@@ -211,7 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "user not found")
return
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
if existing.Role == "admin" && role != "admin" {
badRequest(w, errors.New("唯一管理员不能降级"))
return
}
if existing.Role != "admin" && role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail := existing.Email
loginName := existing.LoginName
if strings.TrimSpace(emailInput) != "" {
primaryEmail, err = cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
loginName = primaryEmail
}
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return
}
@@ -223,6 +252,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("default administrator must remain an active super administrator"))
return
}
if existing.Role == "admin" && disabled {
badRequest(w, errors.New("唯一管理员不能停用"))
return
}
mailboxLimitOverride := existing.MailboxLimitOverride
if req.MailboxLimitOverride != nil {
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -278,8 +311,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
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=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用"))
return
}
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
@@ -293,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
if existing.Role == "admin" {
a.updateConfig(func(cfg *Config) {
cfg.AdminEmail = primaryEmail
})
}
user, err := a.adminUserByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
@@ -320,8 +362,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -538,8 +580,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
@@ -551,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
if role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -593,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
} else {
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
}
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
if err != nil {
badRequest(w, err)
return
}
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
@@ -607,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerLoginName, address) {
ownerDisplayName = ownerLoginName
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, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
badRequest(w, err)
return
@@ -706,26 +749,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
var owner string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var count int
if current != nil && owner == current.ID {
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return
}
if count <= 1 {
badRequest(w, errors.New("cannot delete your last mailbox"))
return
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
return
}
messageIDs := []string{}
@@ -741,12 +767,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
+375 -105
View File
@@ -23,15 +23,39 @@ import (
)
type App struct {
cfg Config
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
cfg Config
cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
turnstileURL string
telegramURL string
telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex
}
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
return a.cfg
}
func (a *App) setConfig(cfg Config) {
a.cfgMu.Lock()
a.cfg = cfg
a.cfgMu.Unlock()
}
func (a *App) updateConfig(update func(*Config)) {
a.cfgMu.Lock()
defer a.cfgMu.Unlock()
update(&a.cfg)
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
@@ -51,7 +75,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()}
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.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
@@ -73,16 +97,29 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
db.Close()
return nil, err
}
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
if strings.TrimSpace(a.config().MaildirRoot) != "" {
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
return a, nil
}
@@ -166,6 +203,14 @@ func (a *App) migrate(ctx context.Context) error {
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(user_id, code_hash)
)`,
`CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -401,6 +446,20 @@ func (a *App) migrate(ctx context.Context) error {
)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
`CREATE TABLE IF NOT EXISTS telegram_mail_outbox (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL UNIQUE,
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
delivered_at TEXT,
lease_until TEXT NOT NULL DEFAULT '',
telegram_message_id INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
AFTER DELETE ON mailboxes BEGIN
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
@@ -643,12 +702,72 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateAPITokenScopes(ctx); err != nil {
return err
}
if err := a.migrateTelegramNotifications(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
return nil
}
func (a *App) migrateTelegramNotifications(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "lease_until", `ALTER TABLE telegram_mail_outbox ADD COLUMN lease_until TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "telegram_message_id", `ALTER TABLE telegram_mail_outbox ADD COLUMN telegram_message_id INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
return nil
}
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
var mailboxSettingExists int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramMailboxIds'`).Scan(&mailboxSettingExists); err != nil {
return err
}
if mailboxSettingExists == 0 {
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM mailboxes m JOIN users u ON u.id=m.user_id WHERE u.role='admin' AND m.status='active' ORDER BY m.address`)
if err != nil {
return err
}
var mailboxIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
mailboxIDs = append(mailboxIDs, id)
}
if err := rows.Close(); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramMailboxIds',?,?)`, strings.Join(mailboxIDs, ","), now); err != nil {
return err
}
}
var includeSettingExists int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramIncludeUnregistered'`).Scan(&includeSettingExists); err != nil {
return err
}
if includeSettingExists == 0 {
var enabled string
_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='telegramMailEnabled'`).Scan(&enabled)
includeUnregistered := "false"
if strings.EqualFold(enabled, "true") {
includeUnregistered = "true"
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramIncludeUnregistered',?,?)`, includeUnregistered, now); err != nil {
return err
}
}
return nil
}
func (a *App) migrateForwardingVerification(ctx context.Context) error {
columns := []struct {
name string
@@ -920,81 +1039,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
return err
}
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
// that implicitly created an admin mailbox with display_name "LanQin Admin".
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
// Administrators now use a real mailbox as their primary login address, so old
// bootstrap mailboxes must be preserved and normalized by the admin identity
// migration instead of deleted.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
rows, err := a.db.QueryContext(ctx, `
SELECT mb.id, mb.domain_id
FROM mailboxes mb
JOIN users u ON u.id=mb.user_id
WHERE mb.address=?
AND mb.display_name='LanQin Admin'
AND u.email=?
AND u.role='admin'`, adminEmail, adminEmail)
if err != nil {
return err
}
type legacyMailbox struct {
id string
domainID string
}
items := []legacyMailbox{}
for rows.Next() {
var item legacyMailbox
if err := rows.Scan(&item.id, &item.domainID); err != nil {
rows.Close()
return err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
if err != nil {
return err
}
messageIDs := []string{}
for messageRows.Next() {
var messageID string
if err := messageRows.Scan(&messageID); err != nil {
messageRows.Close()
return err
}
messageIDs = append(messageIDs, messageID)
}
if err := messageRows.Err(); err != nil {
messageRows.Close()
return err
}
if err := messageRows.Close(); err != nil {
return err
}
for _, messageID := range messageIDs {
a.deleteMessage(ctx, messageID)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `
DELETE FROM domains
WHERE id=?
AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
return err
}
}
return nil
}
@@ -1387,15 +1436,20 @@ func messageIndexes() []string {
}
func (a *App) seed(ctx context.Context) error {
cfg := a.config()
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
}
if count > 0 {
return a.ensureConfiguredAdminSuperAdmin(ctx)
return a.migrateConfiguredAdministratorIdentity(ctx)
}
adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail)
if err != nil {
return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation")
}
adminPassword := a.cfg.AdminPassword
adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
@@ -1410,13 +1464,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
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, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1451,12 +1500,221 @@ func (a *App) seed(ctx context.Context) error {
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
return a.migrateConfiguredAdministratorIdentity(ctx)
}
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
cfg := a.config()
type adminUser struct {
ID string `json:"id"`
LoginName string `json:"loginName,omitempty"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt string `json:"createdAt"`
}
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`)
if err != nil {
return err
}
admins := []adminUser{}
for rows.Next() {
var item adminUser
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err != nil {
rows.Close()
return err
}
admins = append(admins, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if len(admins) == 0 {
if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" {
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail)
var item adminUser
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
admins = append(admins, item)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
}
}
if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername := normalizeLoginName(cfg.AdminUsername)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername)
var item adminUser
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
admins = append(admins, item)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
}
if len(admins) == 0 {
return errors.New("no administrator user found for identity migration")
}
keeper := admins[0]
adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email)
if err != nil {
return err
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var conflictID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil {
return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
demoted := []adminUser{}
for _, admin := range admins[1:] {
if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil {
return err
}
admin.PasswordHash = ""
demoted = append(demoted, admin)
}
if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`,
adminEmail, adminEmail, now, keeper.ID); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return fmt.Errorf("admin identity migration conflict: %w", err)
}
return err
}
parts := strings.SplitN(adminEmail, "@", 2)
localPart := parts[0]
domainName := normalizeDomain(parts[1])
var domainID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
domainID, err = a.createDomainTx(ctx, tx, domainName)
if err != nil {
return err
}
}
mailboxCreated := false
var mailboxID, mailboxUserID string
if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
if err != nil {
return err
}
mailboxCreated = true
} else if mailboxUserID != keeper.ID {
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
}
result := map[string]any{
"adminUserId": keeper.ID,
"adminEmail": adminEmail,
"emailSource": emailSource,
"previousEmail": keeper.Email,
"demotedAdmins": demoted,
"mailboxId": mailboxID,
"mailboxCreated": mailboxCreated,
"migratedAt": now,
}
raw, _ := json.Marshal(result)
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
a.updateConfig(func(current *Config) {
current.AdminEmail = adminEmail
if current.MailDomain == "" {
current.MailDomain = domainName
}
})
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
return nil
}
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
// Once initialized, the database identity is authoritative. This keeps an
// administrator email changed in the UI from reverting to the installer value.
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
return email, "existing_admin_email", nil
}
if strings.TrimSpace(cfg.AdminEmail) != "" {
email, err := cleanPrimaryEmail(cfg.AdminEmail)
if err != nil {
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
}
return email, "configured_admin_email", nil
}
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
preferredLocalPart = normalizeLocalPart(loginName)
}
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
preferredLocalPart = "admin"
}
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
if err != nil {
return "", "", err
}
for rows.Next() {
var address string
if err := rows.Scan(&address); err != nil {
rows.Close()
return "", "", err
}
if email, err := cleanPrimaryEmail(address); err == nil {
rows.Close()
return email, "existing_admin_mailbox", nil
}
}
if err := rows.Err(); err != nil {
rows.Close()
return "", "", err
}
if err := rows.Close(); err != nil {
return "", "", err
}
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
}
var onlyDomain string
var domainCount int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
return "", "", err
}
if domainCount == 1 && validMailDomain(onlyDomain) {
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
}
publicDomain := normalizeDomain(cfg.PublicHostname)
if strings.HasPrefix(publicDomain, "mail.") {
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
}
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
}
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
}
func validMailDomain(domain string) bool {
domain = normalizeDomain(domain)
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
}
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
return err
}
@@ -1572,20 +1830,32 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return err
}
now := a.now().UTC()
systemDomain := normalizeDomain(cfg.MailDomain)
if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") {
systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1])
}
if systemDomain == "" {
systemDomain = normalizeDomain(cfg.PublicHostname)
}
if systemDomain == "" {
systemDomain = "lanqin.local"
}
systemAddress := "system@" + systemDomain
subject := "欢迎使用 NewSzxcn 邮箱"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
To: a.cfg.AdminEmail,
From: "system@lanqin.local",
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
To: cfg.AdminEmail,
From: systemAddress,
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
Time: now,
})
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
@@ -1594,11 +1864,11 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
MailboxID: mailboxID,
FolderID: folderID,
MessageUID: newID("uid"),
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
Subject: subject,
From: "system@lanqin.local",
From: systemAddress,
FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail},
To: []string{cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
Snippet: snippetFrom(bodyText, bodyHTML),
File diff suppressed because it is too large Load Diff
+77 -48
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"errors"
"net/http"
"strings"
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "验证码错误")
return
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
if consumeErr != nil || !ok {
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
return
}
}
a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil {
@@ -50,12 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
loginName, err := cleanLoginName(req.LoginName, req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
email, err := cleanPrimaryEmail(emailInput)
if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
@@ -64,7 +72,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
@@ -81,7 +89,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration {
if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册")
return
}
@@ -101,23 +109,54 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
email, err := cleanPrimaryEmail(req.Email)
if err != nil {
badRequest(w, errors.New("邮箱地址无效"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("密码至少需要 6 个字符"))
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
badRequest(w, errors.New("请输入显示名称"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
return
}
parts := strings.SplitN(email, "@", 2)
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
if mailboxLocalPart == "" {
mailboxLocalPart = normalizeLocalPart(parts[0])
}
mailboxDomainID := strings.TrimSpace(req.DomainID)
var mailboxDomain string
if mailboxDomainID != "" {
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
} else {
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
badRequest(w, errors.New("所选邮箱域名不可用"))
} else {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return
}
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
return
}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
if item == mailboxLocalPart {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
}
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -132,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
@@ -141,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
}
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
user, err := a.userByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -150,46 +207,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
return
}
// Create a mailbox for the registered user
var mailboxDomainID string
var mailboxLocalPart string
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
// User selected a specific domain and local part
mailboxDomainID = strings.TrimSpace(req.DomainID)
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
} else {
// Auto-detect: use the first active domain and email local part
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
mailboxDomainID = ""
}
if mailboxDomainID != "" {
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
}
}
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
}
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -199,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
return
}
var req struct {
DisplayName string `json:"displayName"`
}
@@ -239,8 +268,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("新密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.NewPassword) {
badRequest(w, errors.New("新密码至少需要 6 个字符"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
+17 -1
View File
@@ -14,7 +14,9 @@ type Config struct {
DataDir string
CookieName string
SessionTTLHours int
AdminUsername string
AdminEmail string
MailDomain string
AdminPassword string
PublicHostname string
PublicBaseURL string
@@ -50,6 +52,12 @@ type Config struct {
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
TelegramMailEnabled bool
TelegramBotToken string
TelegramPrivateChatID string
TelegramBodyMode string
TelegramMailboxIDs string
TelegramIncludeUnregistered bool
MailTranslateEnabled bool
MailTranslateMaxChars int
DeliveryWebhookSecret string
@@ -70,7 +78,9 @@ func LoadConfig() Config {
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
@@ -106,6 +116,12 @@ func LoadConfig() Config {
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false),
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")),
TelegramMailboxIDs: getenv("LANQIN_TELEGRAM_MAILBOX_IDS", ""),
TelegramIncludeUnregistered: getenvBool("LANQIN_TELEGRAM_INCLUDE_UNREGISTERED", false),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
+2 -2
View File
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
name := strings.TrimSuffix(d.Name, ".")
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry)
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
ok = true
}
}
+21 -24
View File
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
}
func (a *App) externalIMAPWorker(ctx context.Context) {
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
}
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
respondError(w, http.StatusForbidden, "external imap is disabled")
return
}
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return
}
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
}
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
}
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
if a.cfg.ExternalIMAPAllowPrivateHosts {
if a.config().ExternalIMAPAllowPrivateHosts {
return nil
}
if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
}
func (a *App) externalIMAPKey() ([]byte, error) {
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
}
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
}
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
switch provider {
case externalIMAPOAuthGmail:
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
ClientID: a.config().ExternalIMAPGmailClientID,
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
},
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook:
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
ClientID: a.config().ExternalIMAPOutlookClientID,
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
}
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
}
imported++
} else {
skipped++
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
}
type externalIMAPFolderState struct {
LastUID uint32
LastUID uint32
Initialized bool
}
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
var state externalIMAPFolderState
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
state.Initialized = true
}
return state
}
@@ -1376,15 +1382,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml"
}
func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
parts := externalIMAPAttachmentPartsFromBodyStructure(body)
items := make([]Attachment, 0, len(parts))
for _, part := range parts {
items = append(items, part.Attachment)
}
return items
}
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC()
items := []externalIMAPAttachmentPart{}
+2 -2
View File
@@ -45,7 +45,7 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
@@ -101,7 +101,7 @@ func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID st
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
+3 -3
View File
@@ -389,7 +389,7 @@ func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email
}
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", errors.New("SMTP 未配置,无法发送验证邮件")
}
mb, err := a.primaryMailboxForUser(ctx, userID)
@@ -428,9 +428,9 @@ func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targe
}
func (a *App) forwardingVerificationURL(token string) string {
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
if base == "" {
base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
}
return base + "/api/verify-email?token=" + url.QueryEscape(token)
}
-19
View File
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
+237 -14
View File
@@ -264,11 +264,6 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
Name string `json:"name"`
}
@@ -285,6 +280,47 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("system folder already exists"))
return
}
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
return
}
mailboxIDs := []string{}
for rows.Next() {
var mailboxID string
if err := rows.Scan(&mailboxID); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
mailboxIDs = append(mailboxIDs, mailboxID)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
rows.Close()
if len(mailboxIDs) == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
for _, mailboxID := range mailboxIDs {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
}
}
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
@@ -299,16 +335,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
if folderID == "" {
badRequest(w, errors.New("folder id is required"))
return
}
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
a.handleDeleteAllMailFolders(w, r, folderID)
return
}
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var folderName string
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -378,6 +418,115 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
}
func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
folderName = strings.TrimSpace(folderID[4:])
}
name, err := normalizeCustomFolderName(folderName)
if err != nil {
badRequest(w, err)
return
}
if isSystemFolderName(name) {
badRequest(w, errors.New("system folders cannot be deleted"))
return
}
user := currentUser(r)
type folderTarget struct {
folderID string
mailboxID string
inboxID string
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
}
targets := []folderTarget{}
for rows.Next() {
var target folderTarget
if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
targets = append(targets, target)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
rows.Close()
if len(targets) == 0 {
respondError(w, http.StatusNotFound, "folder not found")
return
}
for i := range targets {
targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load inbox")
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
moved := 0
for _, target := range targets {
messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
return
}
messageIDs := []string{}
for messageRows.Next() {
var messageID string
if err := messageRows.Scan(&messageID); err != nil {
messageRows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
messageIDs = append(messageIDs, messageID)
}
if err := messageRows.Err(); err != nil {
messageRows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
messageRows.Close()
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
return
}
moved++
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
for _, target := range targets {
_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
}
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
return a.ensureFolder(ctx, mailboxID, name)
}
@@ -971,7 +1120,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil {
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue
}
copyMsg := base
@@ -982,11 +1131,12 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
continue
}
if rcptMailbox.Status != "active" {
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base
copyMsg.MailboxID = ""
copyMsg.FolderID = ""
@@ -995,6 +1145,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
}
continue
@@ -1006,12 +1157,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg := base
copyMsg.MailboxID = rcptMailbox.ID
copyMsg.FolderID = inboxID
copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
}
}
}
@@ -2007,6 +2162,74 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
Folder string `json:"folder"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
folder, err := normalizeFolderNameForUser(req.Folder)
if err != nil {
badRequest(w, err)
return
}
ids := make([]string, 0, len(req.IDs))
seen := map[string]bool{}
for _, id := range req.IDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
badRequest(w, errors.New("请选择要移动的邮件"))
return
}
type itemResult struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
OK bool `json:"ok"`
Message string `json:"message"`
}
results := make([]itemResult, 0, len(ids))
folderByMailbox := map[string]string{}
moved := 0
for _, id := range ids {
msg, err := a.loadMessageForRequest(r, id, false)
if err != nil {
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
continue
}
folderID := folderByMailbox[msg.MailboxID]
if folderID == "" {
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
if err != nil {
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
continue
}
folderByMailbox[msg.MailboxID] = folderID
}
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
continue
}
moved++
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
}
failed := len(results) - moved
message := fmt.Sprintf("已移动 %d 封邮件", moved)
if failed > 0 {
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
}
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
@@ -2345,7 +2568,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil {
return err
}
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -2550,7 +2773,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p)
}
}
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}
func (a *App) deleteMessage(ctx context.Context, messageID string) {
+78 -20
View File
@@ -13,13 +13,20 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
)
const maxMailImportBytes int64 = 256 << 20
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
const maxSelectedMailExport = 200
type exportedMessage struct {
raw []byte
subject string
date time.Time
}
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
ids, err := a.exportMessageIDs(r)
@@ -43,19 +50,21 @@ func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
zw := zip.NewWriter(w)
usedNames := make(map[string]int, len(ids))
for index, id := range ids {
raw, subject, err := a.rawMessageForExport(r.Context(), id)
for _, id := range ids {
message, err := a.rawMessageForExport(r.Context(), id)
if err != nil {
_ = zw.Close()
return
}
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
header.SetModTime(message.date)
entry, err := zw.CreateHeader(header)
if err != nil {
_ = zw.Close()
return
}
if _, err := entry.Write(raw); err != nil {
if _, err := entry.Write(message.raw); err != nil {
_ = zw.Close()
return
}
@@ -74,6 +83,10 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
where := []string{}
args := []any{}
selectedIDs, err := selectedExportMessageIDs(r)
if err != nil {
return nil, err
}
if view == "unknown" {
if user.Role != "admin" {
@@ -115,6 +128,14 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
return nil, errors.New("unsupported mail view")
}
}
if len(selectedIDs) > 0 {
placeholders := make([]string, 0, len(selectedIDs))
for _, id := range selectedIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")")
}
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
if err != nil {
@@ -132,40 +153,77 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
return ids, rows.Err()
}
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
func selectedExportMessageIDs(r *http.Request) ([]string, error) {
values := r.URL.Query()["messageId"]
if len(values) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(values))
ids := make([]string, 0, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
if len(ids) > maxSelectedMailExport {
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
}
}
return ids, nil
}
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
msg, err := a.storedMessageByID(ctx, id)
if err != nil {
return nil, "", err
return exportedMessage{}, err
}
exportDate := msg.ReceivedAt
if exportDate.IsZero() {
exportDate = messageDate(msg)
}
if msg.RawPath != "" {
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
return raw, msg.Subject, nil
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
}
}
}
attachments, err := a.attachmentInputsForMessage(ctx, id)
if err != nil {
return nil, "", err
return exportedMessage{}, err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
Date: messageDate(msg), Attachments: attachments,
})
return raw, msg.Subject, err
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
}
func exportMessageFilename(subject, id string, index int) string {
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
name = strings.Trim(name, ".-_")
func exportMessageFilename(subject string, date time.Time) string {
name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
return '-'
}
return r
}, strings.TrimSpace(subject))
name = strings.Trim(name, " .-_")
if name == "" {
name = "message"
name = "无主题"
}
if len(name) > 80 {
name = name[:80]
runes := []rune(name)
if len(runes) > 80 {
name = string(runes[:80])
}
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
if date.IsZero() {
return name + ".eml"
}
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
}
func uniqueExportFilename(name string, used map[string]int) string {
@@ -216,7 +274,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
imported, skipped := 0, 0
problems := []string{}
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
@@ -5,11 +5,15 @@ import (
"bytes"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
stdmail "net/mail"
"net/url"
"strings"
"testing"
"time"
)
func TestParseMBOXMultipleMessages(t *testing.T) {
@@ -65,22 +69,27 @@ func TestMailImportExportAndOwnership(t *testing.T) {
t.Fatalf("owner login=%d", code)
}
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
var imported struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
t.Fatalf("import code=%d response=%+v", code, imported)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "中文标题" || list.Items[1].Subject != "older imported message" {
t.Fatalf("list code=%d items=%+v", code, list.Items)
}
receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC)
if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil {
t.Fatal(err)
}
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
if status != http.StatusOK {
@@ -90,18 +99,50 @@ func TestMailImportExportAndOwnership(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 1 {
if len(zr.File) != 2 {
t.Fatalf("zip entries=%d", len(zr.File))
}
if zr.File[0].Name != "中文标题 (20240103).eml" {
t.Fatalf("first filename=%q", zr.File[0].Name)
}
wantModified := receivedAt
if !zr.File[0].Modified.Equal(wantModified) {
t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified)
}
entry, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
exported, err := io.ReadAll(entry)
entry.Close()
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
if err != nil {
t.Fatalf("exported message err=%v raw=%q", err, exported)
}
parsed, err := stdmail.ReadMessage(bytes.NewReader(exported))
if err != nil {
t.Fatal(err)
}
decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
if err != nil || decodedSubject != "中文标题" {
t.Fatalf("decoded subject=%q err=%v", decodedSubject, err)
}
messageDate, err := parsed.Header.Date()
if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) {
t.Fatalf("message date=%s err=%v", messageDate, err)
}
selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID)
status, selectedArchive := getMailExport(t, owner, selectedPath)
if status != http.StatusOK {
t.Fatalf("selected export status=%d body=%q", status, selectedArchive)
}
selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive)))
if err != nil {
t.Fatal(err)
}
if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" {
t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File))
}
var denied map[string]any
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
@@ -113,6 +154,64 @@ func TestMailImportExportAndOwnership(t *testing.T) {
}
}
func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login=%d", code)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil)
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
owner := &testClient{t: t, server: ts}
other := &testClient{t: t, server: ts}
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("owner login=%d", code)
}
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("other login=%d", code)
}
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
var imported map[string]any
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
t.Fatalf("other import=%d response=%v", code, imported)
}
var otherList struct {
Items []MailMessage `json:"items"`
}
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
}
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
status, archive := getMailExport(t, owner, path)
if status != http.StatusOK {
t.Fatalf("cross-owner export status=%d body=%q", status, archive)
}
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 0 {
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
}
}
func exportEntryNames(files []*zip.File) []string {
names := make([]string, 0, len(files))
for _, file := range files {
names = append(names, file.Name)
}
return names
}
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
t.Helper()
var body bytes.Buffer
+4 -4
View File
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -0,0 +1,34 @@
package app
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login=%d", code)
}
var mailboxes struct {
Items []Mailbox `json:"items"`
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
t.Fatalf("delete final mailbox code=%d", code)
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
}
var me map[string]any
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
t.Fatalf("account was not preserved code=%d", code)
}
}
+1 -1
View File
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
}
+52 -22
View File
@@ -45,13 +45,13 @@ type parsedMail struct {
}
func (a *App) maildirWorker(ctx context.Context) {
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 {
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return maildirSyncCounts{}, nil
}
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil {
return nil, err
}
if a.cfg.CatchAllEnabled {
if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
@@ -297,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
_, err = a.insertMessage(ctx, msg, attachments)
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
return err == nil, err
}
@@ -338,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
if strings.TrimSpace(msg.RecipientAddr) == "" {
msg.RecipientAddr = mb.Address
}
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path
if msg.MessageUID == "" {
@@ -372,6 +373,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
a.processInboundForwarding(ctx, id, mb.ID, raw)
if a.shouldNotifyTelegramMessage(ctx, id) {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
}
return err == nil, err
}
@@ -394,16 +398,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
@@ -514,7 +508,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
@@ -600,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
return address
}
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
address = normalizeEmail(address)
if strings.HasSuffix(address, "@"+domain) {
@@ -624,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if len(to) == 0 {
to = []string{fallbackTo}
}
recipientAddr := originalMailRecipient(m.Header)
sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err
}
if looksLikeHTMLDocument(parsed.Text) {
if strings.TrimSpace(parsed.HTML) == "" {
parsed.HTML = parsed.Text
}
parsed.Text = telegramHTMLToText(parsed.Text)
}
bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" {
@@ -644,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
RecipientAddr: recipientAddr,
Subject: subject,
From: from,
FromName: fromName,
@@ -659,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
}, parsed.Attachments, nil
}
func originalMailRecipient(header netmail.Header) string {
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
value := strings.TrimSpace(header.Get(key))
if key == "Original-Recipient" {
if _, suffix, ok := strings.Cut(value, ";"); ok {
value = strings.TrimSpace(suffix)
}
}
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
return address
}
}
return ""
}
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
@@ -697,6 +717,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil
}
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
if converted, readErr := io.ReadAll(reader); readErr == nil {
decoded = converted
}
}
}
decoded = []byte(strings.ToValidUTF8(string(decoded), ""))
}
switch strings.ToLower(mediaType) {
case "text/html":
if parsed.HTML == "" {
+9 -9
View File
@@ -14,7 +14,7 @@ import (
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
+1 -1
View File
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
}
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
}
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
+1 -1
View File
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
+8 -37
View File
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -318,26 +318,9 @@ 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")
var owner string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
current := currentUser(r)
if current != nil && owner == current.ID {
var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return
}
if count <= 1 {
badRequest(w, errors.New("cannot delete your last mailbox"))
return
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
return
}
messageIDs := []string{}
@@ -353,11 +336,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusNotFound, "mailbox not found")
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -371,8 +354,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
var userID string
@@ -826,7 +809,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
return "", errors.New("invalid owner email")
}
var existing string
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
if err == nil {
return existing, nil
}
@@ -923,15 +906,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
}
return limit
}
func parseOpenAPIOffset(r *http.Request) int {
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
if cursor == "" {
return 0
}
offset, err := strconv.Atoi(cursor)
if err != nil || offset < 0 {
return 0
}
return offset
}
+23
View File
@@ -0,0 +1,23 @@
package app
import "testing"
func TestHasMinimumPasswordLength(t *testing.T) {
tests := []struct {
name string
password string
want bool
}{
{name: "five ASCII characters", password: "abc12", want: false},
{name: "six ASCII characters", password: "abc123", want: true},
{name: "six Unicode characters", password: "密码测试六位", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
}
})
}
}
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if id == PermissionGroupSuperAdmin {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
if intBool(existingSystem) {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
var req struct {
+8 -35
View File
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限配", Description: "查看权限配、权限目录和使用人数。", Category: "权限配"},
{Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"},
{Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsView, Label: "查看权限配", Description: "查看内置和自定义权限配、权限目录和使用人数。", Category: "权限配"},
{Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"},
{Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
{
ID: PermissionGroupSuperAdmin,
Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过权限配分配。",
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
@@ -490,21 +490,6 @@ func regularUserDefaultPermissions() []string {
}
}
func fixedPermissionGroupIDs() map[string]bool {
out := map[string]bool{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = true
}
return out
}
func assignablePermissionGroupIDs() map[string]bool {
out := fixedPermissionGroupIDs()
delete(out, PermissionGroupSuperAdmin)
delete(out, PermissionGroupRegular)
return out
}
func isAssignablePermissionGroupID(groupID string) bool {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
}
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out
}
func permissionGroupNames() map[string]string {
out := map[string]string{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = group.Name
}
return out
}
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() {
@@ -1054,11 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
}
func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
return u != nil && u.Role == "admin"
}
func sortPermissionGroups(items []PermissionGroup) {
+6 -6
View File
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled,
Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return
}
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return
}
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
@@ -129,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil
}
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
+8 -5
View File
@@ -128,6 +128,7 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
@@ -170,6 +171,9 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
@@ -215,7 +219,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -273,7 +277,7 @@ func currentUser(r *http.Request) *User {
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
cookie, err := r.Cookie(a.config().CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
@@ -344,10 +348,9 @@ func bearerToken(r *http.Request) string {
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
loginName := normalizeLoginName(email)
email = normalizeEmail(email)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
FROM users WHERE login_name=? OR email=?
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
FROM users WHERE email=? LIMIT 1`, email)
var u User
var passwordHash string
var disabled, twoFactorEnabled int
+3 -3
View File
@@ -67,7 +67,7 @@ type sendQueueItem struct {
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
+3 -3
View File
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken()
sessionID := newID("ses")
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName,
Name: a.config().CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP,
Secure: !a.config().AllowInsecureHTTP,
})
return nil
}
+144 -70
View File
@@ -40,6 +40,12 @@ type SystemSettings struct {
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type systemSettingsUpdate struct {
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotToken string `json:"telegramBotToken"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type PublicSettings struct {
@@ -100,15 +112,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
refreshSeconds := a.cfg.MailRefreshSeconds
cfg := a.config()
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
if cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
@@ -126,12 +139,14 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
var req systemSettingsUpdate
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
next := a.cfg
next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
@@ -203,12 +218,36 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
next.TelegramMailEnabled = req.TelegramMailEnabled
if strings.TrimSpace(req.TelegramBotToken) != "" {
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
}
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
if next.TelegramMailEnabled {
if next.TelegramBotToken == "" {
badRequest(w, errors.New("Telegram Bot Token 未设置"))
return
}
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
return
}
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
return
}
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
previous := a.config()
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.cfg = next
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -218,7 +257,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
cfg := a.cfg
cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
@@ -285,41 +324,49 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
TelegramMailEnabled: cfg.TelegramMailEnabled,
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil {
return err
@@ -332,79 +379,95 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
}
switch key {
case "publicHostname":
a.cfg.PublicHostname = value
cfg.PublicHostname = value
case "publicBaseUrl":
a.cfg.PublicBaseURL = value
cfg.PublicBaseURL = value
case "smtpHost":
a.cfg.SMTPHost = value
cfg.SMTPHost = value
case "smtpPort":
a.cfg.SMTPPort = value
cfg.SMTPPort = value
case "smtpUsername":
a.cfg.SMTPUsername = value
cfg.SMTPUsername = value
case "smtpPassword":
a.cfg.SMTPPassword = value
cfg.SMTPPassword = value
case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true"
cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
a.cfg.MaildirRoot = value
cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n
cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n
cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true"
cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
a.cfg.OpenRegistration = value == "true"
cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true"
cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true"
cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value
cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value
cfg.TurnstileSecretKey = value
case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true"
cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true"
cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n
cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true"
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
cfg.ExternalIMAPOutlookClientSecret = value
case "telegramMailEnabled":
cfg.TelegramMailEnabled = value == "true"
case "telegramBotToken":
cfg.TelegramBotToken = value
case "telegramPrivateChatId":
cfg.TelegramPrivateChatID = value
case "telegramBodyMode":
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
case "telegramMailboxIds":
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
case "telegramIncludeUnregistered":
cfg.TelegramIncludeUnregistered = value == "true"
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
a.setConfig(cfg)
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
@@ -436,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
"telegramBotToken": cfg.TelegramBotToken,
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
@@ -449,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
return err
}
}
if clearPendingTelegram {
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
return err
}
}
return tx.Commit()
}
+9 -9
View File
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
}
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
@@ -39,7 +39,7 @@ func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey,
}
func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
@@ -59,7 +59,7 @@ func (a *App) statusWebhookWorker(ctx context.Context) {
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
@@ -104,7 +104,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
@@ -134,17 +134,17 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
if !a.cfg.StatusWebhookAllowPrivateHosts {
if !a.config().StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
if err != nil {
return nil, err
}
if a.cfg.StatusWebhookAllowPrivateHosts {
if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+4 -4
View File
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
+21 -14
View File
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to back up database")
return
}
if err := a.triggerUpdateService(r.Context()); err != nil {
a.log.Error("trigger system update", "error", err)
respondError(w, http.StatusBadGateway, "failed to start update")
return
}
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
respondJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
@@ -94,10 +88,11 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
"targetVersion": info.LatestVersion,
"message": "更新已启动,服务会在完成后自动恢复",
})
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.cfg.AppVersion)
current := strings.TrimSpace(a.config().AppVersion)
if current == "" {
current = BuildVersion
}
@@ -124,7 +119,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
@@ -134,7 +129,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -161,11 +156,11 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
}
func (a *App) updateEnabled() bool {
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
@@ -173,9 +168,9 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -192,8 +187,20 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
return nil
}
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
go func() {
// Let the accepted response reach the browser before Watchtower replaces this container.
time.Sleep(250 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := a.triggerUpdateService(ctx); err != nil {
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
}
}()
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.cfg.DataDir, "backups")
backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
@@ -10,8 +10,10 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSystemVersionAndUpdate(t *testing.T) {
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
defer releaseServer.Close()
var updateRequests atomic.Int32
updateStarted := make(chan struct{}, 1)
releaseUpdate := make(chan struct{})
var releaseUpdateOnce sync.Once
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
defer releaseBlockedUpdate()
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("update method = %s", r.Method)
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Errorf("authorization = %q", got)
}
updateRequests.Add(1)
updateStarted <- struct{}{}
<-releaseUpdate
w.WriteHeader(http.StatusOK)
}))
defer updateServer.Close()
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Fatalf("unexpected version response: %+v", version)
}
var update map[string]any
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
t.Fatalf("update code=%d response=%v", code, update)
type updateResponse struct {
code int
err error
}
if updateRequests.Load() != 1 {
t.Fatalf("update requests=%d", updateRequests.Load())
response := make(chan updateResponse, 1)
go func() {
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
if err != nil {
response <- updateResponse{err: err}
return
}
req.AddCookie(admin.cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
response <- updateResponse{err: err}
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
response <- updateResponse{code: resp.StatusCode}
}()
select {
case result := <-response:
if result.err != nil || result.code != http.StatusAccepted {
t.Fatalf("update response=%+v", result)
}
case <-time.After(2 * time.Second):
releaseBlockedUpdate()
t.Fatal("update response waited for container replacement")
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("scheduled update request did not start")
}
releaseBlockedUpdate()
if got := updateRequests.Load(); got != 1 {
t.Fatalf("update requests=%d", got)
}
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
if err != nil || len(backups) != 1 {
+978
View File
@@ -0,0 +1,978 @@
package app
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
nethtml "golang.org/x/net/html"
)
const (
telegramMailMaxAttempts = 8
telegramMessageBudget = 3800
telegramPairingTTL = 10 * time.Minute
)
type telegramPairing struct {
TokenFingerprint string
ExpiresAt time.Time
}
type telegramMailPayload struct {
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
Body string `json:"body"`
BodyMode string `json:"bodyMode"`
OTP string `json:"otp,omitempty"`
AttachmentNames []string `json:"attachmentNames,omitempty"`
AttachmentCount int `json:"attachmentCount,omitempty"`
}
type telegramCredentialsRequest struct {
BotToken string `json:"botToken"`
ChatID string `json:"chatId"`
PairingCode string `json:"pairingCode"`
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *struct {
Text string `json:"text"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"chat"`
} `json:"message"`
}
type telegramAPIError struct {
HTTPStatus int
ErrorCode int
Description string
RetryAfter time.Duration
}
func (e *telegramAPIError) Error() string {
description := strings.TrimSpace(e.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", e.HTTPStatus)
}
return "Telegram 发送失败: " + description
}
type telegramSentMessage struct {
MessageID int64 `json:"message_id"`
}
type telegramFormattedMessage struct {
HTML string
PlainText string
OTP string
}
func normalizeTelegramBodyMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "full") {
return "full"
}
return "summary"
}
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
var bot struct {
Username string `json:"username"`
}
if err := a.callTelegram(r.Context(), token, "getMe", map[string]any{}, &bot); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
if strings.TrimSpace(bot.Username) == "" {
respondError(w, http.StatusBadGateway, "Telegram 机器人没有可用的用户名")
return
}
code, err := newTelegramPairingCode()
if err != nil {
respondError(w, http.StatusInternalServerError, "无法生成 Telegram 绑定码")
return
}
expiresAt := a.now().UTC().Add(telegramPairingTTL)
a.telegramPairMu.Lock()
for value, pairing := range a.telegramPairs {
if !pairing.ExpiresAt.After(a.now().UTC()) {
delete(a.telegramPairs, value)
}
}
a.telegramPairs[code] = telegramPairing{TokenFingerprint: telegramTokenFingerprint(token), ExpiresAt: expiresAt}
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{
"code": code,
"botUsername": bot.Username,
"deepLink": "https://t.me/" + url.PathEscape(bot.Username) + "?start=" + url.QueryEscape(code),
"expiresAt": expiresAt.Format(time.RFC3339Nano),
})
}
func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
if token == "" || code == "" {
badRequest(w, errors.New("请先生成 Telegram 一次性绑定码"))
return
}
a.telegramPairMu.Lock()
pairing, ok := a.telegramPairs[code]
a.telegramPairMu.Unlock()
if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
badRequest(w, errors.New("Telegram 绑定码无效或已过期,请重新生成"))
return
}
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token, code)
if err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
a.telegramPairMu.Lock()
delete(a.telegramPairs, code)
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName})
}
func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token, chatID := a.telegramCredentials(req)
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
if !validTelegramPrivateChatID(chatID) {
badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID"))
return
}
now := a.now().Local().Format("2006-01-02 15:04:05 MST")
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html.EscapeString(now)
if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) {
cfg := a.config()
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(cfg.TelegramBotToken)
}
chatID := strings.TrimSpace(req.ChatID)
if chatID == "" {
chatID = strings.TrimSpace(cfg.TelegramPrivateChatID)
}
return token, chatID
}
func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCode string) (string, string, error) {
var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100,
"timeout": 0,
"allowed_updates": []string{"message"},
}, &updates); err != nil {
return "", "", err
}
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
continue
}
text := strings.TrimSpace(message.Text)
if text != pairingCode && text != "/start "+pairingCode {
continue
}
name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
if name == "" && message.Chat.Username != "" {
name = "@" + message.Chat.Username
}
return strconv.FormatInt(message.Chat.ID, 10), name, nil
}
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(raw)), nil
}
func telegramTokenFingerprint(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func telegramMailboxAllowed(cfg Config, mailboxID string) bool {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" {
return cfg.TelegramIncludeUnregistered
}
for _, id := range cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")) {
if id == mailboxID {
return true
}
}
return false
}
func (a *App) activeTelegramMailboxIDs(ctx context.Context, values []string) []string {
ids := cleanIDList(values)
active := make([]string, 0, len(ids))
for _, id := range ids {
var exists int
if err := a.db.QueryRowContext(ctx, `SELECT 1 FROM mailboxes WHERE id=? AND status='active'`, id).Scan(&exists); err == nil && exists == 1 {
active = append(active, id)
}
}
return active
}
func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) || !telegramMailboxAllowed(cfg, msg.MailboxID) {
return
}
recipient := normalizeEmail(msg.RecipientAddr)
if recipient == "" && len(msg.To) > 0 {
recipient = normalizeEmail(msg.To[0])
}
body := telegramMessageBody(msg)
otp := detectTelegramOTP(msg.Subject, body)
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
limit := 800
if mode == "full" {
limit = 2600
}
body, truncated := truncateRunes(body, limit)
if truncated {
body += "..."
}
if body == "" {
body = normalizeTelegramText(msg.Snippet)
}
from, _ := truncateRunes(strings.TrimSpace(msg.From), 254)
fromName, _ := truncateRunes(strings.TrimSpace(msg.FromName), 160)
subject, _ := truncateRunes(strings.TrimSpace(msg.Subject), 240)
names := make([]string, 0, min(len(attachments), 5))
for _, attachment := range attachments {
name := sanitizeTelegramAttachmentName(attachment.Filename)
if name != "" {
names = append(names, name)
}
if len(names) >= 5 {
break
}
}
payload := telegramMailPayload{
From: from,
FromName: fromName,
Recipient: recipient,
Subject: subject,
ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
Body: body,
BodyMode: mode,
OTP: otp,
AttachmentNames: names,
AttachmentCount: len(attachments),
}
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil {
a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err)
}
}
func telegramMessageBody(msg storedMessage) string {
text := strings.TrimSpace(msg.BodyText)
text, _ = truncateRunes(text, 128*1024)
if text != "" && looksLikeHTMLDocument(text) {
text = telegramHTMLToText(text)
}
if strings.TrimSpace(text) == "" {
text = telegramHTMLToText(msg.BodyHTML)
}
return stripTelegramQuotedContent(normalizeTelegramText(text))
}
func looksLikeHTMLDocument(value string) bool {
value, _ = truncateRunes(value, 128*1024)
value = strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(value, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
return true
}
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
return len(matches) >= 3
}
func telegramHTMLToText(value string) string {
value = strings.ToValidUTF8(value, "")
value, _ = truncateRunes(value, 128*1024)
doc, err := nethtml.Parse(strings.NewReader(value))
if err != nil {
return stripTags(value)
}
var out strings.Builder
var walk func(*nethtml.Node, bool)
walk = func(node *nethtml.Node, skipped bool) {
if node.Type == nethtml.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "head", "noscript", "svg":
skipped = true
case "br":
if !skipped {
out.WriteByte('\n')
}
}
}
if node.Type == nethtml.TextNode && !skipped {
out.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, skipped)
}
if node.Type == nethtml.ElementNode && !skipped {
switch strings.ToLower(node.Data) {
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
out.WriteByte('\n')
}
}
}
walk(doc, false)
return normalizeTelegramText(out.String())
}
func normalizeTelegramText(value string) string {
value = strings.ReplaceAll(strings.ToValidUTF8(value, ""), "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
lines := strings.Split(value, "\n")
out := make([]string, 0, len(lines))
empty := false
for _, line := range lines {
line = strings.TrimSpace(strings.Map(func(r rune) rune {
if r == '\t' {
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, line))
line = strings.Join(strings.Fields(line), " ")
if line == "" {
if !empty && len(out) > 0 {
out = append(out, "")
}
empty = true
continue
}
empty = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[:]|from[:].+|_{5,})$`)
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
func stripTelegramQuotedContent(value string) string {
lines := strings.Split(value, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
lines = lines[:i]
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func sanitizeTelegramAttachmentName(value string) string {
value = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.ToValidUTF8(value, "")))
value = strings.Join(strings.Fields(value), " ")
value, truncated := truncateRunes(value, 100)
if truncated {
value += "..."
}
return value
}
var (
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
)
func detectTelegramOTP(subject, body string) string {
text := normalizeTelegramText(strings.TrimSpace(subject) + "\n" + body)
keywords := telegramOTPKeywordRe.FindAllStringIndex(text, -1)
if len(keywords) == 0 {
return ""
}
type candidateScore struct {
value string
score int
count int
}
scores := map[string]candidateScore{}
subjectEnd := len(strings.TrimSpace(subject))
for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) {
if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) {
continue
}
if match[1] < len(text) && isTelegramOTPAlphaNumeric(rune(text[match[1]])) {
continue
}
value := strings.ToUpper(text[match[0]:match[1]])
hasDigit := false
for _, r := range value {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
continue
}
if isTelegramOTPNonCode(value) {
continue
}
best := 0
for _, keyword := range keywords {
distance := match[0] - keyword[1]
if distance < 0 {
distance = keyword[0] - match[1]
}
if distance < 0 {
distance = 0
}
score := 0
switch {
case distance <= 16:
score = 100
case distance <= 48:
score = 80
case distance <= 100:
score = 55
}
if match[0] <= subjectEnd {
score += 15
}
if score > best {
best = score
}
}
if best == 0 {
continue
}
current := scores[value]
current.value = value
current.count++
if best > current.score {
current.score = best
}
scores[value] = current
}
items := make([]candidateScore, 0, len(scores))
for _, item := range scores {
item.score += min(item.count-1, 2) * 5
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
if len(items) == 0 || items[0].score < 55 {
return ""
}
if len(items) > 1 && items[1].score >= items[0].score-25 {
return ""
}
return items[0].value
}
func isTelegramOTPNonCode(value string) bool {
if len(value) == 4 {
if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 {
return true
}
}
if len(value) == 8 {
if _, err := time.Parse("20060102", value); err == nil {
return true
}
}
return false
}
func isTelegramOTPAlphaNumeric(r rune) bool {
return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r))
}
func (a *App) shouldNotifyTelegramMessage(ctx context.Context, messageID string) bool {
var folder string
if err := a.db.QueryRowContext(ctx, `SELECT lower(COALESCE(NULLIF(f.role,''),f.name,'')) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, messageID).Scan(&folder); err != nil {
return false
}
switch strings.TrimSpace(folder) {
case "spam", "junk", "trash", "deleted":
return false
default:
return true
}
}
func (a *App) telegramMailWorker(ctx context.Context) {
a.log.Info("Telegram mail notification worker started")
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.log.Warn("Telegram mail notification worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("Telegram mail notification worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
_, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil
}
nowText := a.now().UTC().Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
if err != nil {
return err
}
type queueItem struct {
id string
payload telegramMailPayload
attempt int
invalid bool
}
items := []queueItem{}
for rows.Next() {
var item queueItem
var raw string
if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil {
rows.Close()
return err
}
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
item.invalid = true
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
if item.invalid {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
return err
}
continue
}
now := a.now().UTC()
leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
continue
}
formatted := formatTelegramMailMessage(item.payload)
telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
now = a.now().UTC()
if err != nil {
attempts := item.attempt + 1
delay := sendRetryDelay(attempts)
var apiErr *telegramAPIError
if errors.As(err, &apiErr) {
if apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
code := apiErr.ErrorCode
if code == 0 {
code = apiErr.HTTPStatus
}
if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
attempts = telegramMailMaxAttempts
}
}
next := now.Add(delay)
if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
return updateErr
}
continue
}
stamp := now.Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=?,lease_until='',telegram_message_id=?,payload_json='{}' WHERE id=? AND delivered_at IS NULL`, stamp, stamp, telegramMessageID, item.id); err != nil {
return err
}
}
return nil
}
func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
subject, _ = truncateRunes(subject, 180)
from, _ = truncateRunes(from, 220)
recipient, _ := truncateRunes(strings.TrimSpace(payload.Recipient), 160)
lines := []string{
"📩 <b>新邮件通知</b>",
"",
"<b>主题:</b>" + escapeTelegramWithinBudget(subject, 420),
"<b>发件人:</b>" + escapeTelegramWithinBudget(from, 500),
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
"<b>收件时间:</b>" + html.EscapeString(timeText),
}
if payload.OTP != "" {
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
}
if len(payload.AttachmentNames) > 0 {
names := make([]string, 0, len(payload.AttachmentNames))
for _, name := range payload.AttachmentNames {
names = append(names, escapeTelegramWithinBudget(name, 180))
}
attachmentText := strings.Join(names, "、")
if payload.AttachmentCount > len(payload.AttachmentNames) {
attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames))
}
lines = append(lines, "", fmt.Sprintf("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
}
body := strings.TrimSpace(payload.Body)
if body != "" {
label := "正文摘要"
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
label = "邮件正文"
}
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
lines = []string{prefix + body + suffix}
}
htmlText := strings.Join(lines, "\n")
plain := formatTelegramMailPlainText(payload)
return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP}
}
func formatTelegramBodyHTML(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
appendEscaped := func(text string) bool {
for _, r := range text {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
return false
}
out.WriteString(escaped)
used += length
}
return true
}
last := 0
for _, match := range telegramURLRe.FindAllStringIndex(value, -1) {
if !appendEscaped(value[last:match[0]]) {
truncated = true
break
}
rawURL, trailing := trimTelegramURL(value[match[0]:match[1]])
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
if !appendEscaped(value[match[0]:match[1]]) {
truncated = true
break
}
last = match[1]
continue
}
display := rawURL
if utf8.RuneCountInString(display) > 72 {
display = "🔗 " + parsed.Hostname() + " 链接"
}
anchor := `<a href="` + html.EscapeString(rawURL) + `">` + html.EscapeString(display) + `</a>`
length := utf8.RuneCountInString(anchor)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(anchor)
used += length
if !appendEscaped(trailing) {
truncated = true
break
}
last = match[1]
}
if !truncated && last < len(value) && !appendEscaped(value[last:]) {
truncated = true
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func trimTelegramURL(value string) (string, string) {
trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】")
return trimmed, value[len(trimmed):]
}
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
_, err := a.sendTelegramPayload(ctx, token, map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": true,
})
return err
}
func (a *App) deliverTelegramMailMessage(ctx context.Context, token, chatID string, message telegramFormattedMessage) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"text": message.HTML,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
payload["reply_markup"] = markup
}
result, err := a.sendTelegramPayload(ctx, token, payload)
if err == nil {
return result.MessageID, nil
}
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != http.StatusBadRequest {
return 0, err
}
fallback := map[string]any{
"chat_id": chatID,
"text": message.PlainText,
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
fallback["reply_markup"] = markup
}
result, err = a.sendTelegramPayload(ctx, token, fallback)
if err != nil {
return 0, err
}
return result.MessageID, nil
}
func (a *App) sendTelegramPayload(ctx context.Context, token string, payload map[string]any) (telegramSentMessage, error) {
var result telegramSentMessage
err := a.callTelegram(ctx, token, "sendMessage", payload, &result)
return result, err
}
func telegramCopyMarkup(otp string) map[string]any {
otp = strings.TrimSpace(otp)
if otp == "" || utf8.RuneCountInString(otp) > 256 {
return nil
}
return map[string]any{"inline_keyboard": [][]map[string]any{{{
"text": "复制验证码",
"copy_text": map[string]string{"text": otp},
}}}}
}
func escapeTelegramWithinBudget(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
for _, r := range value {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(escaped)
used += length
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func formatTelegramMailPlainText(payload telegramMailPayload) string {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
parts := []string{"新邮件通知", "", "主题:" + subject, "发件人:" + from, "收件邮箱:" + payload.Recipient, "收件时间:" + timeText}
if payload.OTP != "" {
parts = append(parts, "", "验证码", payload.OTP)
}
if payload.AttachmentCount > 0 {
parts = append(parts, "", fmt.Sprintf("附件:%d 个", payload.AttachmentCount))
}
if body := strings.TrimSpace(payload.Body); body != "" {
parts = append(parts, "", "正文摘要", body)
}
text := normalizeTelegramText(strings.Join(parts, "\n"))
text, truncated := truncateRunes(text, telegramMessageBudget-3)
if truncated {
text += "..."
}
return text
}
func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error {
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, "/\\\r\n") {
return errors.New("Telegram Bot Token 无效")
}
base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/")
endpoint := base + "/bot" + url.PathEscape(token) + "/" + method
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0")
client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
return errors.New("Telegram 请求失败,请检查网络连接和机器人配置")
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
var apiResponse telegramAPIResponse
if err := json.Unmarshal(raw, &apiResponse); err != nil {
return fmt.Errorf("Telegram 返回了无效响应(HTTP %d", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK {
description := strings.TrimSpace(apiResponse.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return &telegramAPIError{HTTPStatus: resp.StatusCode, ErrorCode: apiResponse.ErrorCode, Description: description, RetryAfter: time.Duration(apiResponse.Parameters.RetryAfter) * time.Second}
}
if result != nil && len(apiResponse.Result) > 0 {
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
return err
}
}
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
type sentMessage struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
var sent []sentMessage
var pairingCode atomic.Value
pairingCode.Store("")
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/bottest-token/getMe":
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
case "/bottest-token/getUpdates":
code, _ := pairingCode.Load().(string)
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
case "/bottest-token/sendMessage":
var message sentMessage
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
t.Fatalf("decode Telegram message: %v", err)
}
sent = append(sent, message)
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
default:
http.NotFound(w, r)
}
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
payload["telegramBotToken"] = "test-token"
payload["telegramPrivateChatId"] = "123456789"
payload["telegramBodyMode"] = "full"
var adminMailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
t.Fatal(err)
}
payload["telegramMailboxIds"] = []string{adminMailboxID}
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
}
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
t.Fatalf("unexpected Telegram settings: %+v", settings)
}
if a.config().TelegramBotToken != "test-token" {
t.Fatal("Telegram token was not persisted in runtime config")
}
var pairing struct {
Code string `json:"code"`
DeepLink string `json:"deepLink"`
}
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
}
pairingCode.Store(pairing.Code)
var discovered map[string]string
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
t.Fatalf("discover chat code=%d response=%v", code, discovered)
}
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
t.Fatalf("unexpected discovered chat: %v", discovered)
}
var testResult map[string]any
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
}
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
t.Fatalf("unexpected Telegram test message: %+v", sent)
}
sent = nil
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
MailboxID: adminMailboxID,
RecipientAddr: "admin@example.com",
Subject: "账单 <已生成>",
From: "billing@example.net",
FromName: "Billing & Support",
ReceivedAt: receivedAt,
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatalf("process Telegram mail queue: %v", err)
}
if len(sent) != 1 {
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
}
text := sent[0].Text
for _, expected := range []string{"新邮件通知", "Billing &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "&lt;VIP&gt; &amp; 续费信息"} {
if !strings.Contains(text, expected) {
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
}
}
if sent[0].ReplyMarkup == nil {
t.Fatal("Telegram OTP copy button was not included")
}
var delivered, storedPayload string
var telegramMessageID int64
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
}
if storedPayload != "{}" || telegramMessageID != 8 {
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
}
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
disablePayload := systemSettingsPayload(settings)
disablePayload["telegramMailEnabled"] = false
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
t.Fatalf("disable Telegram settings code=%d", code)
}
var pending int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
}
}
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
a := newTestApp(t)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
var body map[string]any
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
}
}
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
serverURL := server.URL
server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = serverURL
const token = "123456:secret-token-value"
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
if err == nil {
t.Fatal("expected Telegram network request to fail")
}
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
t.Fatalf("Telegram error exposed Bot Token: %v", err)
}
}
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
cleaned := stripTelegramQuotedContent(body)
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
t.Fatalf("unexpected OTP %q", otp)
}
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: strings.Repeat("R&D <team@example.com> ", 30),
Recipient: "admin@example.com",
Subject: strings.Repeat("超长主题 & <test> ", 50),
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
Body: strings.Repeat("正文内容 & <重要> ", 1000),
BodyMode: "full",
OTP: "846981",
AttachmentNames: []string{
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
},
AttachmentCount: 12,
})
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
t.Fatalf("Telegram HTML exceeds budget: %d", got)
}
if !strings.Contains(message.HTML, "&amp;") || !strings.Contains(message.HTML, "&lt;") || !strings.Contains(message.HTML, "<code>846981</code>") {
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
}
if markup := telegramCopyMarkup(message.OTP); markup == nil {
t.Fatal("copy_text markup missing")
}
}
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
body := `---------- Forwarded message ---------
Date: 2026年8月6日周四 17:59
Subject: 登录验证码 (https://www.gate.com)
Gate 检测到您的账号正试图从此 IP 获得登录验证码
IP: 87.83.105.229
如为您本人登录请输入如下验证码完成操作
311665
如非本人操作请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
}
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
})
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
t.Fatalf("normal URL was not linkified: %s", message.HTML)
}
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
}
if strings.Contains(message.HTML, "&lt;a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
}
markup := telegramCopyMarkup(message.OTP)
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
t.Fatalf("copy OTP button missing: %#v", markup)
}
}
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
text := telegramMessageBody(storedMessage{BodyText: pseudo})
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
t.Fatalf("pseudo HTML was not cleaned: %q", text)
}
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
if err != nil {
t.Fatal(err)
}
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
tests := []struct {
name string
status int
response string
retryAfter time.Duration
}{
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.response))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
t.Fatalf("unexpected Telegram error: %#v", err)
}
})
}
}
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
cfg.TelegramMailboxIDs = mailboxID
})
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
}
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if msg.RecipientAddr != "admin@lanqin.local" {
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
return
}
if _, exists := payload["parse_mode"]; exists {
t.Fatal("plain-text fallback still included parse_mode")
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
if err != nil || messageID != 99 || calls.Load() != 2 {
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
}
}
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
})
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
t.Fatal(err)
}
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatal(err)
}
var badAttempts int
var delivered string
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
t.Fatal(err)
}
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
}
}
+7 -3
View File
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
}
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
if !a.cfg.TurnstileEnabled {
if !a.config().TurnstileEnabled {
return nil
}
token = strings.TrimSpace(token)
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" {
return errors.New("turnstile verification required")
}
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
if ip := normalizeRemoteIP(remoteIP); ip != "" {
form.Set("remoteip", ip)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
verifyURL := strings.TrimSpace(a.turnstileURL)
if verifyURL == "" {
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
+104 -5
View File
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
func newTwoFactorRecoveryCode() (string, error) {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "", err
}
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
if len(value) > 10 {
value = value[:10]
}
return value[:5] + "-" + value[5:], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return code
}
func totpProvisioningURI(issuer, account, secret string) string {
issuer = strings.TrimSpace(issuer)
account = strings.TrimSpace(account)
@@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
}
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
return nil, err
}
now := a.now().UTC().Format(time.RFC3339Nano)
codes := make([]string, 0, 8)
for len(codes) < 8 {
code, err := newTwoFactorRecoveryCode()
if err != nil {
return nil, err
}
normalized := normalizeRecoveryCode(code)
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
newID("rcv"), userID, hashToken(normalized), now)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
continue
}
return nil, err
}
codes = append(codes, code)
}
return codes, nil
}
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
normalized := normalizeRecoveryCode(code)
if len(normalized) < 8 {
return false, nil
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var id string
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User
@@ -144,7 +214,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -179,7 +249,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
}
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
@@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
+33
View File
@@ -14,12 +14,19 @@ import (
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
type HTMLPolicy struct{ policy *bluemonday.Policy }
const minimumPasswordLength = 6
func hasMinimumPasswordLength(password string) bool {
return utf8.RuneCountInString(password) >= minimumPasswordLength
}
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowElements("html", "head", "body", "center", "font")
@@ -187,6 +194,32 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
return loginName, nil
}
func cleanUsername(value string) (string, error) {
username, err := cleanLoginName(value)
if err != nil {
return "", err
}
if strings.Contains(username, "@") {
return "", errors.New("登录名不能使用邮箱地址")
}
return username, nil
}
func cleanPrimaryEmail(value string) (string, error) {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") {
return "", errors.New("邮箱地址无效")
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", errors.New("邮箱地址无效")
}
if len([]rune(email)) > 254 {
return "", errors.New("邮箱地址不能超过 254 个字符")
}
return email, nil
}
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
+3 -8
View File
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-font-family": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
"@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.10",
"dompurify": "3.4.12",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.30.4",
"react-router-dom": "7.18.2",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20",
"postcss": "8.5.15",
"postcss": "8.5.25",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3",
@@ -1,4 +1,3 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+2 -2
View File
@@ -30,7 +30,7 @@ import {
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限配", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "permissionGroups", label: "权限配", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
@@ -108,7 +108,7 @@ function ProtectedContent() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</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" ? "管理员" : "普通用户"}
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
const update = useMutation({
mutationFn: async () => {
setUpdatePhase("starting")
const result = await api.updateSystem()
const targetVersion = version.data?.latestVersion
let result: Awaited<ReturnType<typeof api.updateSystem>>
try {
result = await api.updateSystem()
} catch (error) {
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
result = {
ok: true,
currentVersion,
targetVersion,
message: "更新请求已发送,正在等待服务恢复",
}
}
setUpdatePhase("restarting")
await waitForUpdatedService(result.targetVersion)
return result
@@ -69,11 +81,11 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
</div>
</DialogHeader>
<div className="space-y-4">
<div className="border-b pb-4 text-center">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">{version.data.latestVersion}</div>}
<div className="space-y-3">
<div className="border-b pb-3 text-center">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">{version.data.latestVersion}</div>}
</div>
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
@@ -91,7 +103,7 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
{version.data?.releaseNotes && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
<div className="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
{version.data.releaseNotes}
</div>
</div>
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function isUpdateConnectionInterruption(error: unknown) {
if (!(error instanceof Error)) return false
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
}
-43
View File
@@ -1,43 +0,0 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+2 -1
View File
@@ -24,6 +24,7 @@
--input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%;
--radius: 0.5rem;
--app-sidebar-width: 18rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%;
@@ -85,7 +86,7 @@
@layer components {
.mail-shell-grid {
display: grid;
grid-template-columns: clamp(236px, 17vw, 344px) minmax(0, 1fr);
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
}
.mail-content-grid {
+11 -1
View File
@@ -233,10 +233,20 @@ export type SystemSettings = {
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
telegramMailEnabled: boolean
telegramBotTokenSet: boolean
telegramPrivateChatId: string
telegramBodyMode: "summary" | "full"
telegramMailboxIds: string[]
telegramIncludeUnregistered: boolean
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
export type TelegramPrivateChat = { chatId: string; displayName: string }
export type TelegramPairing = { code: string; botUsername: string; deepLink: string; expiresAt: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
export type TwoFactorEnableResponse = { user: User; recoveryCodes: string[] }
export type BulkMoveResult = { ok: boolean; moved: number; failed: number; message: string; items: { id: string; mailboxId?: string; ok: boolean; message: string }[] }
+20 -18
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
@@ -69,22 +69,13 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
}
}
async function requestFile(path: string): Promise<Blob> {
const res = await fetch(path, { credentials: "include" })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
}
return res.blob()
}
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
try {
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
if (!res.ok) {
if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试")
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
@@ -111,7 +102,7 @@ export const api = {
updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }),
setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }),
enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
enableTwoFactor: (code: string) => request<TwoFactorEnableResponse>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }),
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
@@ -163,8 +154,8 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { 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 }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -172,7 +163,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -206,6 +197,9 @@ export const api = {
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
@@ -229,7 +223,13 @@ export const api = {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
},
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
deleteFolder: (id: string, mailboxId?: string, folderName?: string) => {
const query = new URLSearchParams()
if (mailboxId) query.set("mailboxId", mailboxId)
if (folderName) query.set("folderName", folderName)
const suffix = query.toString()
return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" })
},
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
@@ -254,12 +254,13 @@ export const api = {
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string; messageIds?: string[] }) => {
const query = new URLSearchParams({ view: params.view })
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.folder) query.set("folder", params.folder)
if (params.labelId) query.set("labelId", params.labelId)
return requestFile(`/api/mail/export?${query.toString()}`)
params.messageIds?.forEach((id) => query.append("messageId", id))
return `/api/mail/export?${query.toString()}`
},
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
const form = new FormData()
@@ -297,5 +298,6 @@ export const api = {
addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }),
removeLabel: (id: string, labelID: string) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels/${labelID}`, { method: "DELETE" }),
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
bulkMove: (ids: string[], folder: string) => request<BulkMoveResult>("/api/mail/messages/bulk-move", { method: "POST", body: JSON.stringify({ ids, folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
}
+7
View File
@@ -69,6 +69,7 @@ const exactTranslations: Record<string, Translation> = {
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
@@ -179,6 +180,12 @@ const exactTranslations: Record<string, Translation> = {
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
+10 -7
View File
@@ -6,14 +6,15 @@ import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import { NotFoundPage } from "@/pages/not-found"
import "./index.css"
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">...</div>}>
<RouterProvider router={router} />
</React.Suspense>
<Toaster />
<LanguageDomSync />
</QueryClientProvider>
+246 -67
View File
@@ -24,15 +24,16 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
import { useMe } from "@/hooks/use-me"
import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types"
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
permissionGroups: { label: "权限配", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" },
permissionGroups: { label: "权限配", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
@@ -57,7 +58,15 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email
const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
function compareAdminUsers(left: AdminUser, right: AdminUser) {
if (left.role === "admin" && right.role !== "admin") return -1
if (left.role !== "admin" && right.role === "admin") return 1
return accountPrimaryEmail(left).localeCompare(accountPrimaryEmail(right), "en", { sensitivity: "base" }) ||
left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) ||
left.createdAt.localeCompare(right.createdAt)
}
export function AdminPage() {
const qc = useQueryClient()
@@ -78,7 +87,7 @@ export function AdminPage() {
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
const [params, setParams] = useSearchParams()
@@ -132,7 +141,7 @@ export function AdminPage() {
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
</main>
</ScrollArea>
)
@@ -244,14 +253,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const canCreate = hasPermission(user, "admin.users.create")
const canDelete = hasPermission(user, "admin.users.delete")
const filteredUsers = users.filter((user) => {
const keyword = query.trim().toLowerCase()
const loginName = accountLoginName(user)
const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
const matchesRole = roleFilter === "all" || user.role === roleFilter
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus
})
const filteredUsers = users
.filter((user) => {
const keyword = query.trim().toLowerCase()
const primaryEmail = accountPrimaryEmail(user)
const matchesKeyword = !keyword || [primaryEmail, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
const matchesRole = roleFilter === "all" || user.role === roleFilter
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus
})
.sort(compareAdminUsers)
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return (
<Card>
@@ -290,9 +301,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{user.displayName}</div>
<div className="truncate text-xs text-muted-foreground">{accountLoginName(user)}</div>
<div className="truncate text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
</div>
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
</div>
<div className="mt-3 flex flex-wrap gap-2">
<RoleBadge user={user} />
@@ -306,20 +317,20 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</div>
<div className="hidden md:block">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-[22rem]"></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-[22rem]"></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.displayName}</div>
<div className="text-xs text-muted-foreground">{accountLoginName(user)}</div>
<div className="text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
</TableCell>
<TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow>
))}
</TableBody>
@@ -348,7 +359,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
onSuccess: () => {
setPendingConfirm(null)
invalidateAdmin(qc)
toast({ title: "权限配已删除" })
toast({ title: "权限配已删除" })
},
onError: (e) => toast({ title: "删除失败", description: e.message }),
})
@@ -357,20 +368,20 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
if (!keyword) return true
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
})
const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin"
const isEditable = (group: PermissionGroup) => !group.system
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
<CardTitle></CardTitle>
{canCreate && <PermissionGroupDialog catalog={catalog} />}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配、说明或权限键" className="pl-9" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配、说明或权限键" className="pl-9" />
</div>
<div className="grid gap-3 lg:grid-cols-2">
{filtered.map((group) => (
@@ -388,14 +399,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{(canUpdate || canDelete) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
disabled={!isDeletable(group) || !canDelete}
onSelect={() => setPendingConfirm({ title: "删除权限配", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配", onConfirm: () => remove.mutate(group.id) })}
onSelect={() => setPendingConfirm({ title: "删除权限配", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配", onConfirm: () => remove.mutate(group.id) })}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>}
@@ -405,7 +416,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
</div>
))}
</div>
{filtered.length === 0 && <Empty text="暂无匹配的权限配" />}
{filtered.length === 0 && <Empty text="暂无匹配的权限配" />}
</CardContent>
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -422,13 +433,13 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultLimits)
React.useEffect(() => {
if (dialogOpen) {
setPermissions(group?.permissions || [])
setLimits(group?.limits || defaultPermissionLimits)
setLimits(group?.limits || defaultLimits)
}
}, [dialogOpen, group])
}, [defaultLimits, dialogOpen, group])
const mutation = useMutation({
mutationFn: (form: FormData) => {
const payload = {
@@ -442,20 +453,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
onSuccess: () => {
invalidateAdmin(qc)
setDialogOpen(false)
toast({ title: group ? "权限配已更新" : "权限配已创建" })
toast({ title: group ? "权限配已更新" : "权限配已创建" })
},
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
})
const trigger = group ? null : (
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4" /></Button>
<Button size="sm"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
)
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger}
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group ? "编辑权限配" : "创建权限配"}</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{group ? "编辑权限配" : "创建权限配"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2">
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
@@ -1018,7 +1029,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
)
}
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { settings?: SystemSettings; domains: Domain[]; mailboxes: MailboxType[]; initialTab?: string | null }) {
const me = useMe()
const user = me.data?.user
const qc = useQueryClient()
@@ -1030,7 +1041,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
const requestedTab = initialTab as SettingsTab | undefined
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
@@ -1043,6 +1055,13 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false)
const [telegramBotToken, setTelegramBotToken] = React.useState("")
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("")
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary")
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>([])
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false)
const [telegramPairing, setTelegramPairing] = React.useState<TelegramPairing | null>(null)
React.useEffect(() => {
if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls)
@@ -1056,7 +1075,36 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
setExternalImapEnabled(settings.externalImapEnabled)
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
setTelegramMailEnabled(settings.telegramMailEnabled)
setTelegramBotToken("")
setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
setTelegramMailboxIds(settings.telegramMailboxIds || [])
setTelegramIncludeUnregistered(settings.telegramIncludeUnregistered)
setTelegramPairing(null)
}, [settings])
const createTelegramPairing = useMutation({
mutationFn: () => api.createTelegramPairing(telegramBotToken),
onSuccess: (pairing) => {
setTelegramPairing(pairing)
toast({ title: "Telegram 绑定码已生成" })
},
onError: (error) => toast({ title: "生成失败", description: error.message }),
})
const discoverTelegram = useMutation({
mutationFn: () => api.discoverTelegramChat(telegramBotToken, telegramPairing?.code || ""),
onSuccess: (chat) => {
setTelegramPrivateChatId(chat.chatId)
setTelegramPairing(null)
toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId })
},
onError: (error) => toast({ title: "获取失败", description: error.message }),
})
const testTelegram = useMutation({
mutationFn: () => api.testTelegram(telegramBotToken, telegramPrivateChatId),
onSuccess: () => toast({ title: "Telegram 测试通知已发送" }),
onError: (error) => toast({ title: "发送失败", description: error.message }),
})
const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
@@ -1089,6 +1137,12 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
telegramMailEnabled,
telegramBotToken,
telegramPrivateChatId,
telegramBodyMode,
telegramMailboxIds,
telegramIncludeUnregistered,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -1130,6 +1184,12 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
settings.externalImapGmailClientSecretSet,
settings.externalImapOutlookClientId,
settings.externalImapOutlookClientSecretSet,
settings.telegramMailEnabled,
settings.telegramBotTokenSet,
settings.telegramPrivateChatId,
settings.telegramBodyMode,
(settings.telegramMailboxIds || []).join(","),
settings.telegramIncludeUnregistered,
].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [
...(canSettingsView ? [
@@ -1137,6 +1197,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
{ key: "smtp" as const, label: "SMTP" },
{ key: "storage" as const, label: "存储" },
{ key: "mail" as const, label: "邮件" },
{ key: "notifications" as const, label: "通知" },
{ key: "externalImap" as const, label: "外部 IMAP" },
] : []),
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
@@ -1246,6 +1307,85 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
</CardContent>
</Card>}
{settingsTab === "notifications" && <Card>
<CardHeader><CardTitle>Telegram </CardTitle></CardHeader>
<CardContent className="space-y-5">
<SwitchRow label="私聊新邮件通知" checked={telegramMailEnabled} onCheckedChange={setTelegramMailEnabled} />
{telegramMailEnabled && (
<div className="space-y-5 border-t pt-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Bot Token</Label>
<Input type="password" value={telegramBotToken} onChange={(event) => setTelegramBotToken(event.target.value)} placeholder={settings?.telegramBotTokenSet ? "已保存,留空不变" : "123456789:..."} />
</div>
<div className="space-y-2">
<Label> Chat ID</Label>
<div className="flex gap-2">
<Input inputMode="numeric" value={telegramPrivateChatId} onChange={(event) => setTelegramPrivateChatId(event.target.value)} placeholder="123456789" />
<Button type="button" variant="outline" className="shrink-0" disabled={createTelegramPairing.isPending} onClick={() => createTelegramPairing.mutate()}>
<ShieldCheck className="mr-2 h-4 w-4" />{createTelegramPairing.isPending ? "生成中" : "安全绑定"}
</Button>
</div>
{telegramPairing && (
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild type="button" variant="outline" size="sm">
<a href={telegramPairing.deepLink} target="_blank" rel="noreferrer"><ExternalLink className="mr-2 h-4 w-4" /></a>
</Button>
<Button type="button" size="sm" disabled={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}>
<CheckCircle2 className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "绑定中" : "完成绑定"}
</Button>
</div>
</div>
)}
</div>
</div>
<div className="space-y-3 border-t pt-5">
<Label></Label>
<div className="grid gap-2 md:grid-cols-2">
{mailboxes.filter((mailbox) => mailbox.status === "active").map((mailbox) => (
<label key={mailbox.id} className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
<Checkbox
checked={telegramMailboxIds.includes(mailbox.id)}
onCheckedChange={(checked) => setTelegramMailboxIds((items) => checked === true ? Array.from(new Set([...items, mailbox.id])) : items.filter((id) => id !== mailbox.id))}
/>
<span className="min-w-0 truncate text-sm font-medium">{mailbox.address}</span>
</label>
))}
<label className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
<Checkbox checked={telegramIncludeUnregistered} onCheckedChange={(checked) => setTelegramIncludeUnregistered(checked === true)} />
<span className="text-sm font-medium"></span>
</label>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select value={telegramBodyMode} onValueChange={(value) => setTelegramBodyMode(value === "full" ? "full" : "summary")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="summary"></SelectItem>
<SelectItem value="full"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="button" variant="outline" disabled={testTelegram.isPending || !telegramPrivateChatId} onClick={() => testTelegram.mutate()}>
<Mail className="mr-2 h-4 w-4" />{testTelegram.isPending ? "发送中" : "测试通知"}
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>}
{settingsTab === "externalImap" && <Card>
<CardHeader>
<CardTitle> IMAP </CardTitle>
@@ -1673,7 +1813,7 @@ function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQ
function UserMailboxCell({ user }: { user: AdminUser }) {
const { toast } = useToast()
const loginAddress = accountLoginName(user)
const loginAddress = accountPrimaryEmail(user)
const mailboxes = user.mailboxes || []
const [mailboxQuery, setMailboxQuery] = React.useState("")
const normalizedQuery = mailboxQuery.trim().toLowerCase()
@@ -1774,7 +1914,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
}
return (
<div className="space-y-2">
<Label></Label>
<Label></Label>
<div className="grid gap-2 md:grid-cols-2">
{groups.map((group) => {
const checked = value.includes(group.id)
@@ -1789,7 +1929,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
)
})}
</div>
{groups.length === 0 && <Empty text="暂无可分配权限配" />}
{groups.length === 0 && <Empty text="暂无可分配自定义权限配" />}
</div>
)
}
@@ -1813,13 +1953,14 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
const canUpdate = hasPermission(currentUser, "admin.users.update")
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
const update = useMutation({
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
mutationFn: (payload: { email: string; displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) },
onError: (e) => toast({ title: "更新失败", description: e.message }),
})
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
const role = patch.role || user.role
function quickPatch(patch: Partial<{ disabled: boolean }>) {
const role = user.role
update.mutate({
email: accountPrimaryEmail(user),
displayName: user.displayName,
role,
disabled: patch.disabled ?? user.disabled,
@@ -1827,27 +1968,24 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
})
}
if (!canUpdate && !canResetPassword && !onDelete) return null
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
}
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [role, setRole] = React.useState<"admin" | "user">("user")
const [status, setStatus] = React.useState("active")
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
const create = useMutation({
mutationFn: (form: FormData) => api.createUser({
loginName: String(form.get("loginName") || ""),
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""),
role,
role: "user",
disabled: status === "disabled",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined,
permissionGroupIds: role === "user" ? permissionGroupIds : [],
mailboxLimitOverride: mailboxLimitFromForm(form),
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) },
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
@@ -1856,15 +1994,14 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="loginName" label="登录" type="text" autoComplete="off" placeholder="admin" />
<Field name="email" label="登录邮箱" type="email" autoComplete="off" placeholder="user@example.com" />
<Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={8} />
<Field name="password" label="初始密码" type="password" minLength={6} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
</div>
{role === "user" && <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />}
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
</form>
</DialogContent>
@@ -1896,6 +2033,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
}, [user, open])
const mut = useMutation({
mutationFn: (form: FormData) => api.updateUser(user.id, {
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
role,
disabled: disabled === "disabled",
@@ -1910,11 +2048,11 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<Field name="loginName" label="登录" value={accountLoginName(user)} readOnly />
<Field name="email" label="登录邮箱" defaultValue={accountPrimaryEmail(user)} type="email" autoComplete="off" />
<Field name="displayName" label="显示名称" defaultValue={user.displayName} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected} />
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
</div>
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
@@ -1927,7 +2065,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
}
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -1944,10 +2082,56 @@ function CreateDomainDialog() {
}
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
const [ownerMode, setOwnerMode] = React.useState("new")
const [userId, setUserId] = React.useState("")
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
if (!userId && users[0]) setUserId(users[0].id)
}, [domains, domainId, users, userId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createMailbox({
domainId,
localPart: String(form.get("localPart")),
displayName: String(form.get("displayName")),
password: String(form.get("password")),
quotaMb: Number(form.get("quotaMb") || 1024),
role: "user",
ownerEmail: String(form.get("ownerEmail") || ""),
userId: ownerMode === "existing" ? userId : "",
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
<Field name="displayName" label="显示名" placeholder="Alice" />
</div>
<SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[["new", "新建/按主登录邮箱匹配账号"], ["existing", "追加到已有账号"]]} />
{ownerMode === "existing" ? (
<SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} />
) : (
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
)}
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
@@ -1978,11 +2162,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
}
const dnsDescriptions: Record<string, string> = {
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
TXT: "", // 具体含义根据内容区分
}
function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
+5 -5
View File
@@ -21,7 +21,7 @@ export function LoginPage() {
const login = useMutation({
mutationFn: (form: FormData) => challengeToken
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
: api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }),
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken)
@@ -49,8 +49,8 @@ export function LoginPage() {
{!challengeToken ? (
<>
<div className="space-y-2">
<Label htmlFor="loginName" className="text-sm font-medium"></Label>
<Input id="loginName" name="loginName" type="text" autoComplete="username" required className="h-11 text-base" />
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -59,8 +59,8 @@ export function LoginPage() {
</>
) : (
<div className="space-y-2">
<Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label>
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-center text-lg tracking-[0.35em]" />
<Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label>
<Input id="twoFactorCode" name="twoFactorCode" autoComplete="one-time-code" minLength={6} required className="h-11 text-center text-lg" />
</div>
)}
{!challengeToken && turnstileRequired && (
+132 -105
View File
@@ -11,8 +11,8 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode"
@@ -88,10 +88,28 @@ const filterLabels: Record<MailFilter, string> = {
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
const mailboxSelectionStorageVersion = "2"
const mailImportBatchBytes = 32 * 1024 * 1024
const mailImportBatchFiles = 20
const mailCompactBreakpoint = 768
const mailDetailBreakpoint = 768
function buildMailImportBatches(files: File[]) {
const batches: File[][] = []
let batch: File[] = []
let batchBytes = 0
for (const file of files) {
if (batch.length > 0 && (batch.length >= mailImportBatchFiles || batchBytes + file.size > mailImportBatchBytes)) {
batches.push(batch)
batch = []
batchBytes = 0
}
batch.push(file)
batchBytes += file.size
}
if (batch.length > 0) batches.push(batch)
return batches
}
function useMaxViewportWidth(maxWidth: number) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
@@ -122,10 +140,7 @@ export function MailPage() {
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
const sidebarCollapsed = false
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => {
if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all"
return localStorage.getItem("lanqin:selected-mailbox") || "all"
})
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
const [externalFolder, setExternalFolder] = React.useState("INBOX")
@@ -140,7 +155,7 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -177,6 +192,11 @@ export function MailPage() {
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
React.useEffect(() => {
if (!user) return
void import("@/pages/profile")
}, [user])
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled })
const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId])
@@ -190,6 +210,8 @@ export function MailPage() {
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
const canManageFolders = canOrganizeMail && hasMailboxes
const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
@@ -444,7 +466,7 @@ export function MailPage() {
onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }),
})
const deleteFolder = useMutation({
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId),
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId, item.folderName),
onSuccess: async (result, item) => {
setPendingConfirm(null)
if (mailView === "folder" && folder === item.folderName) {
@@ -491,14 +513,19 @@ export function MailPage() {
}
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
setSelectedMailboxId("all")
setSelectedExternalAccountId("")
setFolder("Inbox")
setMailView("folder")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
}
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
else localStorage.removeItem("lanqin:selected-mailbox")
localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion)
}, [selectedMailboxId])
localStorage.removeItem("lanqin:selected-mailbox")
localStorage.removeItem("lanqin:selected-mailbox-version")
}, [])
React.useEffect(() => {
setSelectedId(null)
@@ -710,6 +737,8 @@ export function MailPage() {
async function runConfirmedBulkAction(action: BulkAction, ids: string[]) {
setBulkPending(true)
try {
let completionTitle = `已处理 ${ids.length} 封邮件`
let completionDescription: string | undefined
if (action === "read" || action === "unread") {
const read = action === "read"
await Promise.all(ids.map((id) => api.markRead(id, read)))
@@ -720,13 +749,15 @@ export function MailPage() {
await Promise.all(ids.map((id) => api.delete(id)))
} else {
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
await Promise.all(ids.map((id) => api.move(id, target)))
const result = await api.bulkMove(ids, target)
completionTitle = result.ok ? result.message : "批量移动部分失败"
completionDescription = result.ok ? undefined : result.message
}
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([])
setPendingConfirm(null)
await refreshMailData()
toast({ title: `已处理 ${ids.length} 封邮件` })
toast({ title: completionTitle, description: completionDescription })
} catch (error) {
toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
@@ -739,11 +770,11 @@ export function MailPage() {
if (ids.length === 0) return
setBulkPending(true)
try {
await Promise.all(ids.map((id) => api.move(id, folderName)))
const result = await api.bulkMove(ids, folderName)
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([])
await refreshMailData()
toast({ title: folderName === "Inbox" ? `已将 ${ids.length} 封邮件移回收件箱` : `已移动 ${ids.length} 封邮件` })
toast({ title: result.ok ? (folderName === "Inbox" ? `已将 ${result.moved} 封邮件移回收件箱` : `已移动 ${result.moved} 封邮件`) : "批量移动部分失败", description: result.ok ? undefined : result.message })
} catch (error) {
toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
@@ -946,7 +977,9 @@ export function MailPage() {
if (item.type !== "folder" || !item.custom) return
setPendingConfirm({
title: `删除文件夹“${item.label}”?`,
description: "文件夹内的邮件会移回收件箱,不会被删除。",
description: isAllMailboxSelected
? "所有邮箱中的同名文件夹都会删除,文件夹内邮件会移回各自的收件箱。"
: "文件夹内的邮件会移回收件箱,不会被删除。",
confirmText: "删除文件夹",
onConfirm: () => deleteFolder.mutate(item),
})
@@ -1075,28 +1108,22 @@ export function MailPage() {
async function exportCurrentMail() {
if (!canExportCurrentView || exportingMail) return
setExportingMail(true)
try {
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
const blob = await api.exportMail({
view: exportView,
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
folder: exportView === "folder" ? folder : undefined,
labelId: exportView === "label" ? selectedLabelId : undefined,
})
const href = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = href
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(href), 1000)
toast({ title: "邮件已导出", description: `${viewTitle} 已打包为 ZIP` })
} catch (error) {
toast({ title: "导出失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
setExportingMail(false)
}
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
const selectedMessageIds = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
const anchor = document.createElement("a")
anchor.href = api.exportMailUrl({
view: exportView,
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
folder: exportView === "folder" ? folder : undefined,
labelId: exportView === "label" ? selectedLabelId : undefined,
messageIds: selectedMessageIds.length > 0 ? selectedMessageIds : undefined,
})
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
toast({ title: "已开始下载", description: selectedMessageIds.length > 0 ? `正在打包选中的 ${selectedMessageIds.length} 封邮件。` : "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" })
window.setTimeout(() => setExportingMail(false), 1000)
}
function chooseMailImport() {
if (!canImportCurrentView || importingMail) {
@@ -1111,8 +1138,22 @@ export function MailPage() {
if (files.length === 0 || !selectedMailbox) return
setImportingMail(true)
try {
const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" })
const batches = buildMailImportBatches(files)
const target = { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" }
const result = { imported: 0, skipped: 0, errors: [] as string[] }
for (const batch of batches) {
try {
const current = await api.importMail(batch, target)
result.imported += current.imported
result.skipped += current.skipped
result.errors.push(...current.errors)
} catch (error) {
result.skipped += batch.length
result.errors.push(error instanceof Error ? error.message : "导入请求失败")
}
}
await refreshMailData()
if (result.imported === 0 && result.errors.length > 0) throw new Error(result.errors[0])
toast({
title: `已导入 ${result.imported} 封邮件`,
description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
@@ -1129,7 +1170,7 @@ export function MailPage() {
toast({ title: "邮箱地址已复制" })
}
function openSettings() {
navigate("/profile")
void import("@/pages/profile").then(() => navigate("/profile"))
}
function toggleAdvancedSearch() {
setAdvancedSearchDraft(advancedSearch)
@@ -1182,27 +1223,26 @@ export function MailPage() {
onLanguageChange={setLanguage}
onSettings={openSettings}
/>
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : "grid grid-cols-[minmax(0,1fr)_2rem]")}>
<div className={cn("relative mt-2", sidebarCollapsed && "flex justify-center")}>
<MailboxSwitcher
collapsed={sidebarCollapsed}
mailboxes={mailboxList.data?.items || []}
loading={mailboxList.isLoading}
selectedMailboxId={selectedMailboxId}
selectedMailbox={selectedMailbox}
fallbackAddress={selectedMailbox?.address || me.data?.user.email || ""}
unreadCount={mailboxUnreadCount}
hasCopyAction={showMailboxCopy}
onSelect={switchMailbox}
/>
{!sidebarCollapsed && (
{!sidebarCollapsed && showMailboxCopy && (
<Button
type="button"
variant="outline"
variant="ghost"
size="icon"
className={cn("h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background", isAllMailboxSelected && "invisible pointer-events-none")}
className="absolute right-1 top-1 h-6 w-6 rounded-sm text-muted-foreground shadow-none hover:bg-accent hover:text-foreground"
onClick={copyCurrentMailbox}
disabled={!selectedMailbox || isAllMailboxSelected}
disabled={!selectedMailbox}
aria-label="复制邮箱地址"
aria-hidden={isAllMailboxSelected}
tabIndex={isAllMailboxSelected ? -1 : 0}
title="复制邮箱地址"
>
<Copy className="h-3.5 w-3.5" />
@@ -1319,12 +1359,12 @@ export function MailPage() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>}
{(customMailMenuItems.length > 0 || canOrganizeMail) && <SidebarGroup>
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
{!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1">
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" /></SidebarGroupLabel>
{canOrganizeCurrentMailbox && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)} disabled={!selectedMailbox}>
{canManageFolders && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
@@ -1449,7 +1489,7 @@ export function MailPage() {
const mailTransferTools = isTransferView ? (
<div className="flex shrink-0 items-center gap-0.5">
<Button type="button" size="icon" variant="ghost" onClick={() => void exportCurrentMail()} disabled={!canExportCurrentView || exportingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground" title="导出当前邮箱邮件为 ZIP" aria-label="导出当前邮箱邮件为 ZIP">
<Button type="button" size="icon" variant="ghost" onClick={() => void exportCurrentMail()} disabled={!canExportCurrentView || exportingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground" title={selectedCountOnPage > 0 ? `下载选中的 ${selectedCountOnPage} 封邮件` : "导出当前邮箱邮件为 ZIP"} aria-label={selectedCountOnPage > 0 ? `下载选中的 ${selectedCountOnPage} 封邮件` : "导出当前邮箱邮件为 ZIP"}>
<Download className={cn("h-4 w-4", exportingMail && "animate-pulse")} />
</Button>
{mailView !== "unknown" && (
@@ -1468,7 +1508,7 @@ export function MailPage() {
) : !canReadMail ? (
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
<NoMailboxState onOpenSettings={openSettings} />
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && canScheduleMail ? (
<ScheduledSendView
compact={compactMailLayout}
@@ -1751,8 +1791,10 @@ export function MailPage() {
/>
<SidebarContextMenu
state={sidebarContextMenu}
canOrganize={canOrganizeMail}
pending={reorderFolders.isPending}
canCreate={canManageFolders}
canReorder={canOrganizeCurrentMailbox}
canDelete={canManageFolders}
pending={reorderFolders.isPending || deleteFolder.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
closeSidebarContextMenu()
@@ -2061,7 +2103,7 @@ function externalAccountSubtitle(account: ExternalImapAccount) {
return [name, account.host, mode].filter(Boolean).join(" · ")
}
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) {
return (
<div className="grid min-h-0 flex-1 place-items-center p-6">
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
@@ -2069,9 +2111,9 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
<Mail className="h-5 w-5 text-muted-foreground" />
</div>
<div className="text-lg font-semibold"></div>
<div className="mt-2 text-sm text-muted-foreground"></div>
<Button className="mt-5" onClick={onOpenSettings}>
<Settings className="h-4 w-4" />
<div className="mt-2 text-sm text-muted-foreground"></div>
<Button className="mt-5" onClick={onManageMailboxes}>
<MailboxIcon className="h-4 w-4" />
</Button>
</div>
</div>
@@ -2422,7 +2464,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
)
}
function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canOrganize: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canCreate: boolean; canReorder: boolean; canDelete: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
React.useEffect(() => {
if (!state) return
const close = () => onClose()
@@ -2460,30 +2502,32 @@ function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRe
<Button type="button" variant="ghost" className={itemClass} onClick={onRefresh}>
<RefreshCcw className="h-4 w-4" />
</Button>
{canOrganize && (
{canCreate && (
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</Button>
)}
{canOrganize && customFolder && (
{customFolder && (canReorder || canDelete) && (
<>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
{canReorder && <>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
</>}
{canReorder && canDelete && <div className="my-1 h-px bg-border" />}
{canDelete && <Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>}
</>
)}
</div>
@@ -3177,7 +3221,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme}>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</Button>
<DropdownMenu>
@@ -3195,7 +3239,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
))}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onSettings}>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onSettings} title="设置" aria-label="设置">
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
@@ -3215,10 +3259,11 @@ function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "dange
)
}
function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; unreadCount: number; onSelect: (mailboxId: string) => void }) {
function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, selectedMailbox, unreadCount, hasCopyAction, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; loading: boolean; selectedMailboxId: string; selectedMailbox?: Mailbox; unreadCount: number; hasCopyAction: boolean; onSelect: (mailboxId: string) => void }) {
const [mailboxQuery, setMailboxQuery] = React.useState("")
const isAllSelected = selectedMailboxId === "all"
const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱"
const mailboxUnavailable = loading || mailboxes.length === 0
const displayAddress = loading ? "全部邮箱" : mailboxes.length === 0 ? "未注册邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
const normalizedQuery = mailboxQuery.trim().toLowerCase()
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
@@ -3232,13 +3277,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
return (
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
<Button disabled={mailboxUnavailable} variant="outline" className={cn("h-8 w-full min-w-0 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", hasCopyAction && !collapsed && "pr-9", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
<UnreadBadge count={selectedUnreadCount} />
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{!mailboxUnavailable && <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
</>
)}
</Button>
@@ -3247,7 +3292,7 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
align="start"
className={cn(
"max-w-[calc(100vw-32px)] p-1",
collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
)}
>
{mailboxes.length > 0 && (
@@ -4728,24 +4773,6 @@ function toDateTimeLocalValue(date: Date) {
function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft {
return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() }
}
function scheduleToHtml(schedule: ScheduleDraft) {
const start = parseScheduleStart(schedule)
const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000)
const rows = [
["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`],
["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)],
["提醒", reminderLabel(schedule.reminderMinutes)],
["重复", repeatLabel(schedule.repeat)],
schedule.location ? ["位置", schedule.location] : undefined,
schedule.description ? ["描述", schedule.description] : undefined,
].filter(Boolean) as string[][]
return DOMPurify.sanitize(`
<div style="border:1px solid #d4d4d8;border-radius:8px;padding:14px 16px;margin:16px 0;background:#fafafa;">
<div style="font-weight:600;font-size:16px;margin-bottom:10px;">${escapeHtml(schedule.title)}</div>
${rows.map(([label, value]) => `<div style="margin:6px 0;"><span style="color:#71717a;">${label}</span>${escapeHtml(value)}</div>`).join("")}
</div>
`)
}
function scheduleToFile(schedule: ScheduleDraft) {
const ics = scheduleToIcs(schedule)
const filename = `${safeFilename(schedule.title || "schedule")}.ics`
File diff suppressed because it is too large Load Diff
+19 -25
View File
@@ -24,32 +24,28 @@ export function RegisterPage() {
const [domainId, setDomainId] = React.useState("")
const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || []
const selectedDomain = domains.find((d) => d.id === domainId)
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
if (domainId && !domains.some((domain) => domain.id === domainId)) setDomainId(domains[0]?.id || "")
}, [domainId, domains])
const register = useMutation({
mutationFn: (form: FormData) => {
const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
validatePasswordConfirm(password, confirmPassword)
if (domainId && selectedDomain) {
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
return api.register({
email: `${localPart}@${selectedDomain.name}`,
displayName: String(form.get("displayName") || ""),
password,
turnstileToken,
domainId,
localPart,
})
}
// Fallback: no domains available, use email directly
const displayName = String(form.get("displayName") || "").trim()
if (!displayName) throw new Error("请输入显示名称")
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
if (!domainId || !selectedDomain) throw new Error("请选择邮箱域名")
return api.register({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
email: `${localPart}@${selectedDomain.name}`,
displayName,
password,
turnstileToken,
domainId,
localPart,
})
},
onSuccess: async () => {
@@ -102,25 +98,23 @@ export function RegisterPage() {
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="rounded-md bg-muted/40 px-4 py-3 text-center text-sm text-muted-foreground"></div>
)}
<div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label>
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium"></Label>
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading || domains.length === 0}>
{register.isPending ? "注册中..." : "注册"}
{!register.isPending && <ArrowRight className="h-4 w-4" />}
</Button>
+2
View File
@@ -8,6 +8,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+34 -1
View File
@@ -20,6 +20,18 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
# 手动部署可执行:openssl rand -hex 24
LANQIN_UPDATE_TOKEN=
# 一键安装器记录的部署方式。手动部署时可以留空。
LANQIN_INSTALL_FIREWALL_MODE=
LANQIN_INSTALL_WEB_MODE=
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
LANQIN_HTTP_BIND=80
LANQIN_SMTP_BIND=25
LANQIN_SMTPS_BIND=465
LANQIN_SUBMISSION_BIND=587
LANQIN_IMAPS_BIND=993
LANQIN_POP3S_BIND=995
# =========================
# 对外访问地址
# =========================
@@ -39,9 +51,14 @@ LANQIN_TLS_KEY_FILE=
# =========================
# 初始管理员
# =========================
# 第一次启动时会创建这个管理员账号。
# 第一次启动时会创建唯一管理员账号和真实管理员邮箱
# 网页前台和后台统一使用完整主登录邮箱 + 密码登录。
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
# 旧版本兼容字段,网页登录不再使用;新安装脚本会写入管理员邮箱前缀。
LANQIN_ADMIN_USERNAME=
# 生产环境必须改掉默认密码。
LANQIN_ADMIN_PASSWORD=ChangeMe123!
@@ -166,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
# =========================
# Telegram 私聊邮件通知
# =========================
# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。
# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。
LANQIN_TELEGRAM_MAIL_ENABLED=false
# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。
LANQIN_TELEGRAM_BOT_TOKEN=
# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。
LANQIN_TELEGRAM_PRIVATE_CHAT_ID=
# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。
LANQIN_TELEGRAM_BODY_MODE=summary
# =========================
# 系统
# =========================
+46 -2
View File
@@ -15,10 +15,20 @@ sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email reset-2fa
```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、邮箱地址域名、管理员邮箱/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。管理员邮箱默认 `admin@邮箱地址域名`,自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置唯一管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。唯一管理员 2FA 锁死时可使用 `sudo newszxcn-email reset-2fa` 应急关闭。
## 最简单部署:单容器镜像版
服务器上不需要源码构建,只要 `docker-compose.yml``.env` 即可。
@@ -26,7 +36,7 @@ sudo newszxcn-email rollback
```bash
cd deploy
cp .env.example .env
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_MAIL_DOMAIN / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
docker compose pull
docker compose up -d
```
@@ -138,6 +148,33 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
配置完成后点击“检测”。
## Telegram 通知
### 私聊新邮件通知
每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知:
1. 使用 `@BotFather` 创建机器人并填写 Bot Token。
2. 在 Telegram 中打开该机器人并发送 `/start`
3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。
4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。
5. 测试成功后开启“私聊新邮件通知”并保存。
Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。
手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED``LANQIN_TELEGRAM_BOT_TOKEN``LANQIN_TELEGRAM_PRIVATE_CHAT_ID``LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。
### GitHub Release 版本频道通知
版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets
```text
TELEGRAM_RELEASE_BOT_TOKEN
TELEGRAM_RELEASE_CHAT_ID
```
`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。
## 邮件服务边界
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
@@ -154,6 +191,13 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
## 邮件客户端 TLS 证书
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
```dotenv
LANQIN_HTTP_BIND=127.0.0.1:8088
```
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
@@ -174,7 +218,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
```
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
+1
View File
@@ -5,6 +5,7 @@ server {
index index.html;
location /api/ {
client_max_body_size 50m;
proxy_pass http://127.0.0.1:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
+7 -9
View File
@@ -6,19 +6,17 @@ services:
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
ports:
- "80:80"
- "443:443"
- "25:25"
- "465:465"
- "587:587"
- "993:993"
- "995:995"
- "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_SMTP_BIND:-25}:25"
- "${LANQIN_SMTPS_BIND:-465}:465"
- "${LANQIN_SUBMISSION_BIND:-587}:587"
- "${LANQIN_IMAPS_BIND:-993}:993"
- "${LANQIN_POP3S_BIND:-995}:995"
volumes:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
# - /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email"
+1
View File
@@ -3,6 +3,7 @@ server {
server_name _;
location /api/ {
client_max_body_size 50m;
proxy_pass http://api:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
+173
View File
@@ -0,0 +1,173 @@
# NewSzxcn 邮箱指南
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
## 一键安装
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
安装脚本会依次询问防火墙配置、邮件服务器域名、邮箱地址域名、管理员邮箱和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
## 登录入口
假设安装时填写的邮件服务器域名为 `mail.example.com`
| 入口 | 地址 | 用途 |
| --- | --- | --- |
| 邮箱前台 | `https://mail.example.com/` | 收发邮件、申请邮箱和账号设置 |
| 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 |
管理员账号是安装时创建的完整邮箱地址,默认为 `admin@邮箱地址域名`。前台和后台都只能使用完整主登录邮箱 + 密码登录,显示名称仅用于页面展示。
## 首次配置
### 1. 添加邮件域名
1. 登录 NewSzxcn Email 管理后台。
2. 进入“域名管理”,点击“添加域名”。
3. 填写需要收发邮件的域名并保存。
4. 点击该域名右侧的“DNS”,查看系统生成的记录。
5. 前往域名服务商的 DNS 管理页面,逐项添加 MX、SPF、DKIM 和 DMARC 记录。
6. 返回管理后台,点击“检测”。
7. 所有记录检测通过后,即可使用该域名创建邮箱。
DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能代替你修改域名服务商的 DNS。
### 2. 开启账号自助申请邮箱
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“账号自助申请邮箱”。
3. 在“开放域名”中勾选允许用户申请邮箱的域名。
4. 保存设置。
开启后,用户登录邮箱前台,进入“设置 -> 邮箱管理”,即可在账号配额范围内自行申请邮箱,无需管理员逐个分配。
如果账号还没有邮箱,邮箱前台会显示“还没有可用邮箱”。此时应点击“前往邮箱管理”,进入个人中心申请邮箱。
### 3. 开启无人收件
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“无人收件”并保存。
开启后,对于系统中已经添加并启用的邮件域名,即使收件地址尚未注册,服务器仍会接收邮件。例如已经启用 `example.com` 后,发送到 `111@example.com` 的邮件也会被保留。
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
## Telegram 私聊邮件通知
管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊:
1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。
2. 进入“管理后台 -> 系统设置 -> 通知”,填写 Bot Token。
3. 点击“安全绑定”生成一次性绑定码,再点击“打开机器人”。
4. 在机器人会话中发送页面生成的绑定码,然后点击“完成绑定”。
5. 勾选需要通知的邮箱;需要接收未注册地址邮件时,另行勾选“未知收件”。
6. 选择正文显示方式并点击“测试通知”。
7. 测试成功后开启“私聊新邮件通知”,保存设置。
一次性绑定码有效期为 10 分钟,只会匹配发送了该绑定码的私聊账号。通知会显示主题、发件人、收件邮箱、服务器收件时间、正文和附件摘要;识别到唯一高可信验证码时,会高亮显示并提供“复制验证码”按钮。外部 IMAP 第一次同步导入的历史邮件不会发送通知,后续新邮件才会通知。
Telegram 连接失败不会影响邮局收件。系统会保留通知任务并自动重试;关闭通知、更换机器人、更换私聊账号或修改通知邮箱范围时,尚未发送的旧任务会被清除。Telegram Bot API 不提供客户端幂等键,因此网络超时发生在 Telegram 已收到请求但服务器未收到响应时,极少数通知可能重复发送。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。
邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。
## SSL 证书与自动续期
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
查看当前域名的证书和续期信息:
```bash
/root/.acme.sh/acme.sh --info --domain mail.example.com --ecc
```
查看证书实际到期时间:
```bash
openssl x509 -in /opt/newszxcn-email/certs/fullchain.pem -noout -enddate
```
手动申请、检查或重新安装证书:
```bash
sudo newszxcn-email certificate
```
证书续期计划由 `acme.sh` 和证书颁发机构动态决定,不应把预计续期日期写死在配置或文档中。
## 更新与运维
重新打开安装与运维菜单:
```bash
sudo ns
```
也可以执行 `sudo newszxcn-email menu`,或重新运行一键安装命令。
常用命令:
```bash
sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email restart
sudo newszxcn-email logs
sudo newszxcn-email certificate
sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email reset-2fa
```
命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。
`guide` 命令会读取当前安装地址、管理员邮箱、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后按管理员邮箱重置唯一管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。唯一管理员因双因素认证无法登录时,可执行 `reset-2fa` 应急关闭管理员 2FA,登录后应重新绑定并保存新的恢复码。
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
## 必要端口
请同时检查服务器防火墙和云服务商安全组:
| 端口 | 用途 |
| --- | --- |
| `25/TCP` | 邮件服务器之间收发邮件 |
| `80/TCP` | HTTP 跳转和证书签发验证 |
| `443/TCP` | 邮箱前台和管理后台 |
| `465/TCP` | SMTP SSL 发信 |
| `587/TCP` | SMTP Submission 发信 |
| `993/TCP` | IMAP SSL 收信 |
| `995/TCP` | POP3 SSL 收信 |
部分云服务商默认封锁出站 `25/TCP`。网页可以正常打开并不代表公网邮件一定能够成功投递。
## 数据与备份
默认数据目录为 `/opt/newszxcn-email`。重要数据包括:
```text
/opt/newszxcn-email/
|-- .env
|-- data/
|-- mail/
|-- dkim/
`-- certs/
```
执行服务器快照或异地备份时,应同时保存这些目录。不要公开 `.env`、证书私钥、数据库备份或管理员登录信息。
## 更多文档
- [项目说明](../README.md)
- [Docker 部署说明](../deploy/README.md)
- [API 文档](API.md)
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
+277
View File
@@ -0,0 +1,277 @@
# NewSzxcn Email 问题台账
本文件是 NewSzxcn Email 的单一问题台账。所有问题保留唯一编号和完整历史,不重复建立“历史问题”副本。
## 管理模式
- 当前任务是唯一长期运行的 NewSzxcn 项目任务。
- 内部按项目总览、问题中心、前端、后端、邮件核心、部署运维、质量复核、文档发布分类管理。
- 用户提出问题后,默认在同一任务内完成分类、资料查询、代码/服务器调查、修复、测试、复核和发布门禁。
- 只有问题确实复杂且用户同意时,才临时开启专项任务。
- 用户明确说“先记录”“不要修改”“等问题多了一起处理”时,只登记和调查,不实施产品代码修改。
## 状态
允许状态:新反馈、待复现、待确认、待处理、处理中、待验收、已完成、已归档。
固定流程:收集问题 -> 复现 -> 用户确认批次 -> 实现 -> 回归 -> CI -> 全部镜像完成 -> Release -> 通知更新 -> 线上验收。
发布门禁:CI 未通过不得进入 Release;全部镜像未完成不得通知更新。
## 模块职责
| 分类 | 责任 |
| --- | --- |
| 项目总览 | 统一编号、状态流转、批次确认、冲突协调、发布门禁 |
| 问题中心 | 问题收集、复现证据、影响面、严重度、完整历史 |
| 前端 | Web 页面、交互、批量操作、权限可见性、前端构建 |
| 后端 | API、鉴权、数据库、业务校验、后台任务 |
| 邮件核心 | IMAP/SMTP、Postfix、Dovecot、Rspamd、Maildir、投递与同步 |
| 部署运维 | install.sh、Docker、Compose、Nginx、CI/CD、服务器调查 |
| 质量复核 | 独立复核、回归测试、风险确认、验收建议 |
| 文档发布 | 版本说明、部署说明、Release 文案、更新通知 |
## 问题列表
| 编号 | 日期 | 状态 | 模块 | 现象 | 严重度 | 目标版本 | 发布状态 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| NSX-20260804-001 | 2026-08-04 | 已完成 | 前端主责;后端、邮件核心协作 | 全部邮箱批量移动到自定义文件夹失败 | S2 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 |
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
| NSX-20260806-007 | 2026-08-06 | 待验收 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 待发布 |
## NSX-20260804-001
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260804-001 |
| 日期 | 2026-08-04 |
| 状态 | 已完成 |
| 模块 | 前端主责;后端、邮件核心协作;质量复核待后续介入 |
| 现象 | 全部邮箱批量移动到自定义文件夹失败 |
| 截图 | 待用户补充 |
| 复现步骤 | 待补充。已知描述:选择全部邮箱后,批量移动到自定义文件夹失败。 |
| 影响 | 影响批量整理邮件,用户可能只能逐封或分批移动;是否造成数据错位或丢失待复现确认。 |
| 严重度 | S2 |
| 根因 | 全部邮箱视图中批量移动沿用单邮箱移动路径,目标自定义文件夹没有按消息所属邮箱分别解析/创建,跨邮箱消息会出现 `failed to move message`。 |
| 关联提交 | 未提交;关联工作区文件:`apps/api/internal/app/mail_handlers.go``apps/web/src/pages/mail.tsx``apps/web/src/lib/api.ts``apps/api/internal/app/app_test.go`。 |
| 目标版本 | v1.2.14 |
| 测试结果 | 已完成 Go 全量测试、go vet、前端 check/build、安装脚本测试和 ShellCheck;批量移动回归测试覆盖跨邮箱、自定义文件夹及部分失败结果。 |
| 发布状态 | 随 v1.2.14 发布。 |
### 分派
| 分类 | 处理方式 |
| --- | --- |
| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 |
| 问题中心 | 已补充根因和实现证据;等待完整回归和用户线上验收。 |
| 前端 | 已接入批量移动接口并展示中文成功/失败明细。 |
| 后端 | 已实现批量移动接口,按消息所属邮箱隔离处理目标文件夹和权限。 |
| 邮件核心 | 已保持消息所属邮箱和文件夹映射隔离,完整运行回归待主任务执行。 |
| 部署运维 | 暂无服务器动作;涉及线上日志或容器状态时再介入。 |
| 质量复核 | 修复后独立复核批量移动、部分失败、权限边界、回归风险。 |
| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-04 | 用户指定切换为精简单任务模式,并要求“全部邮箱批量移动到自定义文件夹失败”继续只调查不修改,等待汇总更多问题。 |
| 2026-08-04 | 项目总控建立唯一问题编号 NSX-20260804-001,状态设为待复现。 |
| 2026-08-05 | 用户确认本批继续实施;已实现可靠批量移动接口并接入前端批量移动入口,状态流转为待验收。 |
## NSX-20260804-002
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260804-002 |
| 日期 | 2026-08-04 |
| 状态 | 已完成 |
| 模块 | 前端/UI/响应式布局;质量复核待后续介入 |
| 现象 | 左侧栏选择具体邮箱账号后,顶部邮箱选择器和展开菜单比“全部邮箱”状态明显变窄,邮箱地址显示为 `crrhuawei@new...`,下拉项也被过早截断。右侧复制按钮占位后,选择器宽度发生变化。 |
| 截图 | `/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-fadc6a64-ce13-4a5e-ae8f-5de7e7756e62.png` |
| 复现步骤 | 1. 打开邮箱左侧栏。 2. 从“全部邮箱”切换到具体邮箱账号。 3. 展开顶部邮箱选择器。 4. 观察选择器宽度、展开菜单宽度、邮箱地址截断和复制按钮出现后的布局变化。 |
| 影响 | 影响账号切换区域的可读性和稳定性;邮箱地址过早截断,复制按钮出现时造成布局跳变。 |
| 严重度 | S3 |
| 根因 | 邮箱选择器右侧复制按钮只在具体邮箱状态占位,触发区域宽度随状态变化;下拉菜单宽度跟随被挤压后的触发区域,导致邮箱地址过早截断。 |
| 关联提交 | 未提交;关联工作区文件:`apps/web/src/pages/mail.tsx`。 |
| 目标版本 | v1.2.14 |
| 测试结果 | 前端 check/build 通过;桌面浏览器验证“全部邮箱”和具体邮箱状态均保留复制按钮固定槽位,具体邮箱触发区宽 225px;390×844 移动端无横向溢出。 |
| 发布状态 | 随 v1.2.14 发布。 |
### 期望
邮箱选择器在“全部邮箱”和具体邮箱两种状态下保持原来的稳定长度;展开菜单宽度与选择区域协调,尽量完整显示邮箱地址;复制按钮出现时不能挤压选择器或造成布局跳变。
### 关联
与此前“全部邮箱选择器长度、复制按钮出现后布局变化”反馈关联;本条作为可复现回归继续保留,不合并归档。
### 分派
| 分类 | 处理方式 |
| --- | --- |
| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 |
| 问题中心 | 保留截图路径、现象、期望和关联背景;已补充根因和实现证据。 |
| 前端 | 已固定复制按钮占位和下拉菜单宽度,等待桌面/移动验收。 |
| 后端 | 暂无介入需求。 |
| 邮件核心 | 暂无介入需求。 |
| 部署运维 | 暂无介入需求。 |
| 质量复核 | 修复后复核“全部邮箱”和具体邮箱两种状态、展开菜单、长邮箱地址、复制按钮出现前后布局稳定性。 |
| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-04 | 用户新增“邮箱选择器展开后宽度变窄”待处理问题,要求只登记,不修改。 |
| 2026-08-04 | 项目总控建立唯一问题编号 NSX-20260804-002,状态设为待处理。 |
| 2026-08-05 | 用户确认本批继续实施;已固定复制按钮占位和下拉菜单宽度,状态流转为待验收。 |
## NSX-20260805-003
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260805-003 |
| 日期 | 2026-08-05 |
| 状态 | 已完成 |
| 模块 | 前端/UI/布局稳定性;质量复核待后续介入 |
| 现象 | 邮箱页面左侧栏明显更宽,设置页面侧栏变窄,垂直分隔边框与主内容起点改变;点击齿轮切换时产生页面左右跳动和“页面变来变去”的感觉。 |
| 截图 | 邮箱页:`/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-e293b3f1-7797-49ec-b5f9-b7de08ba4c6c.png`;设置页:`/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-89b85640-d262-4b1c-8996-cd097036614e.png` |
| 复现步骤 | 1. 打开邮箱页面。 2. 观察左侧栏宽度、垂直分隔线、顶部栏高度和主内容起点。 3. 点击齿轮进入设置页面。 4. 对比设置页侧栏宽度、分隔线位置和主内容起点。 5. 返回邮箱页,观察页面是否左右跳动。 |
| 影响 | 影响应用壳层稳定性和页面切换体验;邮箱页与设置页布局不一致会造成明显跳动,并可能影响邮箱选择器可用宽度。 |
| 严重度 | S3 |
| 根因 | 邮箱页和设置页分别使用独立侧栏宽度,设置页硬编码宽度小于邮箱页,导致切换时分隔线与主内容起点变化。 |
| 关联提交 | 未提交;关联工作区文件:`apps/web/src/index.css``apps/web/src/pages/profile.tsx``apps/web/src/pages/mail.tsx`。 |
| 目标版本 | v1.2.14 |
| 测试结果 | 前端 check/build 通过;桌面浏览器实测邮箱页与设置页侧栏均为 288px,页面宽 1440px 且无横向溢出;390×844 移动端邮箱和设置页面均无横向溢出、遮挡或控制台错误。 |
| 发布状态 | 随 v1.2.14 发布。 |
### 期望
邮箱和设置页面使用统一的应用壳层尺寸;侧栏宽度、垂直分隔线、顶部栏高度和主内容起点保持一致,只替换内部导航内容。后续方案需核对桌面与移动端,不允许使用会挤压邮箱选择器的临时宽度。
### 批次关联
纳入即将给用户确认的前端/UI/布局稳定性候选批次。与 NSX-20260804-002 均涉及邮箱页左侧区域布局稳定性,但本条关注跨页面应用壳层尺寸一致性,保持独立编号。
### 分派
| 分类 | 处理方式 |
| --- | --- |
| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 |
| 问题中心 | 保留两张截图路径、现象、期望和验收注意事项;已补充根因和实现证据。 |
| 前端 | 已统一邮箱页与设置页应用壳层侧栏宽度变量,等待桌面/移动验收。 |
| 后端 | 暂无介入需求。 |
| 邮件核心 | 暂无介入需求。 |
| 部署运维 | 暂无介入需求。 |
| 质量复核 | 修复后独立复核桌面与移动端页面切换稳定性,确认不会挤压邮箱选择器或造成新的布局跳变。 |
| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-05 | 用户新增“邮箱页与设置页侧栏宽度/边框位置不一致”待处理问题,要求只登记并纳入即将确认的批次方案,现在不要修改。 |
| 2026-08-05 | 项目总控建立唯一问题编号 NSX-20260805-003,状态设为待处理。 |
| 2026-08-05 | 用户确认本批继续实施;已统一应用壳层侧栏宽度变量,状态流转为待验收。 |
## NSX-20260806-004
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-004 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 前端/UI/响应式布局 |
| 现象 | “全部邮箱”状态没有复制按钮,但选择器右侧仍永久保留一列空白区域,箭头与侧栏边框之间出现明显空块。 |
| 截图 | `/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-ae8ada7f-ba00-4036-9103-8cb76b9496a2.png``/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-80fb1ec3-9e98-4159-a8fb-b79a0b0e9a32.png` |
| 根因 | 为避免具体邮箱状态出现复制按钮时选择器缩窄,旧实现始终保留 32px 复制按钮网格列;“全部邮箱”状态只是隐藏按钮,没有释放占位。 |
| 实现 | 选择器改为始终占满整行;只有具体邮箱状态才显示覆盖式复制按钮,并在选择器内容区预留图标空间。 |
| 目标版本 | v1.2.15 |
| 测试结果 | 前端 check/build 通过;1280px 桌面端选择器宽度在两种状态下均为 263px;390×844 移动端侧栏选择器宽度均为 294px,页面无横向溢出;控制台无错误或警告。 |
| 发布状态 | 随 v1.2.15 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
| 2026-08-06 | 已移除永久占位列,改为具体邮箱状态覆盖显示复制按钮,状态流转为待验收。 |
## NSX-20260806-005
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-005 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 后端/通知;前端/设置;部署运维/CI;质量复核 |
| 需求 | 后台配置 Telegram 机器人,将新邮件排版后发送到管理员私聊;GitHub Release 成功后统一向版本频道发送一次更新通知。 |
| 边界 | 邮件通知由各部署实例独立配置;版本通知只由 GitHub Release 工作流发送,不依赖已部署邮局是否更新。 |
| 实现 | 新增 Telegram 通知设置、私聊 Chat ID 自动获取、测试发送、正文模式、持久化通知队列、去重与失败重试;Release 工作流在全部镜像和 Release 成功后发送频道消息。 |
| 安全 | Bot Token 不通过设置查询接口返回,不写入仓库;频道密钥使用 GitHub Actions SecretsTelegram 失败不阻塞收件或版本发布。 |
| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 |
| 目标版本 | v1.2.16 |
| 测试结果 | Go 全量测试和 vet、前端 check/build、安装脚本语法/ShellCheck/回归、工作流 YAML、密钥扫描、桌面和移动端页面检查均通过。 |
| 发布状态 | 随 v1.2.16 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户确认后台只保留机器人私聊邮件通知,版本频道通知交由 GitHub Release 工作流统一发送。 |
| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 |
| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |
| 2026-08-06 | v1.2.16 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功。 |
## NSX-20260806-006
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-006 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 后端/通知;邮件核心;前端/设置;质量复核 |
| 现象 | 原自动获取 Chat ID 可能匹配错误私聊;通知范围默认覆盖全部本地邮箱;关闭后重开可能补发旧任务;收件邮箱、正文编码、长度预算、错误重试和验证码复制不完整。 |
| 根因 | 通知功能首版只覆盖基础发送,没有建立安全配对、显式邮箱范围、发送租约、Telegram 错误分类和统一的 MIME/正文规范化流程。 |
| 实现 | 使用 10 分钟一次性绑定码;通知范围改为显式邮箱和未知收件选择;目的地或范围变化时事务清理旧任务;增加发送租约、Telegram 消息编号、送达后正文清除、400 纯文本降级、429 `retry_after`、401/403 停止重试;新增验证码评分与 `copy_text` 按钮、全消息长度预算、附件清理、GBK 等正文字符集解码和伪 HTML 清理。 |
| 收件链路 | 修正实际收件地址解析;本地互发、未知收件和后续外部 IMAP 新邮件统一通知;首次外部 IMAP 历史导入不通知;收信规则先执行,垃圾邮件和已删除邮件不通知。 |
| 兼容性 | 数据库仅增加可空闲迁移列和设置项;现有 Bot Token 保留且不回传;升级后管理员邮箱自动成为默认通知范围,已开启通知的实例继续包含未知收件。 |
| 目标版本 | v1.2.17 |
| 测试结果 | Telegram 专项测试、Go 全量测试、`go vet`、竞态检测、前端 shadcn 检查和生产构建通过;桌面端与移动端页面视觉验收通过。 |
| 发布状态 | 随 v1.2.17 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 完成通知全链路复核,确认安全绑定、旧队列、收件地址、来源覆盖、长度预算、错误分类和 MIME 处理问题。 |
| 2026-08-06 | 用户确认继续修改,并明确保留现有机器人 Token。 |
| 2026-08-06 | 完成实现和自动化回归,状态流转为待验收。 |
| 2026-08-06 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |
## NSX-20260806-007
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-007 |
| 日期 | 2026-08-06 |
| 状态 | 待验收 |
| 模块 | 前端/UI;后端/通知;部署运维/CI;质量复核 |
| 现象 | 邮箱选择器展开层超过侧栏边框;含日期年份的验证码邮件未显示复制按钮;邮件长链接难以阅读;Release 通知底部按钮需改为正文文字链接。 |
| 根因 | 展开层固定为 21rem,未跟随触发按钮;年份与真实验证码同时进入评分后触发歧义保护;正文仅转义未生成显式链接;Release 工作流使用 inline keyboard。 |
| 实现 | 展开层宽度跟随触发按钮;排除年份和紧凑日期候选;正文 URL 安全转义并生成链接,长追踪地址缩短显示;Release 移除按钮并在正文末尾加入“查看本次更新”链接。 |
| 目标版本 | v1.2.18 |
| 测试结果 | Gate 转发邮件验证码与链接专项测试、Telegram 全部竞态测试、Go 全量测试和 vet、前端 check/build、工作流 YAML、差异格式检查均通过;桌面端“全部邮箱”和具体邮箱状态下触发按钮与下拉层均为 263px,左右边界一致且无控制台错误。 |
| 发布状态 | 待发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户提供三张截图并确认本批修改范围,问题进入处理中。 |
| 2026-08-06 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 |
+1318 -77
View File
File diff suppressed because it is too large Load Diff
+63 -105
View File
@@ -50,15 +50,6 @@ importers:
'@tiptap/core':
specifier: ^3.27.0
version: 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-font-family':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-highlight':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-image':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
@@ -74,9 +65,6 @@ importers:
'@tiptap/extension-text-style':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-underline':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/pm':
specifier: ^3.27.0
version: 3.27.0
@@ -93,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.10
version: 3.4.10
specifier: 3.4.12
version: 3.4.12
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -107,12 +95,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: 6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 7.18.2
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: 2.5.4
version: 2.5.4
@@ -131,10 +116,10 @@ importers:
version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7)(yaml@2.9.0))
autoprefixer:
specifier: 10.4.20
version: 10.4.20(postcss@8.5.15)
version: 10.4.20(postcss@8.5.25)
postcss:
specifier: 8.5.15
version: 8.5.15
specifier: 8.5.25
version: 8.5.25
tailwindcss:
specifier: 3.4.15
version: 3.4.15
@@ -642,10 +627,6 @@ packages:
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -789,11 +770,6 @@ packages:
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-color@3.27.0':
resolution: {integrity: sha512-K+2aI4k0yEekZ4Sq+puNWE4/z26E1wfAmRqci6k6T8tAQz5PsDPzgNUSHTB4FSTzH0hLYrFzj/yaggjF9VeHGg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-document@3.27.0':
resolution: {integrity: sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==}
peerDependencies:
@@ -811,11 +787,6 @@ packages:
'@tiptap/core': 3.27.0
'@tiptap/pm': 3.27.0
'@tiptap/extension-font-family@3.27.0':
resolution: {integrity: sha512-cK+5R6KoOoxXmwbA03RYZS7G0//DovLNV/6mhgYPIuO/tNXXcwMGOxUKmMC2aUr95aL00EqGtsX4JotRyBVvXg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-gapcursor@3.27.0':
resolution: {integrity: sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==}
peerDependencies:
@@ -831,11 +802,6 @@ packages:
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-highlight@3.27.0':
resolution: {integrity: sha512-p7Epzx8KbxxqIUG68dFk2lpmPp0Bvth7K11oL/pAlaXOR2Z4X9kef5+FJ/fEDehUtWPzfzVn/eO2a5bYN7k2AA==}
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-horizontal-rule@3.27.0':
resolution: {integrity: sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==}
peerDependencies:
@@ -1031,6 +997,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1052,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.10:
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -1252,8 +1222,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1339,8 +1309,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
postcss@8.5.25:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
prosemirror-changeset@2.4.1:
@@ -1415,24 +1385,22 @@ packages:
'@types/react':
optional: true
react-resizable-panels@2.1.9:
resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==}
react-router-dom@7.18.2:
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react: '>=18'
react-dom: '>=18'
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
react-router@7.18.2:
resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -1478,6 +1446,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2123,8 +2094,6 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
'@remix-run/router@1.23.3': {}
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -2215,10 +2184,6 @@ snapshots:
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-document@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2234,10 +2199,6 @@ snapshots:
'@tiptap/pm': 3.27.0
optional: true
'@tiptap/extension-font-family@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-gapcursor@3.27.0(@tiptap/extensions@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/extensions': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)
@@ -2250,10 +2211,6 @@ snapshots:
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-highlight@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-horizontal-rule@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2426,14 +2383,14 @@ snapshots:
dependencies:
tslib: 2.8.1
autoprefixer@10.4.20(postcss@8.5.15):
autoprefixer@10.4.20(postcss@8.5.25):
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001799
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
baseline-browser-mapping@2.10.37: {}
@@ -2476,6 +2433,8 @@ snapshots:
commander@4.1.1: {}
cookie@1.1.1: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -2488,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.10:
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2639,7 +2598,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.12: {}
nanoid@3.3.16: {}
node-releases@2.0.47: {}
@@ -2665,28 +2624,28 @@ snapshots:
pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.5.15):
postcss-import@15.1.0(postcss@8.5.25):
dependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.15):
postcss-js@4.1.0(postcss@8.5.25):
dependencies:
camelcase-css: 2.0.1
postcss: 8.5.15
postcss: 8.5.25
postcss-load-config@4.0.2(postcss@8.5.15):
postcss-load-config@4.0.2(postcss@8.5.25):
dependencies:
lilconfig: 3.1.3
yaml: 2.9.0
optionalDependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-nested@6.2.0(postcss@8.5.15):
postcss-nested@6.2.0(postcss@8.5.25):
dependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4:
@@ -2696,9 +2655,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.15:
postcss@8.5.25:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -2807,22 +2766,19 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
react-resizable-panels@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
cookie: 1.1.1
react: 18.3.1
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1):
dependencies:
@@ -2884,6 +2840,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {}
sucrase@3.35.1:
@@ -2920,11 +2878,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
postcss: 8.5.15
postcss-import: 15.1.0(postcss@8.5.15)
postcss-js: 4.1.0(postcss@8.5.15)
postcss-load-config: 4.0.2(postcss@8.5.15)
postcss-nested: 6.2.0(postcss@8.5.15)
postcss: 8.5.25
postcss-import: 15.1.0(postcss@8.5.25)
postcss-js: 4.1.0(postcss@8.5.25)
postcss-load-config: 4.0.2(postcss@8.5.25)
postcss-nested: 6.2.0(postcss@8.5.25)
postcss-selector-parser: 6.1.4
resolve: 1.22.12
sucrase: 3.35.1
@@ -2987,7 +2945,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.25
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export LANQIN_SOURCE_ONLY=true
# shellcheck source=install.sh
source "${ROOT_DIR}/install.sh"
fail_test() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
assert_eq() {
local want="$1" got="$2" label="$3"
[[ "${got}" == "${want}" ]] || fail_test "${label}: got '${got}', want '${want}'"
}
test_hostname_validation() {
valid_hostname "mail.example.com" || fail_test "valid hostname rejected"
valid_hostname "mx-1.example.co.uk" || fail_test "valid multi-label hostname rejected"
! valid_hostname "mail_example.com" || fail_test "hostname with underscore accepted"
! valid_hostname "localhost" || fail_test "single-label hostname accepted"
! valid_hostname "-mail.example.com" || fail_test "hostname with leading hyphen accepted"
}
test_password_validation() {
LANQIN_ADMIN_PASSWORD="abc123"
assert_eq "abc123" "$(prompt_admin_password)" "six-character password"
if (LANQIN_ADMIN_PASSWORD="abc12" prompt_admin_password >/dev/null 2>&1); then
fail_test "five-character password accepted"
fi
if (LANQIN_ADMIN_PASSWORD="abc\$123" prompt_admin_password >/dev/null 2>&1); then
fail_test "unsafe env-file password accepted"
fi
if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then
fail_test "password beginning with an env-file comment marker accepted"
fi
LANQIN_RESET_PASSWORD="reset1"
assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password"
if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then
fail_test "five-character reset password accepted"
fi
}
test_mail_domain_and_admin_email_validation() {
assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion"
assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion"
LANQIN_MAIL_DOMAIN="example.com"
LANQIN_ADMIN_EMAIL="admin@example.com"
assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain"
assert_eq "admin@example.com" "$(prompt_admin_email "example.com")" "explicit administrator email"
if (LANQIN_ADMIN_EMAIL="admin@other.example.com" prompt_admin_email "example.com" >/dev/null 2>&1); then
fail_test "administrator email outside mail domain accepted"
fi
if (has_tty() { return 1; }; unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL; prompt_mail_domain "mail.example.com" >/dev/null 2>&1); then
fail_test "noninteractive mail domain guessed without explicit input"
fi
unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL
}
test_install_configuration() {
local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5"
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env.example"
export INSTALL_DIR="${temp_dir}"
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
export LANQIN_PUBLIC_HOSTNAME="mail.example.com"
export LANQIN_MAIL_DOMAIN="example.com"
export LANQIN_ADMIN_EMAIL="admin@example.com"
export LANQIN_ADMIN_PASSWORD="abc123"
export LANQIN_INSTALL_WEB_MODE="${web_mode}"
configure_first_install
configure_runtime_bindings
assert_eq "${firewall_mode}" "$(env_value LANQIN_INSTALL_FIREWALL_MODE)" "firewall mode"
assert_eq "${web_mode}" "$(env_value LANQIN_INSTALL_WEB_MODE)" "web mode"
assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind"
assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL"
assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag"
assert_eq "example.com" "$(env_value LANQIN_MAIL_DOMAIN)" "mail address domain"
assert_eq "admin@example.com" "$(env_value LANQIN_ADMIN_EMAIL)" "administrator email"
assert_eq "admin" "$(env_value LANQIN_ADMIN_USERNAME)" "legacy administrator username prefix"
assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password"
}
test_nginx_configuration() {
local temp_dir old_path
temp_dir="$(mktemp -d)"
old_path="${PATH}"
mkdir -p "${temp_dir}/bin" "${temp_dir}/install" "${temp_dir}/certs" "${temp_dir}/acme"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/nginx"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/systemctl"
chmod 0755 "${temp_dir}/bin/nginx" "${temp_dir}/bin/systemctl"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/install/.env"
export PATH="${temp_dir}/bin:${PATH}"
INSTALL_DIR="${temp_dir}/install"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
ACME_WEBROOT="${temp_dir}/acme"
CERT_DIR="${temp_dir}/certs"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
write_nginx_http_config
grep -Fq 'proxy_pass http://127.0.0.1:8088;' "${NGINX_CONFIG}" || fail_test "HTTP proxy target missing"
grep -Fq 'root '"${ACME_WEBROOT}"';' "${NGINX_CONFIG}" || fail_test "ACME webroot missing"
write_nginx_https_config
grep -Fq 'listen 443 ssl http2;' "${NGINX_CONFIG}" || fail_test "HTTPS listener missing"
# shellcheck disable=SC2016
grep -Fq 'return 301 https://$host$request_uri;' "${NGINX_CONFIG}" || fail_test "HTTPS redirect missing"
grep -Fq "ssl_certificate ${CERT_DIR}/fullchain.pem;" "${NGINX_CONFIG}" || fail_test "certificate path missing"
PATH="${old_path}"
}
test_compose_configuration() {
# shellcheck disable=SC2016
grep -Fq '${LANQIN_HTTP_BIND:-80}:80' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "HTTP port mapping missing"
! grep -Fq 'LANQIN_HTTPS_BIND' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "dead container HTTPS mapping remains"
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
}
test_legacy_configuration_is_preserved() {
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env"
export INSTALL_DIR="${temp_dir}"
set_env LANQIN_INSTALL_WEB_MODE ""
set_env LANQIN_HTTP_BIND "127.0.0.1:9090"
configure_first_install
configure_runtime_bindings
assert_eq "127.0.0.1:9090" "$(env_value LANQIN_HTTP_BIND)" "legacy HTTP bind"
}
test_menu_choice() {
export LANQIN_MENU_ACTION=0
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
export LANQIN_MENU_ACTION=1
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
export LANQIN_MENU_ACTION=12
assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action"
if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then
fail_test "out-of-range menu action accepted"
fi
unset LANQIN_MENU_ACTION
}
test_admin_credentials() (
local temp_dir output
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
LANQIN_ADMIN_PASSWORD=recorded-password
EOF
output="$(do_show_admin_credentials 2>&1)"
[[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing"
[[ "${output}" == *'管理员邮箱:admin@example.com'* ]] || fail_test "administrator email missing"
[[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing"
[[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing"
)
test_admin_password_hash_parsing() (
compose() {
# shellcheck disable=SC2016
printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n'
}
# shellcheck disable=SC2016
assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing"
)
test_admin_password_reset_only_updates_admin_account() (
local temp_dir compose_calls backup_path
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
compose_calls="${temp_dir}/compose-calls"
mkdir -p "${INSTALL_DIR}/data/backups"
cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
LANQIN_ADMIN_PASSWORD=old-password
EOF
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:test-image\n'; }
backup_database() {
backup_path="$1"
printf 'backup\n' > "${backup_path}"
}
prompt_reset_password() { printf 'new-password'; }
# shellcheck disable=SC2016
generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; }
compose() {
printf '%s\n' "$*" >> "${compose_calls}"
if [[ "$*" == *'SELECT id FROM users'* ]]; then
printf 'admin-user-id\n'
elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then
printf 'user=1\nmailboxes=2\n'
fi
}
do_reset_admin_password >/dev/null
assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password"
[[ -s "${backup_path}" ]] || fail_test "password reset database backup missing"
grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not email and role restricted"
grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated"
grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized"
grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator"
)
test_admin_two_factor_reset_only_updates_admin_account() (
local temp_dir compose_calls backup_path
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
compose_calls="${temp_dir}/compose-calls"
mkdir -p "${INSTALL_DIR}/data/backups"
cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
EOF
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:test-image\n'; }
backup_database() {
backup_path="$1"
printf 'backup\n' > "${backup_path}"
}
compose() {
printf '%s\n' "$*" >> "${compose_calls}"
if [[ "$*" == *'SELECT id FROM users'* ]]; then
printf 'admin-user-id\n'
elif [[ "$*" == *"UPDATE users SET two_factor_secret=''"* ]]; then
printf 'user=1\nrecovery=2\nchallenges=1\n'
fi
}
do_reset_admin_two_factor >/dev/null
[[ -s "${backup_path}" ]] || fail_test "2FA reset database backup missing"
grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "2FA administrator lookup is not email and role restricted"
grep -Fq "UPDATE users SET two_factor_secret=''" "${compose_calls}" || fail_test "administrator 2FA flag was not cleared"
grep -Fq "DELETE FROM two_factor_recovery_codes WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator recovery codes were not deleted"
grep -Fq "DELETE FROM login_challenges WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator login challenges were not deleted"
)
test_offline_database_backup() (
local temp_dir destination
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}/data/backups"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");'
compose() { return 0; }
destination="${INSTALL_DIR}/data/backups/offline.db"
backup_database "${destination}" "unused-image"
[[ -s "${destination}" ]] || fail_test "offline database backup missing"
assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content"
)
test_guide_generation() (
local temp_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="${temp_dir}/guide.txt"
mkdir -p "${CERT_DIR}"
cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com"
set_env LANQIN_MAIL_DOMAIN "example.com"
set_env LANQIN_ADMIN_EMAIL "admin@example.com"
generate_guide
grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing"
grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing"
grep -Fq '管理员邮箱:admin@example.com' "${GUIDE_FILE}" || fail_test "guide administrator email missing"
grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing"
[[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600"
)
test_acme_cron_detection() (
crontab() {
printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n'
}
acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected"
)
test_cli_alias_safety() (
local temp_dir
temp_dir="$(mktemp -d)"
CLI_PATH="${temp_dir}/newszxcn-email"
CLI_ALIAS_PATH="${temp_dir}/ns"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_cli_alias
[[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created"
assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target"
rm -f "${CLI_ALIAS_PATH}"
printf 'occupied\n' > "${CLI_ALIAS_PATH}"
ensure_cli_alias
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
)
test_compose_runtime_image_pin() (
local temp_dir calls
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
calls="${temp_dir}/docker-calls"
mkdir -p "${INSTALL_DIR}"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}"
docker() {
printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}"
}
compose ps
grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose"
clear_runtime_image_pin
compose ps
[[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose"
)
test_update_snapshot_restore() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() {
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
printf '1.2.4\n'
fi
return 0
}
compose() {
if [[ "${1:-}" == "up" ]]; then
grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin"
fi
return 0
}
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing"
printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");'
restore_update_snapshot "${snapshot}"
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored"
grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored"
grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored"
assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content"
assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin"
)
test_snapshot_restores_absent_optional_files() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker"
[[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker"
[[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker"
[[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker"
mkdir -p "${CERT_DIR}"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
restore_update_snapshot "${snapshot}"
[[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback"
[[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback"
[[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback"
[[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback"
)
test_pre_start_restore_preserves_current_database() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
reload_nginx() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");'
restore_update_snapshot "${snapshot}" false
assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start"
assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull"
)
test_failed_asset_validation_preserves_production() (
local temp_dir source_dir
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source"
INSTALL_DIR="${temp_dir}/install"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}"
printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml"
cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example"
cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh"
script_dir() { printf '%s\n' "${source_dir}"; }
docker() { return 1; }
if (stage_assets >/dev/null 2>&1); then
fail_test "invalid Compose file passed staging validation"
fi
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation"
grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation"
)
test_backup_reinstall_restores_on_failure() (
local temp_dir failed_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
do_install() {
mkdir -p "${INSTALL_DIR}"
printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
return 1
}
if (do_backup_reinstall); then
fail_test "failed reinstall unexpectedly succeeded"
fi
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored"
failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)"
[[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved"
)
test_backup_reinstall_recovers_from_nginx_reload_failure() (
local temp_dir compose_calls reload_count_file
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
compose_calls="${temp_dir}/compose-calls"
reload_count_file="${temp_dir}/reload-count"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '0\n' > "${reload_count_file}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; }
reload_nginx() {
local count
count="$(cat "${reload_count_file}")"
printf '%s\n' "$((count + 1))" > "${reload_count_file}"
[[ "${count}" -gt 0 ]]
}
wait_for_health() { return 0; }
do_install() { fail_test "fresh install started after Nginx reload failure"; }
if (do_backup_reinstall >/dev/null 2>&1); then
fail_test "reinstall continued after Nginx reload failure"
fi
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure"
grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure"
)
test_hostname_validation
test_password_validation
test_mail_domain_and_admin_email_validation
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_nginx_configuration
test_compose_configuration
test_legacy_configuration_is_preserved
test_menu_choice
test_admin_credentials
test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account
test_admin_two_factor_reset_only_updates_admin_account
test_offline_database_backup
test_guide_generation
test_acme_cron_detection
test_cli_alias_safety
test_compose_runtime_image_pin
test_update_snapshot_restore
test_snapshot_restores_absent_optional_files
test_pre_start_restore_preserves_current_database
test_failed_asset_validation_preserves_production
test_backup_reinstall_restores_on_failure
test_backup_reinstall_recovers_from_nginx_reload_failure
printf 'install.sh tests passed\n'