Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 942605b2b6 | |||
| 635ab02b29 | |||
| 4c90c1de44 | |||
| 1fa029370d | |||
| 79f920bf0b | |||
| c77f63b5af | |||
| 86773c64ca | |||
| a11e1cd2f1 | |||
| 94efc2c62b | |||
| cefbee4f7f | |||
| 06388465a2 | |||
| e6504ef492 | |||
| 39ff9ce01d | |||
| df50f8b3ef | |||
| 397ce51800 | |||
| e789cf9b14 | |||
| 7e480795fc | |||
| a2eb1fbc52 | |||
| cab71c0539 | |||
| 6301182cfc | |||
| 6d0147b639 |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
|
||||
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
|
||||
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
|
||||
@@ -0,0 +1,6 @@
|
||||
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
|
||||
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
|
||||
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
|
||||
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
|
||||
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
|
||||
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
|
||||
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
|
||||
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
|
||||
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
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
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
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
|
||||
@@ -205,11 +205,8 @@ 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)"
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
@@ -240,38 +237,10 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
cp generated-release-notes.md release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
id: release_result
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
@@ -280,6 +249,90 @@ jobs:
|
||||
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."
|
||||
|
||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](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 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -35,16 +35,16 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
|
||||
启动失败时自动回滚。
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员
|
||||
邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
|
||||
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
@@ -70,15 +72,21 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
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
|
||||
@@ -86,7 +94,9 @@ sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -33,8 +33,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
|
||||
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
|
||||
@@ -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,20 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
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 == "" {
|
||||
@@ -131,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)
|
||||
@@ -161,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
|
||||
}
|
||||
@@ -190,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
@@ -218,16 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
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 requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
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" || role == "admin")) {
|
||||
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
@@ -239,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)
|
||||
@@ -294,14 +311,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
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("登录名已被使用"))
|
||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
@@ -317,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")
|
||||
@@ -575,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)
|
||||
@@ -617,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 {
|
||||
@@ -631,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
|
||||
@@ -730,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{}
|
||||
@@ -765,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})
|
||||
|
||||
+346
-115
@@ -23,16 +23,21 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
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
|
||||
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 {
|
||||
@@ -70,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()
|
||||
@@ -92,6 +97,18 @@ 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) })
|
||||
@@ -102,6 +119,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -185,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,
|
||||
@@ -420,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;
|
||||
@@ -662,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
|
||||
@@ -939,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.config().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
|
||||
}
|
||||
|
||||
@@ -1412,7 +1442,11 @@ func (a *App) seed(ctx context.Context) error {
|
||||
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 := cfg.AdminPassword
|
||||
@@ -1430,25 +1464,8 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
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, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(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)
|
||||
@@ -1483,18 +1500,221 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
|
||||
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
LoginName string `json:"loginName,omitempty"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
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
|
||||
}
|
||||
adminEmail := normalizeEmail(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1616,13 +1836,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
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: cfg.AdminEmail,
|
||||
From: "system@lanqin.local",
|
||||
From: systemAddress,
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Time: now,
|
||||
@@ -1633,9 +1864,9 @@ 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{cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
|
||||
@@ -413,6 +413,20 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
|
||||
return group
|
||||
}
|
||||
|
||||
func setRegularPermissionGroupForTest(t *testing.T, a *App, permissions []string, limits PermissionLimits) PermissionGroup {
|
||||
t.Helper()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(context.Background(), `UPDATE permission_groups SET permissions_json=?, limits_json=?, updated_at=? WHERE id=?`,
|
||||
encodePermissions(permissions), encodePermissionLimits(limits), now, PermissionGroupRegular); err != nil {
|
||||
t.Fatalf("set regular permission group fixture: %v", err)
|
||||
}
|
||||
group, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("load regular permission group fixture: %v", err)
|
||||
}
|
||||
return *group
|
||||
}
|
||||
|
||||
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
return map[string]any{
|
||||
"publicHostname": settings.PublicHostname,
|
||||
@@ -445,6 +459,12 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
"externalImapGmailClientSecret": "",
|
||||
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": "",
|
||||
"telegramMailEnabled": settings.TelegramMailEnabled,
|
||||
"telegramBotToken": "",
|
||||
"telegramPrivateChatId": settings.TelegramPrivateChatID,
|
||||
"telegramBodyMode": settings.TelegramBodyMode,
|
||||
"telegramMailboxIds": settings.TelegramMailboxIDs,
|
||||
"telegramIncludeUnregistered": settings.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,7 +1299,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
|
||||
@@ -1333,7 +1353,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
@@ -1345,32 +1365,92 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
registration := map[string]string{
|
||||
"email": "newuser@lanqin.local",
|
||||
"displayName": "New User",
|
||||
"password": "Password123!",
|
||||
"domainId": domainID,
|
||||
"localPart": "newuser",
|
||||
}
|
||||
var registered struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" {
|
||||
if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
|
||||
t.Fatalf("register code=%d user=%+v", code, registered.User)
|
||||
}
|
||||
var me struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" {
|
||||
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@lanqin.local" {
|
||||
t.Fatalf("me code=%d user=%+v", code, me.User)
|
||||
}
|
||||
var mine struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
|
||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "newuser@lanqin.local" {
|
||||
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
|
||||
}
|
||||
|
||||
another := &testClient{t: t, server: ts}
|
||||
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK {
|
||||
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@lanqin.local", "password": "Password123!"}, &out); code != http.StatusOK {
|
||||
t.Fatalf("login registered user code=%d body=%v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
|
||||
func TestTurnstileRetainedForLoginAndRegistration(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
verifyCalls := 0
|
||||
verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Form.Get("secret") != "secret-key" || r.Form.Get("response") == "" {
|
||||
t.Fatalf("turnstile form secret=%q response=%q", r.Form.Get("secret"), r.Form.Get("response"))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"success": r.Form.Get("response") == "valid-token"})
|
||||
}))
|
||||
defer verifyServer.Close()
|
||||
a.turnstileURL = verifyServer.URL
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.OpenRegistration = true
|
||||
cfg.TurnstileEnabled = true
|
||||
cfg.TurnstileSiteKey = "site-key"
|
||||
cfg.TurnstileSecretKey = "secret-key"
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
client := &testClient{t: t, server: ts}
|
||||
|
||||
var public PublicSettings
|
||||
if code := client.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.TurnstileEnabled || public.TurnstileSiteKey != "site-key" {
|
||||
t.Fatalf("public turnstile settings code=%d settings=%+v", code, public)
|
||||
}
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("login without turnstile code=%d", code)
|
||||
}
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!", "turnstileToken": "valid-token"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login with turnstile code=%d body=%v", code, login)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
registerClient := &testClient{t: t, server: ts}
|
||||
registerPayload := map[string]string{"email": "turnstile-user@lanqin.local", "displayName": "Turnstile User", "password": "Password123!", "domainId": domainID, "localPart": "turnstile-user"}
|
||||
if code := registerClient.do("POST", "/api/auth/register", registerPayload, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("register without turnstile code=%d", code)
|
||||
}
|
||||
registerPayload["turnstileToken"] = "valid-token"
|
||||
var registered map[string]any
|
||||
if code := registerClient.do("POST", "/api/auth/register", registerPayload, ®istered); code != http.StatusCreated {
|
||||
t.Fatalf("register with turnstile code=%d body=%v", code, registered)
|
||||
}
|
||||
if verifyCalls != 2 {
|
||||
t.Fatalf("turnstile verifier calls=%d, want 2", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBootstrapMailboxMigrationKeepsAdminMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
@@ -1413,15 +1493,15 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin user count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy mailbox count=%d err=%v", count, err)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin mailbox count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy domain count=%d err=%v", count, err)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin domain count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
func TestConfiguredAdminEmailCreatesMailboxAndRejectsUsernameLogin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
@@ -1430,6 +1510,7 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminUsername: "admin",
|
||||
AdminEmail: "root@example.test",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
@@ -1444,8 +1525,15 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domains != 0 || mailboxes != 0 {
|
||||
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
|
||||
if domains != 1 || mailboxes != 1 {
|
||||
t.Fatalf("admin email bootstrap domains=%d mailboxes=%d", domains, mailboxes)
|
||||
}
|
||||
var welcomeFrom, welcomeMessageID string
|
||||
if err := a.db.QueryRow(`SELECT from_addr,message_id FROM messages ORDER BY created_at LIMIT 1`).Scan(&welcomeFrom, &welcomeMessageID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if welcomeFrom != "system@example.test" || !strings.HasSuffix(welcomeMessageID, "@example.test>") {
|
||||
t.Fatalf("welcome message retained placeholder domain: from=%q messageId=%q", welcomeFrom, welcomeMessageID)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -1454,33 +1542,339 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("username login code=%d", code)
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy username login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin email login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "rootadmin",
|
||||
"email": "root@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("rename administrator code=%d", code)
|
||||
t.Fatalf("admin display update code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "root@example.test",
|
||||
"email": "not-an-email",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusBadRequest {
|
||||
t.Fatalf("email-shaped login name code=%d", code)
|
||||
t.Fatalf("invalid primary email update code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorPrimaryEmailPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "root@example.test",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(a.Router())
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"email": "owner@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("admin email update code=%d", code)
|
||||
}
|
||||
if a.config().AdminEmail != "owner@example.test" {
|
||||
t.Fatalf("runtime admin email=%q", a.config().AdminEmail)
|
||||
}
|
||||
ts.Close()
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldLogin := &testClient{t: t, server: ts}
|
||||
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("old username login code=%d", code)
|
||||
restarted, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLogin := &testClient{t: t, server: ts}
|
||||
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("renamed username login code=%d", code)
|
||||
t.Cleanup(func() { _ = restarted.Close() })
|
||||
var email, loginName string
|
||||
if err := restarted.db.QueryRow(`SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "owner@example.test" || loginName != "owner@example.test" || restarted.config().AdminEmail != "owner@example.test" {
|
||||
t.Fatalf("administrator identity reverted after restart: email=%q login=%q config=%q", email, loginName, restarted.config().AdminEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAdminIdentityMigrationKeepsEarliestAdminAndRecordsResult(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminEmail = "admin@example.test"
|
||||
})
|
||||
keeperHash, err := bcrypt.GenerateFromPassword([]byte("OriginalPass123!"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
demotedHash, err := bcrypt.GenerateFromPassword([]byte("OtherPass123!"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_users_single_admin`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC()
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin', password_hash=?, two_factor_secret='legacy-secret', two_factor_enabled=1, created_at=?, updated_at=? WHERE role='admin'`,
|
||||
string(keeperHash), now.Add(-2*time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES('usr_second_admin','second','second@example.test','Second Admin','admin',?,0,?,?)`, string(demotedHash), now.Add(-time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := a.migrateConfiguredAdministratorIdentity(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var adminCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil || adminCount != 1 {
|
||||
t.Fatalf("admin count=%d err=%v", adminCount, err)
|
||||
}
|
||||
var email, loginName, passwordHash, twoFactorSecret string
|
||||
var enabled int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT email,login_name,password_hash,two_factor_secret,two_factor_enabled FROM users WHERE role='admin'`).Scan(&email, &loginName, &passwordHash, &twoFactorSecret, &enabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "admin@example.test" || loginName != "admin@example.test" || twoFactorSecret != "legacy-secret" || enabled != 1 {
|
||||
t.Fatalf("admin identity not migrated safely email=%q login=%q secret=%q enabled=%d", email, loginName, twoFactorSecret, enabled)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("OriginalPass123!")); err != nil {
|
||||
t.Fatalf("admin password hash was not preserved: %v", err)
|
||||
}
|
||||
var secondRole string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT role FROM users WHERE id='usr_second_admin'`).Scan(&secondRole); err != nil || secondRole != "user" {
|
||||
t.Fatalf("second admin role=%q err=%v", secondRole, err)
|
||||
}
|
||||
var mailboxCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address='admin@example.test'`).Scan(&mailboxCount); err != nil || mailboxCount != 1 {
|
||||
t.Fatalf("admin mailbox count=%d err=%v", mailboxCount, err)
|
||||
}
|
||||
var rawResult string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&rawResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rawResult, `"adminEmail":"admin@example.test"`) || !strings.Contains(rawResult, `"id":"usr_second_admin"`) {
|
||||
t.Fatalf("migration result not recorded: %s", rawResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyWebUpdateResolvesAdminEmailFromExistingMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "bootstrap@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopTestWorkers(a)
|
||||
ctx := context.Background()
|
||||
var adminID, passwordHash string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id,password_hash FROM users WHERE role='admin'`).Scan(&adminID, &passwordHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domainID, err := a.createDomainTx(ctx, nil, "example.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHash(ctx, adminID, domainID, "admin", "admin@example.test", passwordHash, 1024, "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE address='bootstrap@lanqin.local'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin' WHERE id=?`, adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Old installations had only LANQIN_ADMIN_USERNAME. A webpage update starts
|
||||
// the new image directly, without running the interactive installer first.
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminEmail = ""
|
||||
cfg.MailDomain = ""
|
||||
updated, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = updated.Close() })
|
||||
var email, loginName string
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "admin@example.test" || loginName != "admin@example.test" {
|
||||
t.Fatalf("legacy administrator resolved incorrectly: email=%q login=%q", email, loginName)
|
||||
}
|
||||
if updated.config().AdminEmail != "admin@example.test" {
|
||||
t.Fatalf("runtime administrator email was not synchronized: %q", updated.config().AdminEmail)
|
||||
}
|
||||
var wrongDomainCount int
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email LIKE '%@lanqin.local'`).Scan(&wrongDomainCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrongDomainCount != 0 {
|
||||
t.Fatalf("web update created a lanqin.local administrator: %d", wrongDomainCount)
|
||||
}
|
||||
var migrationResult string
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&migrationResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(migrationResult, `"emailSource":"existing_admin_mailbox"`) {
|
||||
t.Fatalf("unexpected administrator email source: %s", migrationResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyPrimaryEmailCanLoginSecondaryMailboxCannot(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
primary := createTestMailbox(t, admin, domainID, "primary-login", "Primary Login", "Password123!", nil)
|
||||
secondary := createTestMailbox(t, admin, domainID, "secondary-login", "Secondary Login", "MailboxOnly123!", map[string]any{"ownerEmail": primary.Address})
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("primary email login code=%d", code)
|
||||
}
|
||||
secondaryLogin := &testClient{t: t, server: ts}
|
||||
if code := secondaryLogin.do("POST", "/api/auth/login", map[string]string{"email": secondary.Address, "password": "MailboxOnly123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("secondary mailbox should not login code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAPICannotCreateOrPromoteAdministrator(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
var errBody map[string]any
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "new-admin@lanqin.local",
|
||||
"displayName": "New Admin",
|
||||
"role": "admin",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("create admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
var user AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "regular@lanqin.local",
|
||||
"displayName": "Regular",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &user); code != http.StatusCreated {
|
||||
t.Fatalf("create user code=%d user=%+v", code, user)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+user.ID, map[string]any{
|
||||
"email": user.Email,
|
||||
"displayName": user.DisplayName,
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("promote admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsersListOrdersAdministratorThenAZPrimaryEmail(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
for _, user := range []struct {
|
||||
email string
|
||||
displayName string
|
||||
}{
|
||||
{"zeta@lanqin.local", "Zeta"},
|
||||
{"Alpha@lanqin.local", "Alpha"},
|
||||
{"bravo@lanqin.local", "Bravo"},
|
||||
} {
|
||||
var created AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": user.email,
|
||||
"displayName": user.displayName,
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create user %s code=%d user=%+v", user.email, code, created)
|
||||
}
|
||||
}
|
||||
|
||||
var users struct {
|
||||
Items []AdminUser `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
|
||||
t.Fatalf("list users code=%d users=%+v", code, users.Items)
|
||||
}
|
||||
if len(users.Items) < 4 {
|
||||
t.Fatalf("expected at least 4 users, got %+v", users.Items)
|
||||
}
|
||||
got := []string{users.Items[0].Email, users.Items[1].Email, users.Items[2].Email, users.Items[3].Email}
|
||||
want := []string{"admin@lanqin.local", "alpha@lanqin.local", "bravo@lanqin.local", "zeta@lanqin.local"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("users order=%v want prefix=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,7 +1942,7 @@ func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
limits.MaxMailboxCount = 1
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), limits)
|
||||
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), limits)
|
||||
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox count limit code=%d body=%v", code, errBody)
|
||||
}
|
||||
@@ -1648,6 +2042,45 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
t.Fatalf("folders for selected mailbox code=%d", code)
|
||||
}
|
||||
|
||||
var sharedFolder MailFolder
|
||||
if code := userClient.do("POST", "/api/mail/folders?mailboxId=all", map[string]string{"name": "Shared Project"}, &sharedFolder); code != http.StatusCreated {
|
||||
t.Fatalf("create shared folder code=%d folder=%+v", code, sharedFolder)
|
||||
}
|
||||
var primarySharedID, secondarySharedID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "Shared Project").Scan(&primarySharedID); err != nil {
|
||||
t.Fatalf("primary shared folder: %v", err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "Shared Project").Scan(&secondarySharedID); err != nil {
|
||||
t.Fatalf("secondary shared folder: %v", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, primarySharedID, "msg_multi_primary_read"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, secondarySharedID, "msg_multi_secondary_unread"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deleted struct {
|
||||
Moved int `json:"moved"`
|
||||
}
|
||||
deletePath := "/api/mail/folders/" + url.PathEscape(sharedFolder.ID) + "?mailboxId=all&folderName=" + url.QueryEscape(sharedFolder.Name)
|
||||
if code := userClient.do("DELETE", deletePath, nil, &deleted); code != http.StatusOK || deleted.Moved != 2 {
|
||||
t.Fatalf("delete shared folders code=%d moved=%d", code, deleted.Moved)
|
||||
}
|
||||
var sharedCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE mailbox_id IN (?,?) AND name=?`, primary.ID, secondary.ID, "Shared Project").Scan(&sharedCount); err != nil || sharedCount != 0 {
|
||||
t.Fatalf("shared folders remaining=%d err=%v", sharedCount, err)
|
||||
}
|
||||
var restoredPrimaryFolder, restoredSecondaryFolder string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_primary_read").Scan(&restoredPrimaryFolder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_secondary_unread").Scan(&restoredSecondaryFolder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restoredPrimaryFolder != primaryInboxID || restoredSecondaryFolder != secondaryInboxID {
|
||||
t.Fatalf("restored folders primary=%s secondary=%s", restoredPrimaryFolder, restoredSecondaryFolder)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
payload := map[string]any{
|
||||
"mailboxId": secondary.ID,
|
||||
@@ -1666,6 +2099,138 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMailboxBulkMoveToCustomFolderKeepsMailboxIsolation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var domainList struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
|
||||
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
|
||||
}
|
||||
domainID := domainList.Items[0].ID
|
||||
primary := createTestMailbox(t, admin, domainID, "bulk-primary", "Bulk Primary", "Password123!", nil)
|
||||
secondary := createTestMailbox(t, admin, domainID, "bulk-secondary", "Bulk Secondary", "Password456!", map[string]any{"ownerEmail": primary.Address})
|
||||
otherUserMailbox := createTestMailbox(t, admin, domainID, "bulk-other", "Bulk Other", "Password789!", nil)
|
||||
if primary.UserID != secondary.UserID {
|
||||
t.Fatalf("primary and secondary should share owner: primary=%s secondary=%s", primary.UserID, secondary.UserID)
|
||||
}
|
||||
if primary.UserID == otherUserMailbox.UserID {
|
||||
t.Fatalf("other mailbox should belong to a different user")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
primaryInboxID, err := a.ensureFolder(ctx, primary.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondaryInboxID, err := a.ensureFolder(ctx, secondary.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInboxID, err := a.ensureFolder(ctx, otherUserMailbox.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
insertMessage := func(id, mailboxID, folderID, subject string) {
|
||||
t.Helper()
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, mailboxID, folderID, "", id+"-uid", "<"+id+"@example.test>", subject, "sender@example.test", "", jsonEncode([]string{"recipient@example.test"}), "[]", "[]", now, now, subject, "", "", 0, 0, 0, 0, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insertMessage("msg_bulk_primary_move", primary.ID, primaryInboxID, "bulk move primary")
|
||||
insertMessage("msg_bulk_secondary_move", secondary.ID, secondaryInboxID, "bulk move secondary")
|
||||
insertMessage("msg_bulk_other_stays", otherUserMailbox.ID, otherInboxID, "bulk move other")
|
||||
|
||||
userClient := &testClient{t: t, server: ts}
|
||||
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
|
||||
var allInbox struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&folder=Inbox&q=bulk%20move", nil, &allInbox); code != http.StatusOK || len(allInbox.Items) != 2 {
|
||||
t.Fatalf("all inbox code=%d items=%+v", code, allInbox.Items)
|
||||
}
|
||||
messageIDs := make([]string, 0, len(allInbox.Items)+1)
|
||||
for _, item := range allInbox.Items {
|
||||
messageIDs = append(messageIDs, item.ID)
|
||||
}
|
||||
messageIDs = append(messageIDs, "msg_bulk_other_stays")
|
||||
var moved struct {
|
||||
OK bool `json:"ok"`
|
||||
Moved int `json:"moved"`
|
||||
Failed int `json:"failed"`
|
||||
Message string
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if code := userClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": messageIDs, "folder": "跨邮箱项目"}, &moved); code != http.StatusOK {
|
||||
t.Fatalf("bulk move code=%d body=%+v", code, moved)
|
||||
}
|
||||
if moved.OK || moved.Moved != 2 || moved.Failed != 1 || !strings.Contains(moved.Message, "已移动 2 封邮件,1 封失败") || len(moved.Items) != 3 {
|
||||
t.Fatalf("bulk move summary=%+v", moved)
|
||||
}
|
||||
|
||||
var primaryTargetID, secondaryTargetID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "跨邮箱项目").Scan(&primaryTargetID); err != nil {
|
||||
t.Fatalf("primary target folder: %v", err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "跨邮箱项目").Scan(&secondaryTargetID); err != nil {
|
||||
t.Fatalf("secondary target folder: %v", err)
|
||||
}
|
||||
var primaryFolderID, secondaryFolderID, otherFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_primary_move").Scan(&primaryFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_secondary_move").Scan(&secondaryFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_other_stays").Scan(&otherFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if primaryFolderID != primaryTargetID || secondaryFolderID != secondaryTargetID {
|
||||
t.Fatalf("messages moved to wrong folders primary=%s want=%s secondary=%s want=%s", primaryFolderID, primaryTargetID, secondaryFolderID, secondaryTargetID)
|
||||
}
|
||||
if otherFolderID != otherInboxID {
|
||||
t.Fatalf("other user's message moved: folder=%s want=%s", otherFolderID, otherInboxID)
|
||||
}
|
||||
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": otherUserMailbox.Address, "password": "Password789!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login code=%d", code)
|
||||
}
|
||||
var forbidden struct {
|
||||
OK bool `json:"ok"`
|
||||
Moved int `json:"moved"`
|
||||
Failed int `json:"failed"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if code := otherClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": []string{"msg_bulk_primary_move"}, "folder": "Inbox"}, &forbidden); code != http.StatusOK || forbidden.OK || forbidden.Moved != 0 || forbidden.Failed != 1 || len(forbidden.Items) != 1 || forbidden.Items[0].Message != "邮件不存在或无权访问" {
|
||||
t.Fatalf("other user bulk move primary message code=%d body=%+v", code, forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -3299,7 +3864,7 @@ func TestAdminSendAuditAccessAndFilters(t *testing.T) {
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("regular send audit code=%d", code)
|
||||
}
|
||||
updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
setRegularPermissionGroupForTest(t, a, []string{PermissionAdminOverview}, defaultPermissionLimits())
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("admin access without messages permission code=%d", code)
|
||||
}
|
||||
@@ -4129,11 +4694,15 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enabled struct {
|
||||
User User `json:"user"`
|
||||
User User `json:"user"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("enable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
if len(enabled.RecoveryCodes) != 8 {
|
||||
t.Fatalf("recovery codes=%+v", enabled.RecoveryCodes)
|
||||
}
|
||||
|
||||
fresh := &testClient{t: t, server: ts}
|
||||
var challenge struct {
|
||||
@@ -4146,14 +4715,28 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong challenge status=%d body=%v", status, out)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &login); status != http.StatusOK || fresh.cookie == nil {
|
||||
t.Fatalf("recovery login status=%d body=%v cookie=%v", status, login, fresh.cookie)
|
||||
}
|
||||
reused := &testClient{t: t, server: ts}
|
||||
if status := reused.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
|
||||
t.Fatalf("reused challenge status=%d challenge=%+v", status, challenge)
|
||||
}
|
||||
if status := reused.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &out); status != http.StatusUnauthorized {
|
||||
t.Fatalf("reused recovery status=%d body=%v", status, out)
|
||||
}
|
||||
totpClient := &testClient{t: t, server: ts}
|
||||
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
|
||||
t.Fatalf("totp challenge status=%d challenge=%+v", status, challenge)
|
||||
}
|
||||
code, err = generateTOTP(setup.Secret, a.now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil {
|
||||
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie)
|
||||
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || totpClient.cookie == nil {
|
||||
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, totpClient.cookie)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
|
||||
if status := totpClient.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("disable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
}
|
||||
@@ -4236,9 +4819,20 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
|
||||
var regularUpdateErr map[string]any
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||
"name": "Changed Regular",
|
||||
"description": "Should not change",
|
||||
"permissions": []string{PermissionAdminOverview},
|
||||
}, ®ularUpdateErr); code != http.StatusForbidden {
|
||||
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
|
||||
}
|
||||
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||
@@ -4317,7 +4911,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
}, &plainUser); code != http.StatusCreated {
|
||||
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
|
||||
}
|
||||
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionMailAccess) || userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
|
||||
}
|
||||
|
||||
@@ -4523,7 +5117,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
|
||||
t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
|
||||
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess), defaultPermissionLimits())
|
||||
noAccess := &testClient{t: t, server: ts}
|
||||
if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no access login code=%d", code)
|
||||
@@ -4532,7 +5126,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
|
||||
t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
|
||||
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend), defaultPermissionLimits())
|
||||
noSend := &testClient{t: t, server: ts}
|
||||
if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no send login code=%d", code)
|
||||
|
||||
@@ -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,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(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
|
||||
@@ -107,8 +109,8 @@ 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
|
||||
}
|
||||
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
@@ -138,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, "该邮箱已被注册")
|
||||
@@ -147,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")
|
||||
@@ -156,38 +207,6 @@ 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.config().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})
|
||||
}
|
||||
|
||||
@@ -205,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"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
MailDomain string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
@@ -51,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
|
||||
@@ -72,7 +79,8 @@ func LoadConfig() Config {
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
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"),
|
||||
@@ -108,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", ""),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -982,6 +1131,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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -292,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
|
||||
}
|
||||
|
||||
@@ -333,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 == "" {
|
||||
@@ -367,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
|
||||
}
|
||||
@@ -585,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) {
|
||||
@@ -609,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) == "" {
|
||||
@@ -629,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,
|
||||
@@ -644,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)
|
||||
@@ -682,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 == "" {
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
return u != nil && u.Role == "admin"
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -127,6 +139,8 @@ 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)
|
||||
@@ -204,8 +218,32 @@ 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
|
||||
}
|
||||
@@ -318,6 +356,12 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +446,18 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg.ExternalIMAPOutlookClientID = value
|
||||
case "externalImapOutlookClientSecret":
|
||||
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"
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -411,7 +467,7 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
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,
|
||||
@@ -443,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)
|
||||
@@ -456,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +88,7 @@ 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) {
|
||||
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
}
|
||||
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,6 +187,18 @@ 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.config().DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "<VIP> & 续费信息"} {
|
||||
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, "&") || !strings.Contains(message.HTML, "<") || !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, "<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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) {
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
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: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
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" }),
|
||||
}
|
||||
|
||||
@@ -69,8 +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: "Loading mailboxes..." },
|
||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
||||
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
|
||||
+241
-60
@@ -24,16 +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" | "externalImap" | "templates" | "security" | "about"
|
||||
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: "管理域名转发规则。" },
|
||||
@@ -58,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()
|
||||
@@ -79,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()
|
||||
@@ -133,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} initialTab={params.get("settingsTab")} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -245,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>
|
||||
@@ -291,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} />
|
||||
@@ -307,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>
|
||||
@@ -349,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 }),
|
||||
})
|
||||
@@ -358,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) => (
|
||||
@@ -389,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>}
|
||||
@@ -406,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()} />
|
||||
@@ -443,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="例如:客服主管" />
|
||||
@@ -1019,7 +1029,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) {
|
||||
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()
|
||||
@@ -1032,7 +1042,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
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)
|
||||
@@ -1045,6 +1055,13 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
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)
|
||||
@@ -1058,7 +1075,36 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
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 || ""),
|
||||
@@ -1091,6 +1137,12 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
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"] })
|
||||
@@ -1132,6 +1184,12 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
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 ? [
|
||||
@@ -1139,6 +1197,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
{ 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: "模板" }] : []),
|
||||
@@ -1248,6 +1307,85 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
</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>
|
||||
@@ -1675,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()
|
||||
@@ -1776,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)
|
||||
@@ -1791,7 +1929,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groups.length === 0 && <Empty text="暂无可分配权限配额" />}
|
||||
{groups.length === 0 && <Empty text="暂无可分配自定义权限配置" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1815,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,
|
||||
@@ -1829,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 (
|
||||
@@ -1858,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={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>
|
||||
@@ -1898,7 +2033,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
}, [user, open])
|
||||
const mut = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateUser(user.id, {
|
||||
loginName: String(form.get("loginName") || ""),
|
||||
email: String(form.get("email") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
role,
|
||||
disabled: disabled === "disabled",
|
||||
@@ -1913,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="登录名" defaultValue={accountLoginName(user)} type="text" autoComplete="off" />
|
||||
<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} />}
|
||||
@@ -1930,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={6} /><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 }) {
|
||||
@@ -1947,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="至少 6 位" /><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[] }) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+122
-79
@@ -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")
|
||||
@@ -174,10 +189,14 @@ export function MailPage() {
|
||||
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const canViewUnknownMail = user?.role === "admin"
|
||||
const canManageMailboxes = hasPermission(user, "admin.mailboxes.view")
|
||||
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])
|
||||
@@ -191,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") })
|
||||
@@ -445,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) {
|
||||
@@ -492,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)
|
||||
@@ -711,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)))
|
||||
@@ -721,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 {
|
||||
@@ -740,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 {
|
||||
@@ -947,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),
|
||||
})
|
||||
@@ -1076,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) {
|
||||
@@ -1112,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 : "收件箱"}`,
|
||||
@@ -1130,7 +1170,7 @@ export function MailPage() {
|
||||
toast({ title: "邮箱地址已复制" })
|
||||
}
|
||||
function openSettings() {
|
||||
navigate("/profile")
|
||||
void import("@/pages/profile").then(() => navigate("/profile"))
|
||||
}
|
||||
function toggleAdvancedSearch() {
|
||||
setAdvancedSearchDraft(advancedSearch)
|
||||
@@ -1183,7 +1223,7 @@ 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 || []}
|
||||
@@ -1191,19 +1231,18 @@ export function MailPage() {
|
||||
selectedMailboxId={selectedMailboxId}
|
||||
selectedMailbox={selectedMailbox}
|
||||
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" />
|
||||
@@ -1320,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>
|
||||
)}
|
||||
@@ -1450,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" && (
|
||||
@@ -1469,7 +1508,7 @@ export function MailPage() {
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onManageMailboxes={() => navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} />
|
||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
compact={compactMailLayout}
|
||||
@@ -1752,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()
|
||||
@@ -2423,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()
|
||||
@@ -2461,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>
|
||||
@@ -3178,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>
|
||||
@@ -3196,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>
|
||||
@@ -3216,11 +3259,11 @@ function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "dange
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, selectedMailbox, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; loading: boolean; selectedMailboxId: string; selectedMailbox?: Mailbox; 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 mailboxUnavailable = loading || mailboxes.length === 0
|
||||
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
||||
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)
|
||||
@@ -3234,7 +3277,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button disabled={mailboxUnavailable} 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 && (
|
||||
<>
|
||||
@@ -3249,7 +3292,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
|
||||
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 && (
|
||||
|
||||
@@ -59,6 +59,7 @@ export function ProfilePage() {
|
||||
const passwordFormRef = React.useRef<HTMLFormElement>(null)
|
||||
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
|
||||
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
||||
const [statsMailboxId, setStatsMailboxId] = React.useState("all")
|
||||
const [statsRangeDays, setStatsRangeDays] = React.useState(30)
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [displayMode, setDisplayMode] = useDisplayMode()
|
||||
@@ -66,6 +67,7 @@ export function ProfilePage() {
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
|
||||
const [twoFactorRecoveryCodes, setTwoFactorRecoveryCodes] = React.useState<string[]>([])
|
||||
const isMobile = useIsMobile()
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
@@ -98,29 +100,32 @@ export function ProfilePage() {
|
||||
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account"
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
|
||||
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox && tab === "mailboxes" })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings, enabled: tab === "mailboxes" || (tab === "profile" && accountTab === "clients") })
|
||||
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens, enabled: tab === "apiTokens" })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts && tab === "contacts" })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures && tab === "profile" && accountTab === "mail" })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules && tab === "rules" })
|
||||
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail && tab === "rules" })
|
||||
const ruleVerifiedEmails = React.useMemo(() => ruleForwarding.data?.verifiedEmails.filter((item) => item.verified).map((item) => item.email) || [], [ruleForwarding.data?.verifiedEmails])
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked && tab === "blocked" })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: tab === "mailboxes" && !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
React.useEffect(() => {
|
||||
if (!externalRunAccountId) return
|
||||
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
||||
setExternalRunAccountId("")
|
||||
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
||||
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats })
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && ((tab === "profile" && accountTab === "mail" && (canReadMail || canManageLabels)) || (tab === "rules" && canManageRules)) })
|
||||
const accountStats = useQuery({ queryKey: ["mail-stats", "all", 30], queryFn: () => api.mailStats("all", 30), enabled: canViewStats && tab === "profile" && accountTab === "account" })
|
||||
const mailboxStats = useQuery({ queryKey: ["mail-stats", activeMailboxId, 30], queryFn: () => api.mailStats(activeMailboxId, 30), enabled: !!activeMailboxId && canViewStats && (tab === "cleanup" || tab === "cleanupQueue") })
|
||||
const blockedStats = useQuery({ queryKey: ["mail-stats", blockedMailboxId, 30], queryFn: () => api.mailStats(blockedMailboxId, 30), enabled: canViewStats && tab === "blocked" })
|
||||
const dashboardStats = useQuery({ queryKey: ["mail-stats", statsMailboxId, statsRangeDays], queryFn: () => api.mailStats(statsMailboxId, statsRangeDays), enabled: canViewStats && tab === "stats" })
|
||||
|
||||
const profile = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
||||
@@ -138,17 +143,17 @@ export function ProfilePage() {
|
||||
})
|
||||
const setupTwoFactor = useMutation({
|
||||
mutationFn: api.setupTwoFactor,
|
||||
onSuccess: () => toast({ title: "双因素密钥已生成" }),
|
||||
onSuccess: () => { setTwoFactorRecoveryCodes([]); toast({ title: "双因素密钥已生成" }) },
|
||||
onError: (error) => toast({ title: "生成失败", description: error.message }),
|
||||
})
|
||||
const enableTwoFactor = useMutation({
|
||||
mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")),
|
||||
onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
|
||||
onSuccess: (data) => { qc.setQueryData(["me"], { user: data.user }); setTwoFactorRecoveryCodes(data.recoveryCodes || []); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
|
||||
onError: (error) => toast({ title: "启用失败", description: error.message }),
|
||||
})
|
||||
const disableTwoFactor = useMutation({
|
||||
mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")),
|
||||
onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
|
||||
onSuccess: (data) => { qc.setQueryData(["me"], data); setTwoFactorRecoveryCodes([]); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
|
||||
onError: (error) => toast({ title: "关闭失败", description: error.message }),
|
||||
})
|
||||
const createApiToken = useMutation({
|
||||
@@ -335,9 +340,9 @@ export function ProfilePage() {
|
||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="h-[64px] border-b">
|
||||
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.loginName || user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
||||
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground">管理</div>
|
||||
@@ -356,6 +361,16 @@ export function ProfilePage() {
|
||||
<span className="truncate">{tabs[key].label}</span>
|
||||
</button>
|
||||
))}
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 stroke-[1.8]" />
|
||||
<span className="truncate">后台管理</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t p-2">
|
||||
@@ -370,7 +385,10 @@ export function ProfilePage() {
|
||||
const pageTitle = tabs[tab].label
|
||||
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
|
||||
const pageAction = tab === "stats"
|
||||
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
? <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[220px]"><MailboxSelect value={statsMailboxId} mailboxes={mailboxes.data?.items || []} onChange={setStatsMailboxId} /></div>
|
||||
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
</div>
|
||||
: tab === "apiTokens"
|
||||
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API 文档</a></Button>
|
||||
: undefined
|
||||
@@ -420,7 +438,7 @@ export function ProfilePage() {
|
||||
profile={profile}
|
||||
password={password}
|
||||
passwordFormRef={passwordFormRef}
|
||||
stats={canViewStats ? stats.data : undefined}
|
||||
stats={canViewStats ? accountStats.data : undefined}
|
||||
showStats={canViewStats}
|
||||
displayMode={displayMode}
|
||||
onDisplayModeChange={setDisplayMode}
|
||||
@@ -428,6 +446,7 @@ export function ProfilePage() {
|
||||
setupTwoFactor={setupTwoFactor}
|
||||
enableTwoFactor={enableTwoFactor}
|
||||
disableTwoFactor={disableTwoFactor}
|
||||
twoFactorRecoveryCodes={twoFactorRecoveryCodes}
|
||||
mailboxes={mailboxes.data?.items || []}
|
||||
selectedMailboxId={mailboxId}
|
||||
selectedMailbox={selectedMailbox}
|
||||
@@ -477,11 +496,11 @@ export function ProfilePage() {
|
||||
/>
|
||||
)
|
||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} verifiedEmails={ruleVerifiedEmails} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? blockedStats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={dashboardStats.data} />
|
||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
||||
return null
|
||||
}
|
||||
@@ -565,6 +584,7 @@ type AccountSettingsSectionProps = {
|
||||
setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }
|
||||
enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
|
||||
disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
|
||||
twoFactorRecoveryCodes: string[]
|
||||
onCopy: (text: string) => void
|
||||
mailboxes: Mailbox[]
|
||||
selectedMailboxId: string
|
||||
@@ -620,6 +640,7 @@ function AccountSettingsSection(props: AccountSettingsSectionProps) {
|
||||
setupTwoFactor={props.setupTwoFactor}
|
||||
enableTwoFactor={props.enableTwoFactor}
|
||||
disableTwoFactor={props.disableTwoFactor}
|
||||
recoveryCodes={props.twoFactorRecoveryCodes}
|
||||
onCopy={props.onCopy}
|
||||
/>
|
||||
)
|
||||
@@ -655,7 +676,7 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
|
||||
}
|
||||
|
||||
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
||||
const accountName = user.loginName || user.email
|
||||
const accountName = user.email
|
||||
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
||||
const storageBytes = stats?.storageBytes || 0
|
||||
const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0
|
||||
@@ -663,7 +684,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="账号信息">
|
||||
<div className="space-y-5">
|
||||
<InfoLine label="用户名" value={accountName} />
|
||||
<InfoLine label="主登录邮箱" value={accountName} />
|
||||
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
||||
<Select defaultValue="Asia/Shanghai">
|
||||
@@ -892,14 +913,14 @@ function EditSignatureForm({ item, mailboxes, pending, onCancel, onSubmit }: { i
|
||||
)
|
||||
}
|
||||
|
||||
function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject<HTMLFormElement>; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; onCopy: (text: string) => void }) {
|
||||
function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, recoveryCodes, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject<HTMLFormElement>; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; recoveryCodes: string[]; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="当前登录" contentClassName="border-t py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="font-semibold">{user.loginName || user.email}</div>
|
||||
<div className="font-semibold">{user.email}</div>
|
||||
<div className="text-sm text-muted-foreground">上次登录:刚刚</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -951,12 +972,28 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
||||
</form>
|
||||
)}
|
||||
{user.twoFactorEnabled && (
|
||||
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field label="当前验证码"><Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required /></Field>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭两步验证"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{recoveryCodes.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">恢复码</div>
|
||||
<div className="text-xs text-muted-foreground">每个恢复码只能使用一次。</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(recoveryCodes.join("\n"))}><Copy className="h-4 w-4" />复制</Button>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{recoveryCodes.map((code) => <code key={code} className="rounded-md bg-background px-3 py-2 text-sm font-semibold">{code}</code>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field label="当前验证码或恢复码"><Input name="code" autoComplete="one-time-code" minLength={6} required /></Field>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭两步验证"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -2394,7 +2431,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
||||
)
|
||||
}
|
||||
|
||||
function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
|
||||
function StatsSection({ stats }: { stats?: MailStats }) {
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100)
|
||||
const primaryCards = [
|
||||
|
||||
@@ -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,14 +98,12 @@ 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>
|
||||
@@ -120,7 +114,7 @@ export function RegisterPage() {
|
||||
<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>
|
||||
|
||||
+23
-3
@@ -51,9 +51,13 @@ LANQIN_TLS_KEY_FILE=
|
||||
# =========================
|
||||
# 初始管理员
|
||||
# =========================
|
||||
# 第一次启动时只创建管理员账号,不会自动创建同名邮箱或域名。
|
||||
# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
# 第一次启动时会创建唯一管理员账号和真实管理员邮箱。
|
||||
# 网页前台和后台统一使用完整主登录邮箱 + 密码登录。
|
||||
LANQIN_MAIL_DOMAIN=example.com
|
||||
LANQIN_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# 旧版本兼容字段,网页登录不再使用;新安装脚本会写入管理员邮箱前缀。
|
||||
LANQIN_ADMIN_USERNAME=
|
||||
|
||||
# 生产环境必须改掉默认密码。
|
||||
LANQIN_ADMIN_PASSWORD=ChangeMe123!
|
||||
@@ -179,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
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
+37
-2
@@ -15,11 +15,19 @@ 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` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||
首次安装会依次询问防火墙模式、邮件服务器域名、邮箱地址域名、管理员邮箱/密码和 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` 应急关闭。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
@@ -28,7 +36,7 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / 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
|
||||
```
|
||||
@@ -140,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`。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 Secrets;Telegram 失败不阻塞收件或版本发布。 |
|
||||
| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 |
|
||||
| 目标版本 | 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 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 |
|
||||
+800
-119
File diff suppressed because it is too large
Load Diff
+451
-12
@@ -36,6 +36,27 @@ test_password_validation() {
|
||||
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() {
|
||||
@@ -47,7 +68,8 @@ test_install_configuration() {
|
||||
export INSTALL_DIR="${temp_dir}"
|
||||
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
|
||||
export LANQIN_PUBLIC_HOSTNAME="mail.example.com"
|
||||
export LANQIN_ADMIN_USERNAME="admin"
|
||||
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
|
||||
@@ -58,6 +80,9 @@ test_install_configuration() {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -114,38 +139,452 @@ test_menu_choice() {
|
||||
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=9
|
||||
assert_eq "9" "$(prompt_menu_choice 1)" "menu uninstall 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_backup_reinstall_preserves_existing_directory() (
|
||||
local temp_dir backup_dir
|
||||
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() {
|
||||
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "fresh install started before old directory was moved"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
|
||||
return 1
|
||||
}
|
||||
|
||||
do_backup_reinstall
|
||||
backup_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.backup-*' -print -quit)"
|
||||
[[ -n "${backup_dir}" ]] || fail_test "existing install backup directory missing"
|
||||
grep -Fq 'existing-data' "${backup_dir}/marker" || fail_test "existing install data was not preserved"
|
||||
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_install_configuration 3 3 "80" "http://mail.example.com" "true"
|
||||
test_nginx_configuration
|
||||
test_compose_configuration
|
||||
test_legacy_configuration_is_preserved
|
||||
test_menu_choice
|
||||
test_backup_reinstall_preserves_existing_directory
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user