Compare commits

...

27 Commits

Author SHA1 Message Date
szx 28c468cc1a Merge pull request '优化 DNS 记录配置与检查提示' (#1) from codex/dns-record-guidance into main
CI / Check web and api (push) Waiting to run
Reviewed-on: https://gitea.xzys.me/szx/NewSzxcn-Email/pulls/1
2026-08-14 14:08:03 +08:00
zxyszx 75c399e178 refine DNS record guidance
AI PR Review / AI PR Review (pull_request) Waiting to run
CI / Check web and api (pull_request) Waiting to run
2026-08-14 13:14:56 +08:00
zxyszx d345ae7e08 release: prepare v1.2.44 2026-08-14 07:28:40 +08:00
zxyszx 7cbae154bf chore: configure Gitea repository source
CI / Check web and api (push) Has been cancelled
2026-08-14 06:08:44 +08:00
云逸 bd6edf353d Merge pull request #2 from zxyszx/codex/release-v1.2.43
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
release: v1.2.43
2026-08-14 05:29:58 +08:00
zxyszx 14ec1979b2 release: prepare v1.2.43 2026-08-14 05:27:03 +08:00
zxyszx 6fa98cec97 refactor: remove settings about section 2026-08-14 05:25:19 +08:00
zxyszx 71746bdfba feat: refine admin dashboard and mail experience 2026-08-14 05:19:03 +08:00
zxyszx f15a3d3222 release: prepare v1.2.42
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 18:42:27 +08:00
zxyszx 7fc91f2ed9 release: prepare v1.2.41
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 02:09:04 +08:00
zxyszx c482e5b896 release: prepare v1.2.40
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 01:39:33 +08:00
zxyszx 07e62e9c2d test: wait for manual backup completion 2026-08-13 01:38:59 +08:00
zxyszx 5797b472d9 release: prepare v1.2.39
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 01:34:19 +08:00
zxyszx 3caee11e38 release: prepare v1.2.38
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 01:24:12 +08:00
zxyszx 2dd3647923 release: prepare v1.2.37
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 01:11:44 +08:00
zxyszx ccb8ce01d9 release: prepare v1.2.36
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-13 00:55:10 +08:00
zxyszx 4e3b69608f release: prepare v1.2.35
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 18:41:49 +08:00
zxyszx 1dbc33b0dc release: prepare v1.2.34
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 17:58:48 +08:00
zxyszx 48a1d53133 release: prepare v1.2.33
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 16:59:20 +08:00
zxyszx ff5578368a fix: satisfy installer shellcheck
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 16:17:38 +08:00
zxyszx fc3a3462cf release: prepare v1.2.32 2026-08-12 16:15:19 +08:00
zxyszx 9a572e0100 release: prepare v1.2.31
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 06:01:56 +08:00
zxyszx c92140c9cc release: prepare v1.2.30
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 05:34:49 +08:00
zxyszx 2fd37bf635 release: prepare v1.2.29
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 04:25:17 +08:00
zxyszx 3d3a251af2 release: prepare v1.2.28
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 01:15:42 +08:00
zxyszx 6ad9164be3 release: prepare v1.2.27
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 23:40:55 +08:00
zxyszx ce98978ebd release: prepare v1.2.26
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 14:09:08 +08:00
70 changed files with 5122 additions and 846 deletions
+7
View File
@@ -0,0 +1,7 @@
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
+6
View File
@@ -0,0 +1,6 @@
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
+8
View File
@@ -0,0 +1,8 @@
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
+13
View File
@@ -0,0 +1,13 @@
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
+9
View File
@@ -0,0 +1,9 @@
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
+7
View File
@@ -0,0 +1,7 @@
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
+11
View File
@@ -0,0 +1,11 @@
- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。
- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。
- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。
- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。
- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。
- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。
- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。
- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。
- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。
**完整更新日志**[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32)
+9
View File
@@ -0,0 +1,9 @@
- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。
- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。
- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。
- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。
- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。
- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。
- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。
**完整更新日志**[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33)
+9
View File
@@ -0,0 +1,9 @@
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
**完整更新日志**[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
+7
View File
@@ -0,0 +1,7 @@
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
**完整更新日志**[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
+11
View File
@@ -0,0 +1,11 @@
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
**完整更新日志**[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
+4
View File
@@ -0,0 +1,4 @@
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
**完整更新日志**[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
+6
View File
@@ -0,0 +1,6 @@
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
**完整更新日志**[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
+4
View File
@@ -0,0 +1,4 @@
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
**完整更新日志**[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
+6
View File
@@ -0,0 +1,6 @@
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
**完整更新日志**[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
+3
View File
@@ -0,0 +1,3 @@
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
**完整更新日志**[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
+4
View File
@@ -0,0 +1,4 @@
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
**完整更新日志**[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
+6
View File
@@ -0,0 +1,6 @@
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
**完整更新日志**[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
+5
View File
@@ -0,0 +1,5 @@
- 修复刷新后台仪表盘时,注册、自助申请邮箱等功能在设置读取完成前短暂显示为关闭的问题。
- 为后台统计、系统状态、域名与列表页面、备份设置和个人中心补充统一加载状态,避免将未加载数据误显示为 0、未配置或暂无数据。
- 优化受限权限账号的状态展示:无权读取的系统设置明确显示为不可查看,不再误判为关闭或未配置。
**完整更新日志**[v1.2.43...v1.2.44](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.43...v1.2.44)
+3 -3
View File
@@ -2,7 +2,7 @@
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment. NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md) [Releases](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [Chinese README](README.md)
## Features ## Features
@@ -18,7 +18,7 @@ NewSzxcn Email is a self-hosted mail server with a complete Webmail client and a
Debian and Ubuntu on `amd64` or `arm64` are supported. Debian and Ubuntu on `amd64` or `arm64` are supported.
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
``` ```
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator. The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
@@ -55,7 +55,7 @@ Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Publ
## Manual source deployment ## Manual source deployment
```bash ```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy cd NewSzxcn-Email/deploy
cp .env.example .env cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
+5 -10
View File
@@ -2,12 +2,7 @@
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。 NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
[![Release](https://img.shields.io/github/v/release/zxyszx/NewSzxcn-Email?display_name=tag&sort=semver)](https://github.com/zxyszx/NewSzxcn-Email/releases) [邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) · [MIT License](LICENSE)
[![Docker Release](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能 ## 主要功能
@@ -26,13 +21,13 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com` 支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
``` ```
已使用 `root` 登录时,也可以使用: 已使用 `root` 登录时,也可以使用:
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
``` ```
### 管理面板 ### 管理面板
@@ -194,7 +189,7 @@ sudo newszxcn-email uninstall
需要自行控制 Compose 配置时: 需要自行控制 Compose 配置时:
```bash ```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy cd NewSzxcn-Email/deploy
cp .env.example .env cp .env.example .env
# 编辑 .env # 编辑 .env
@@ -216,7 +211,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
- 后端:Go、Chi、SQLite - 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS - 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd - 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR - 部署:Docker、Docker Compose、GHCR
## 本地开发 ## 本地开发
+7 -5
View File
@@ -2,7 +2,7 @@
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。 NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能 ## 主要功能
@@ -21,13 +21,13 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com` 支持 Debian / Ubuntu 的 `amd64``arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
``` ```
已使用 `root` 登录时,也可以使用: 已使用 `root` 登录时,也可以使用:
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
``` ```
脚本会自动完成: 脚本会自动完成:
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
## 更新与回滚 ## 更新与回滚
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
### 后台页面更新 ### 后台页面更新
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
@@ -121,7 +123,7 @@ sudo newszxcn-email uninstall
需要自行控制 Compose 配置时: 需要自行控制 Compose 配置时:
```bash ```bash
git clone https://github.com/zxyszx/NewSzxcn-Email.git git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
cd NewSzxcn-Email/deploy cd NewSzxcn-Email/deploy
cp .env.example .env cp .env.example .env
# 编辑 .env # 编辑 .env
@@ -143,7 +145,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
- 后端:Go、Chi、SQLite - 后端:Go、Chi、SQLite
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS - 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
- 邮件:Postfix、Dovecot、Rspamd - 邮件:Postfix、Dovecot、Rspamd
- 部署:Docker、Docker Compose、GitHub Actions、GHCR - 部署:Docker、Docker Compose、GHCR
## 本地开发 ## 本地开发
+1 -1
View File
@@ -1 +1 @@
1.2.25 1.2.44
+171 -66
View File
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
UnreadMessages int64 `json:"unreadMessages"` UnreadMessages int64 `json:"unreadMessages"`
StorageBytes int64 `json:"storageBytes"` StorageBytes int64 `json:"storageBytes"`
TodaySent int64 `json:"todaySent"`
TodayReceived int64 `json:"todayReceived"`
SendDelivered int64 `json:"sendDelivered"`
SendFailed int64 `json:"sendFailed"`
QueueMessages int64 `json:"queueMessages"`
} }
now := a.now().UTC()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
queries := []struct { queries := []struct {
q string q string
dest *int64 dest *int64
args []any
}{ }{
{`SELECT COUNT(*) FROM users`, &out.Users}, {q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers}, {q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
{`SELECT COUNT(*) FROM domains`, &out.Domains}, {q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes}, {q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes}, {q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
{`SELECT COUNT(*) FROM aliases`, &out.Aliases}, {q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
{`SELECT COUNT(*) FROM messages`, &out.Messages}, {q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages}, {q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes}, {q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
} }
for _, item := range queries { for _, item := range queries {
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil { if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load overview") respondError(w, http.StatusInternalServerError, "failed to load overview")
return return
} }
@@ -49,9 +62,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`) 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users") respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -64,7 +77,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64 var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users") respondError(w, http.StatusInternalServerError, "failed to scan users")
return return
} }
@@ -101,6 +114,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"` MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB int `json:"storageQuotaMb"`
PermissionGroupIDs []string `json:"permissionGroupIds"` PermissionGroupIDs []string `json:"permissionGroupIds"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -142,6 +156,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" { if role == "admin" {
mailboxLimitOverride = nil mailboxLimitOverride = nil
} }
storageQuotaMB := req.StorageQuotaMB
if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if storageQuotaMB == 0 {
storageQuotaMB = defaultUserStorageQuotaMB
}
if !hasMinimumPasswordLength(req.Password) { if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters")) badRequest(w, errors.New("password must be at least 6 characters"))
return return
@@ -159,11 +181,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() 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) if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
var primaryDomainID string
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
badRequest(w, err)
return
}
} else if !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "failed to load account domain")
return
}
permissionGroupIDs := req.PermissionGroupIDs permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" { if role == "admin" {
permissionGroupIDs = nil permissionGroupIDs = nil
@@ -194,6 +227,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"` Role string `json:"role"`
Disabled *bool `json:"disabled"` Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"` MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB *int `json:"storageQuotaMb"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"` PermissionGroupIDs *[]string `json:"permissionGroupIds"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -267,6 +301,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" { if role == "admin" {
mailboxLimitOverride = nil mailboxLimitOverride = nil
} }
var storageQuotaMB int
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
return
}
if req.StorageQuotaMB != nil {
storageQuotaMB = *req.StorageQuotaMB
}
if storageQuotaMB < 100 {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil { if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err) badRequest(w, err)
return return
@@ -311,8 +357,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用")) badRequest(w, errors.New("主登录邮箱已被使用"))
return return
@@ -409,11 +455,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if target, err := a.userByID(r.Context(), id); err != nil { if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found") respondError(w, http.StatusNotFound, "user not found")
return return
} else if a.isDefaultAdminUser(target) { } else if target.Role == "admin" {
badRequest(w, errors.New("default administrator cannot be deleted")) badRequest(w, errors.New("administrator accounts cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
return return
} }
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil { if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
@@ -553,6 +596,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created) m.CreatedAt = parseTime(created)
items = append(items, m) items = append(items, m)
} }
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items}) respondJSON(w, http.StatusOK, map[string]any{"items": items})
} }
@@ -563,7 +607,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Password string `json:"password"` Password string `json:"password"`
QuotaMB int `json:"quotaMb"` QuotaMB int `json:"quotaMb"`
Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"` OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"` OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"` UserID string `json:"userId"`
@@ -580,20 +623,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err) badRequest(w, err)
return return
} }
if !hasMinimumPasswordLength(req.Password) { userID := strings.TrimSpace(req.UserID)
badRequest(w, errors.New("password must be at least 6 characters")) if req.QuotaMB < 0 {
return badRequest(w, errors.New("quotaMb must be zero or greater"))
}
role := req.Role
if role == "" {
role = "user"
}
if role != "user" && role != "admin" {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return return
} }
@@ -611,15 +643,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
userID := strings.TrimSpace(req.UserID)
displayName := req.DisplayName displayName := req.DisplayName
if displayName == "" { if displayName == "" {
displayName = address displayName = address
} }
var disabled, ownerStorageQuotaMB int
var passwordHash, ownerRole string
if userID != "" { if userID != "" {
var disabled int if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found") respondError(w, http.StatusNotFound, "owner user not found")
} else { } else {
@@ -627,11 +658,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
if intBool(disabled) { } else {
badRequest(w, errors.New("owner user is disabled")) if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return return
} }
} else {
ownerEmailInput := req.OwnerEmail ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") { if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName ownerEmailInput = req.OwnerLoginName
@@ -641,21 +672,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err) badRequest(w, err)
return return
} }
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID) err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if hashErr != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password") respondError(w, http.StatusInternalServerError, "failed to hash password")
return return
} }
userID = newID("usr") userID = newID("usr")
ownerDisplayName := displayName passwordHash = string(hash)
if !strings.EqualFold(ownerEmail, address) { ownerRole = "user"
ownerDisplayName = ownerEmail ownerStorageQuotaMB = defaultUserStorageQuotaMB
} now := a.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now) VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
if err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
@@ -664,16 +694,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if err := tx.Commit(); err != nil { if intBool(disabled) {
respondError(w, http.StatusInternalServerError, "failed to prepare owner user") badRequest(w, errors.New("owner user is disabled"))
return return
} }
quotaMB := req.QuotaMB
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active") if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
m, err := a.mailboxByID(r.Context(), mailboxID) m, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox") respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -699,8 +739,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName is required")) badRequest(w, errors.New("displayName is required"))
return return
} }
if req.QuotaMB <= 0 { if req.QuotaMB < 0 {
req.QuotaMB = 1024 badRequest(w, errors.New("quotaMb must be zero or greater"))
return
} }
status := strings.TrimSpace(req.Status) status := strings.TrimSpace(req.Status)
if status == "" { if status == "" {
@@ -710,13 +751,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid status")) badRequest(w, errors.New("invalid status"))
return return
} }
existingMailbox, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if existingMailbox.Primary && status != existingMailbox.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
userID := strings.TrimSpace(req.UserID) userID := strings.TrimSpace(req.UserID)
if userID == "" { if userID == "" {
badRequest(w, errors.New("userId is required")) badRequest(w, errors.New("userId is required"))
return return
} }
if existingMailbox.Primary && userID != existingMailbox.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
var disabled int var disabled int
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil { var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found") respondError(w, http.StatusNotFound, "owner user not found")
} else { } else {
@@ -728,8 +783,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("owner user is disabled")) badRequest(w, errors.New("owner user is disabled"))
return return
} }
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, if ownerRole == "admin" {
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) req.QuotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox") respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return return
@@ -749,6 +807,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -1118,15 +1184,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
} }
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) { func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=? WHERE u.id=?
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id) GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
var item AdminUser var item AdminUser
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64 var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string var created, mailboxCSV string
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err return nil, err
} }
item.Disabled = intBool(disabled) item.Disabled = intBool(disabled)
@@ -1191,9 +1257,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
return nil, err return nil, err
} }
m.CreatedAt = parseTime(created) m.CreatedAt = parseTime(created)
if err := a.markMailboxPrimary(ctx, &m); err != nil {
return nil, err
}
return &m, nil return &m, nil
} }
func markPrimaryMailboxes(items []Mailbox) {
primaryByUser := make(map[string]int)
for i := range items {
candidate, ok := primaryByUser[items[i].UserID]
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
primaryByUser[items[i].UserID] = i
}
}
for _, index := range primaryByUser {
items[index].Primary = true
}
}
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
var primaryID string
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE mb.user_id=?
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
if err != nil {
return err
}
mailbox.Primary = mailbox.ID == primaryID
return nil
}
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
mailbox, err := a.mailboxByID(ctx, id)
if err != nil {
return err
}
if mailbox.Primary {
return errors.New("用户默认邮箱不能删除")
}
return nil
}
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) { func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID) row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
var m Mailbox var m Mailbox
+76 -7
View File
@@ -38,8 +38,17 @@ type App struct {
telegramPairMu sync.Mutex telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex telegramDeliveryMu sync.Mutex
backupMu sync.Mutex
backupJob *backupJob
backupTransfers map[string]*backupTransfer
} }
const (
defaultUserStorageQuotaMB = 100
defaultAdminStorageQuotaMB = 1024
minimumStorageQuotaMB = 100
)
func (a *App) config() Config { func (a *App) config() Config {
a.cfgMu.RLock() a.cfgMu.RLock()
defer a.cfgMu.RUnlock() defer a.cfgMu.RUnlock()
@@ -75,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
} }
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}} a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
a.externalIMAP = a a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil { if err := a.configureSQLite(context.Background()); err != nil {
db.Close() db.Close()
@@ -97,6 +106,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close() db.Close()
return nil, err return nil, err
} }
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil { if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -120,6 +133,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) }) a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) }) a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) }) a.startWorker(func() { a.telegramMailWorker(workerCtx) })
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
return a, nil return a, nil
} }
@@ -168,6 +182,7 @@ func (a *App) migrate(ctx context.Context) error {
two_factor_secret TEXT NOT NULL DEFAULT '', two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0, two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER, mailbox_limit_override INTEGER,
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -673,6 +688,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil { if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err return err
} }
if err := a.migrateUserStorageQuota(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil { if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err return err
} }
@@ -1335,6 +1353,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
return err return err
} }
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
return err
}
hasColumn := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "storage_quota_mb" {
hasColumn = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasColumn {
return nil
}
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
return err
}
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error { func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil { if err != nil {
@@ -1513,8 +1564,8 @@ func (a *App) seed(ctx context.Context) error {
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
return err return err
} }
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1535,7 +1586,7 @@ func (a *App) seed(ctx context.Context) error {
} }
// Create mailbox for admin // Create mailbox for admin
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active") mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
if err != nil { if err != nil {
return err return err
} }
@@ -1656,7 +1707,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active") mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
if err != nil { if err != nil {
return err return err
} }
@@ -1871,8 +1922,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
if localPart == "" { if localPart == "" {
return "", errors.New("invalid local part") return "", errors.New("invalid local part")
} }
if quotaMB <= 0 { if quotaMB < 0 {
quotaMB = 1024 return "", errors.New("quotaMb must be zero or greater")
}
var ownerRole string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
return "", err
}
if ownerRole == "admin" {
quotaMB = 0
} }
if status == "" { if status == "" {
status = "active" status = "active"
@@ -1905,6 +1963,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
return id, nil return id, nil
} }
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
SET quota_mb=0, updated_at=?
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
return err
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error { func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config() cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox") folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
+192 -8
View File
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox return mailbox
} }
func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID)
if err != nil {
t.Fatal(err)
}
if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB {
t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB)
}
domainID := mustDefaultDomainID(t, a)
var secondary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "admin-secondary",
"userId": adminUser.ID,
}, &secondary); code != http.StatusCreated {
t.Fatalf("create admin secondary mailbox code=%d", code)
}
if secondary.QuotaMB != 0 {
t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB)
}
var primaryHash, secondaryHash string
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil {
t.Fatal(err)
}
if primaryHash != secondaryHash {
t.Fatal("admin secondary mailbox did not inherit the owner password")
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "owner@lanqin.local",
"displayName": "Owner",
"password": "OwnerPassword123!",
"role": "user",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular owner code=%d", code)
}
if regular.MailboxCount != 1 {
t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount)
}
if regular.StorageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB)
}
if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil {
t.Fatalf("new account primary mailbox missing: %v", err)
}
var primaryStatusErr map[string]any
primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{
"userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled",
}, &primaryStatusErr); code != http.StatusBadRequest {
t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr)
}
var regularMailbox Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "owner-secondary",
"userId": regular.ID,
}, &regularMailbox); code != http.StatusCreated {
t.Fatalf("create regular secondary mailbox code=%d", code)
}
if regularMailbox.QuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB)
}
var userHash, mailboxHash string
if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil {
t.Fatal(err)
}
if userHash != mailboxHash {
t.Fatal("regular secondary mailbox did not inherit the owner password")
}
}
func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
var errBody map[string]any
if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator account delete code=%d body=%v", code, errBody)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody)
}
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99,
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody)
}
var updatedAdmin AdminUser
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100,
}, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 {
t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin)
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular user code=%d user=%+v", code, regular)
}
primary, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody)
}
secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID})
if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK {
t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody)
}
}
func createTestAPIToken(t *testing.T, client *testClient, name string) string { func createTestAPIToken(t *testing.T, client *testClient, name string) string {
return createTestAPITokenWithScopes(t, client, name, nil) return createTestAPITokenWithScopes(t, client, name, nil)
} }
@@ -1574,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" { if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User) t.Fatalf("register code=%d user=%+v", code, registered.User)
} }
var storageQuotaMB int
if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil {
t.Fatal(err)
}
if storageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB)
}
var me struct { var me struct {
User User `json:"user"` User User `json:"user"`
} }
@@ -3177,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK { if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain) t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
} }
var owner AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!",
}, &owner); code != http.StatusCreated {
t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner)
}
var mailbox Mailbox var mailbox Mailbox
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{ if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
"domainId": domain.ID, "domainId": domain.ID,
"localPart": "api-user", "localPart": "api-user",
"displayName": "API User", "displayName": "API User",
"password": "Password123!", "password": "DifferentPassword123!",
"quotaMb": 256, "quotaMb": 256,
"userId": owner.ID,
}, &mailbox); code != http.StatusCreated { }, &mailbox); code != http.StatusCreated {
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox) t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
} }
ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email)
if err != nil {
t.Fatal(err)
}
var protectedMailboxErr map[string]any
if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest {
t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr)
}
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 { if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
t.Fatalf("mailbox=%+v", mailbox) t.Fatalf("mailbox=%+v", mailbox)
} }
var ownerPasswordHash, mailboxPasswordHash string
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil {
t.Fatal(err)
}
if mailboxPasswordHash != ownerPasswordHash {
t.Fatal("open api mailbox did not inherit the owner password")
}
var mailboxes struct { var mailboxes struct {
Items []Mailbox `json:"items"` Items []Mailbox `json:"items"`
} }
@@ -3207,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" { if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
t.Fatalf("updated mailbox=%+v", updated) t.Fatalf("updated mailbox=%+v", updated)
} }
if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 {
t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated)
}
var ok map[string]any var ok map[string]any
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK { if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok) t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
@@ -5195,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden { }, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody) t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
} }
var regularUpdateErr map[string]any var updatedRegular PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{ if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Changed Regular", "name": "Changed Regular",
"description": "Should not change", "description": "Should not change",
"permissions": []string{PermissionAdminOverview}, "permissions": regularUserDefaultPermissions(),
}, &regularUpdateErr); code != http.StatusForbidden { "limits": defaultPermissionLimits(),
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr) }, &updatedRegular); code != http.StatusOK {
t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular)
}
if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" {
t.Fatalf("regular system permission group identity changed: %+v", updatedRegular)
} }
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular) regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) { if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup) t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup)
} }
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden { 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) t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -6357,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
ctx := context.Background() ctx := context.Background()
user, mb := defaultAdminUserAndMailbox(t, a) user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID) clearMailboxMessagesForTest(t, a, mb.ID)
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil { if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{ _, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
@@ -6433,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK { if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats) t.Fatalf("stats code=%d stats=%+v", code, stats)
} }
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 { var aliceStorageQuotaMB int64
if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil {
t.Fatal(err)
}
if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats) t.Fatalf("stats quota/attachment not populated: %+v", stats)
} }
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 { if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
+3 -3
View File
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() 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) if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册") respondError(w, http.StatusConflict, "该邮箱已被注册")
return return
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return return
} }
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil { if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册") respondError(w, http.StatusConflict, "该邮箱已被注册")
} else { } else {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
}
}
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
if err != nil {
t.Fatal(err)
}
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
t.Fatalf("unexpected groups: %+v", groups)
}
}
func TestGoogleDriveResumableRequest(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
if err != nil {
t.Fatal(err)
}
if size != int64(len("encrypted backup")) {
t.Fatalf("upload size = %d", size)
}
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
}
var metadata struct {
Name string `json:"name"`
Parents []string `json:"parents"`
}
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
t.Fatal(err)
}
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
t.Fatalf("metadata = %+v", metadata)
}
}
func TestBackupProgressReaderReportsBytes(t *testing.T) {
var updates []int64
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
updates = append(updates, uploaded)
}}
raw, err := io.ReadAll(reader)
if err != nil || string(raw) != "encrypted backup" {
t.Fatalf("read = %q, %v", raw, err)
}
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
t.Fatalf("progress updates = %v", updates)
}
}
func TestGoogleDriveUploadMessage(t *testing.T) {
tests := []struct {
status int
body string
want string
}{
{http.StatusUnauthorized, `{}`, "授权已失效"},
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
{http.StatusForbidden, `{}`, "无上传权限"},
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
}
for _, test := range tests {
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
if !strings.Contains(message, test.want) {
t.Fatalf("message %q does not contain %q", message, test.want)
}
}
}
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "large-backup.tar.zst.enc")
size := int64(googleDriveUploadChunkSize + 3)
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(size); err != nil {
t.Fatal(err)
}
_ = file.Close()
var ranges []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ranges = append(ranges, r.Header.Get("Content-Range"))
_, _ = io.Copy(io.Discard, r.Body)
if len(ranges) == 1 {
w.WriteHeader(http.StatusPermanentRedirect)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
if !a.startBackupTransfer("googleDrive", path) {
t.Fatal("failed to start transfer")
}
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
t.Fatal(err)
}
wantRanges := []string{
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
}
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
}
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
if transfer == nil || transfer.Uploaded != size {
t.Fatalf("transfer = %+v", transfer)
}
}
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
})
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
t.Fatal("backup password encryption succeeded without a deployment secret")
}
}
func TestBackupPasswordValidation(t *testing.T) {
for _, valid := range []string{"12345678", "Restore Password 123!"} {
if !validBackupPassword(valid) {
t.Errorf("valid password rejected: %q", valid)
}
}
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
if validBackupPassword(invalid) {
t.Errorf("invalid password accepted: %q", invalid)
}
}
}
func TestBackupPasswordHint(t *testing.T) {
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
t.Fatalf("password hint = %q", got)
}
if got := backupPasswordHint("ab"); got != "ab" {
t.Fatalf("two-character password hint = %q", got)
}
if got := backupPasswordHint(""); got != "" {
t.Fatalf("empty password hint = %q", got)
}
}
func TestSavedBackupPasswordAndHint(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("A23456789Z")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "A23456789Z" {
t.Fatalf("saved password = %q, %v", password, err)
}
schedule, err := a.loadBackupSchedule(context.Background())
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
t.Fatalf("schedule password state = %+v, %v", schedule, err)
}
}
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
now := a.now().UTC().Format(time.RFC3339Nano)
for key, value := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
t.Fatal(err)
}
}
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
t.Fatalf("password update code=%d body=%v", code, response)
}
if response["passwordHint"] != "N••••••••••9" {
t.Fatalf("password hint = %v", response["passwordHint"])
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "NewSharedPassword9" {
t.Fatalf("saved password = %q, %v", password, err)
}
for key, want := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
var got string
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
}
}
}
func TestManualBackupReusesSavedPassword(t *testing.T) {
dir := t.TempDir()
deployDir := filepath.Join(dir, "deploy")
if err := os.MkdirAll(deployDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
t.Fatalf("manual backup code=%d body=%v", code, response)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "SharedBackupPassword9" {
t.Fatalf("saved password changed: %q, %v", password, err)
}
deadline := time.Now().Add(10 * time.Second)
for {
a.backupMu.Lock()
status := a.backupJob.Status
a.backupMu.Unlock()
if status != "running" {
break
}
if time.Now().After(deadline) {
t.Fatal("manual backup did not finish before timeout")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestPublicServerIPValidation(t *testing.T) {
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
if !isPublicIP(net.ParseIP(value)) {
t.Errorf("public IP rejected: %s", value)
}
}
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
if isPublicIP(net.ParseIP(value)) {
t.Errorf("non-public IP accepted: %s", value)
}
}
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
t.Fatalf("literal public IP = %q", got)
}
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
t.Fatalf("literal private IP = %q", got)
}
}
func TestWriteRuntimeBackupEnv(t *testing.T) {
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
path := filepath.Join(t.TempDir(), ".env")
if err := writeRuntimeBackupEnv(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
contents := string(raw)
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
if !strings.Contains(contents, expected) {
t.Errorf("backup environment missing %q: %s", expected, contents)
}
}
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
if strings.Contains(contents, excluded) {
t.Fatalf("backup environment included excluded value %q", excluded)
}
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
}
}
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
dir := t.TempDir()
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
})
stopTestWorkers(a)
if !a.backupAssetsAvailable() {
t.Fatal("bundled compose did not enable complete backups")
}
if err := os.Remove(compose); err != nil {
t.Fatal(err)
}
if a.backupAssetsAvailable() {
t.Fatal("missing bundled compose incorrectly enabled complete backups")
}
}
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
if err != nil || ciphertext == "BackupPassword123!" {
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
}
plain, err := a.decryptBackupPassword(ciphertext)
if err != nil || plain != "BackupPassword123!" {
t.Fatalf("password decryption = %q, %v", plain, err)
}
if !validTelegramPrivateChatID("-1001234567890") {
t.Fatal("private Telegram group chat ID was rejected")
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
report, err := a.backupTelegramReport(context.Background(), path, info)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
if !strings.Contains(report, expected) {
t.Errorf("report missing %q: %s", expected, report)
}
}
if strings.Contains(report, "newszxcn.com(管理员)") {
t.Fatal("domain list incorrectly contains account role")
}
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
t.Fatal("report leaked a password")
}
}
+4
View File
@@ -67,6 +67,8 @@ type Config struct {
ReleaseAPIURL string ReleaseAPIURL string
UpdateServiceURL string UpdateServiceURL string
UpdateServiceToken string UpdateServiceToken string
BackupSourceDir string
BackupDir string
} }
func LoadConfig() Config { func LoadConfig() Config {
@@ -131,6 +133,8 @@ func LoadConfig() Config {
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"), ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""), UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""), UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
} }
} }
+4 -2
View File
@@ -87,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created) m.CreatedAt = parseTime(created)
items = append(items, m) items = append(items, m)
} }
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items}) respondJSON(w, http.StatusOK, map[string]any{"items": items})
} }
@@ -2619,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
return nil return nil
} }
var quotaMB int64 var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); err != nil { var userID string
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&quotaMB, &userID); err != nil {
return err return err
} }
if quotaMB <= 0 { if quotaMB <= 0 {
return nil return nil
} }
var used int64 var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil { if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
return err return err
} }
quotaBytes := quotaMB * 1024 * 1024 quotaBytes := quotaMB * 1024 * 1024
+74 -23
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
maxChars = 8000 maxChars = 8000
} }
text, truncated := truncateRunes(text, maxChars) text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if strings.TrimSpace(msg.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target) translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil { if err != nil {
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err) a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed") respondError(w, http.StatusBadGateway, "translation failed")
return return
} }
translatedHTML := "" translatedHTML := <-translatedHTMLResult
if strings.TrimSpace(msg.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
} }
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
maxChars = 8000 maxChars = 8000
} }
text, truncated := truncateRunes(text, maxChars) text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target) translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil { if err != nil {
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err) a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed") respondError(w, http.StatusBadGateway, "translation failed")
return return
} }
translatedHTML := "" translatedHTML := <-translatedHTMLResult
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
} }
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) { func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
}
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil) nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
if err != nil { if err != nil {
return "", err return "", err
} }
type translationJob struct {
node *html.Node
original string
text string
}
remaining := maxChars remaining := maxChars
var translateNode func(*html.Node) error jobs := make([]translationJob, 0)
translateNode = func(n *html.Node) error { var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) { if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
return nil return
} }
if n.Type == html.TextNode { if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data) text := strings.TrimSpace(n.Data)
if text != "" && containsTranslatableLetter(text) && remaining > 0 { if text != "" && containsTranslatableLetter(text) && remaining > 0 {
limited, _ := truncateRunes(text, remaining) limited, _ := truncateRunes(text, remaining)
remaining -= utf8.RuneCountInString(limited) remaining -= utf8.RuneCountInString(limited)
translated, _, err := googleFreeTranslate(ctx, limited, target) jobs = append(jobs, translationJob{node: n, original: text, text: limited})
if err != nil {
return err
}
n.Data = strings.Replace(n.Data, text, translated, 1)
} }
} }
for c := n.FirstChild; c != nil; c = c.NextSibling { for c := n.FirstChild; c != nil; c = c.NextSibling {
if err := translateNode(c); err != nil { collect(c)
return err
}
} }
return nil
} }
for _, n := range nodes { for _, n := range nodes {
if err := translateNode(n); err != nil { collect(n)
return "", err }
} results := make([]string, len(jobs))
jobIndexes := make(chan int)
errCh := make(chan error, 1)
workers := min(4, len(jobs))
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobIndexes {
translated, _, translateErr := translator(ctx, jobs[index].text, target)
if translateErr != nil {
select {
case errCh <- translateErr:
default:
}
continue
}
results[index] = translated
}
}()
}
for index := range jobs {
jobIndexes <- index
}
close(jobIndexes)
wg.Wait()
select {
case translateErr := <-errCh:
return "", translateErr
default:
}
for index, job := range jobs {
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
} }
var b bytes.Buffer var b bytes.Buffer
for _, n := range nodes { for _, n := range nodes {
+21 -1
View File
@@ -1,6 +1,10 @@
package app package app
import "testing" import (
"context"
"strings"
"testing"
)
func TestParseGoogleTranslateResponse(t *testing.T) { func TestParseGoogleTranslateResponse(t *testing.T) {
raw := []any{ raw := []any{
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
} }
} }
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
translator := func(_ context.Context, text, target string) (string, string, error) {
return strings.ToUpper(text) + "-" + target, "en", nil
}
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
t.Fatalf("translated HTML = %q", got)
}
if !strings.Contains(got, `<pre>keep me</pre>`) {
t.Fatalf("code block was translated: %q", got)
}
}
func TestTruncateRunes(t *testing.T) { func TestTruncateRunes(t *testing.T) {
got, truncated := truncateRunes("你好world", 4) got, truncated := truncateRunes("你好world", 4)
if got != "你好wo" || !truncated { if got != "你好wo" || !truncated {
+7 -4
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) { func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
@@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 { 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)) 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 { if !mailboxes.Items[0].Primary {
t.Fatalf("delete final mailbox code=%d", code) t.Fatal("administrator mailbox should be marked as primary")
} }
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 { if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("delete primary mailbox code=%d", code)
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items)) t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
} }
var me map[string]any var me map[string]any
+9
View File
@@ -21,6 +21,7 @@ import (
"golang.org/x/text/encoding" "golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex" "golang.org/x/text/encoding/ianaindex"
"golang.org/x/text/encoding/simplifiedchinese"
) )
type maildirMailbox struct { type maildirMailbox struct {
@@ -822,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
if charset == "utf-8" || charset == "us-ascii" { if charset == "utf-8" || charset == "us-ascii" {
return input, nil return input, nil
} }
// GB2312 is commonly used as a label for GBK-compatible mail content.
// ianaindex does not consistently resolve these real-world aliases.
switch charset {
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
case "gb18030":
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
}
enc, err := ianaindex.IANA.Encoding(charset) enc, err := ianaindex.IANA.Encoding(charset)
if err != nil { if err != nil {
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err) return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
+51 -6
View File
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("password must be at least 6 characters")) badRequest(w, errors.New("password must be at least 6 characters"))
return return
} }
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID) domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "domain not found") respondError(w, http.StatusNotFound, "domain not found")
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
respondMailboxOwnerError(w, err) respondMailboxOwnerError(w, err)
return return
} }
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active") var ownerPasswordHash, ownerRole string
var ownerStorageQuotaMB int
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
displayName = current.DisplayName displayName = current.DisplayName
} }
quotaMB := req.QuotaMB quotaMB := req.QuotaMB
if quotaMB <= 0 { if quotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
if quotaMB == 0 {
quotaMB = current.QuotaMB quotaMB = current.QuotaMB
} }
status := strings.TrimSpace(req.Status) status := strings.TrimSpace(req.Status)
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
if userID == "" { if userID == "" {
userID = current.UserID userID = current.UserID
} }
if current.Primary && userID != current.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
if current.Primary && status != current.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil { if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
respondMailboxOwnerError(w, err) respondMailboxOwnerError(w, err)
return return
} }
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, var ownerRole, ownerPasswordHash string
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
if ownerRole == "admin" {
quotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox") respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return return
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
if displayName == "" { if displayName == "" {
displayName = email displayName = email
} }
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now) VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
return userID, err return userID, err
} }
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) { func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
var existingSystem int var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil { var existingName, existingDescription string
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
respondError(w, http.StatusNotFound, "permission group not found") respondError(w, http.StatusNotFound, "permission group not found")
return return
} }
if intBool(existingSystem) { if intBool(existingSystem) && id != PermissionGroupRegular {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited") respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return return
} }
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return return
} }
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)
if id == PermissionGroupRegular {
name = existingName
req.Description = existingDescription
}
if name == "" { if name == "" {
badRequest(w, errors.New("name is required")) badRequest(w, errors.New("name is required"))
return return
+13 -21
View File
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName must be at most 80 characters")) badRequest(w, errors.New("displayName must be at most 80 characters"))
return return
} }
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active") quotaMB := defaultUserStorageQuotaMB
if user.Role == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
if err != nil { if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱地址已被占用") respondError(w, http.StatusConflict, "该邮箱地址已被占用")
@@ -900,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats") respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return return
} }
if mailboxID != "" && !isAllMailboxID(mailboxID) { var quotaMB int64
var quotaMB int64 if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan(&quotaMB); err != nil {
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil { respondError(w, http.StatusInternalServerError, "failed to load quota")
respondError(w, http.StatusInternalServerError, "failed to load quota") return
return }
} stats.QuotaBytes = quotaMB * 1024 * 1024
stats.QuotaBytes = quotaMB * 1024 * 1024 if stats.QuotaBytes > 0 {
if stats.QuotaBytes > 0 { stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} }
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
+14
View File
@@ -139,6 +139,20 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAdminAccess) r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion) r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate) r.Post("/admin/system/update", a.handleSystemUpdate)
r.Get("/admin/backups", a.handleListBackups)
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
r.Post("/admin/backups", a.handleCreateBackup)
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview) r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers) r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser) r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
+46 -1
View File
@@ -71,6 +71,7 @@ type telegramUpdate struct {
Chat struct { Chat struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
Username string `json:"username"` Username string `json:"username"`
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
func validTelegramPrivateChatID(value string) bool { func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0 return err == nil && id != 0
} }
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试") return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
} }
type telegramDiscoveredChat struct {
ChatID string `json:"chatId"`
DisplayName string `json:"displayName"`
}
func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) {
var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100, "timeout": 0, "allowed_updates": []string{"message"},
}, &updates); err != nil {
return nil, err
}
found := make([]telegramDiscoveredChat, 0)
seen := make(map[int64]bool)
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
continue
}
text := strings.TrimSpace(message.Text)
fields := strings.Fields(text)
matches := strings.EqualFold(text, pairingCode)
if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
matches = strings.EqualFold(fields[1], pairingCode)
}
if !matches {
continue
}
if seen[message.Chat.ID] {
continue
}
seen[message.Chat.ID] = true
name := strings.TrimSpace(message.Chat.Title)
if name == "" {
name = "Telegram 群组"
}
found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
}
if len(found) == 0 {
return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
}
return found, nil
}
func newTelegramPairingCode() (string, error) { func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6) raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil { if _, err := rand.Read(raw); err != nil {
+32
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -355,6 +356,37 @@ func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
} }
} }
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
subject := "验证 Apple 账户电子邮件地址"
body := "你的 Apple 验证码是 978534"
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
if err != nil {
t.Fatal(err)
}
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
if err != nil {
t.Fatal(err)
}
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
"To: admin@example.com\r\n" +
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
"Content-Type: text/plain; charset=gb2312\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
base64.StdEncoding.EncodeToString(encodedBody))
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if msg.Subject != subject {
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
}
if msg.BodyText != body {
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) { func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4 -2
View File
@@ -21,8 +21,9 @@ type User struct {
type AdminUser struct { type AdminUser struct {
User User
MailboxCount int `json:"mailboxCount"` MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"` Mailboxes []string `json:"mailboxes"`
StorageQuotaMB int `json:"storageQuotaMb"`
} }
type APIToken struct { type APIToken struct {
@@ -71,6 +72,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"` QuotaMB int `json:"quotaMb"`
Status string `json:"status"` Status string `json:"status"`
Primary bool `json:"primary"`
UnreadCount int `json:"unreadCount"` UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
+11
View File
@@ -0,0 +1,11 @@
import { Mail } from "lucide-react"
import { cn } from "@/lib/utils"
export function BrandMark({ className }: { className?: string }) {
return (
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
<Mail className="size-6 stroke-[1.8]" />
</span>
)
}
+2 -2
View File
@@ -26,12 +26,12 @@ export function ConfirmDialog({
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
{description && <div className="text-sm text-muted-foreground">{description}</div>} {description && <div className="text-sm text-muted-foreground">{description}</div>}
<DialogFooter> <DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelText} {cancelText}
</Button> </Button>
+7 -11
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom" import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react" import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout" import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard" import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog" import { SystemVersionDialog } from "@/components/system-version-dialog"
import { BrandMark } from "@/components/brand-mark"
import { hasAnyPermission } from "@/lib/permissions" import { hasAnyPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types" import type { PermissionKey } from "@/lib/api-types"
import { import {
@@ -28,7 +28,7 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] }, { key: "overview", label: "仪表盘", icon: <LayoutDashboard />, permissions: ["admin.overview.view"] },
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] }, { key: "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: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
@@ -36,6 +36,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] }, { key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] }, { key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] }, { key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] }, { key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
] ]
@@ -57,7 +58,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile") const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin") const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview" const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions)) const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
if (isMailRoute || isProfileRoute) { if (isMailRoute || isProfileRoute) {
return <Outlet /> return <Outlet />
@@ -72,9 +73,7 @@ function ProtectedContent() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link to="/"> <Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <BrandMark className="size-8 rounded-md [&>svg]:size-5" />
<Mail className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">NewSzxcn </span> <span className="truncate font-semibold">NewSzxcn </span>
</div> </div>
@@ -110,15 +109,12 @@ function ProtectedContent() {
<span className="truncate font-semibold">{user.displayName}</span> <span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div> </div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<div className="p-2"> <div className="p-2">
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}> <Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />退 <LogOut className="h-3.5 w-3.5" />退
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground hover:bg-primary/90", "bg-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:
+4 -1
View File
@@ -8,6 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card"
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-sm",
className className
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
)) ))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
+1 -1
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
className className
)} )}
{...props} {...props}
+4 -4
View File
@@ -519,7 +519,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem" SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[active=true]:hover:bg-[hsl(var(--sidebar-active))] data-[active=true]:hover:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -621,7 +621,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))] md:opacity-0",
className className
)} )}
{...props} {...props}
@@ -639,7 +639,7 @@ const SidebarMenuBadge = React.forwardRef<
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground", "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))]",
"peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", "data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
+4 -1
View File
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
)) ))
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader"
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
data-slot="table-row"
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className className
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<th <th
ref={ref} ref={ref}
data-slot="table-head"
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className
+6 -6
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
className className
)} )}
{...props} {...props}
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border-border/80 bg-popover text-popover-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
props.onClick?.(event) props.onClick?.(event)
}} }}
className={cn( className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
className className
)} )}
toast-close="" toast-close=""
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className={cn("break-words text-sm font-semibold leading-5", className)}
{...props} {...props}
/> />
)) ))
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
{...props} {...props}
/> />
)) ))
+2 -2
View File
@@ -14,11 +14,11 @@ export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast()
return ( return (
<ToastProvider> <ToastProvider duration={5000} swipeDirection="right">
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="min-w-0 flex-1 space-y-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (
<ToastDescription>{description}</ToastDescription> <ToastDescription>{description}</ToastDescription>
+2 -2
View File
@@ -5,8 +5,8 @@ import type {
ToastProps, ToastProps,
} from "@/components/ui/toast" } from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string
+71 -4
View File
@@ -12,10 +12,12 @@
--popover-foreground: 0 0% 9%; --popover-foreground: 0 0% 9%;
--primary: 0 0% 12%; --primary: 0 0% 12%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
--action-primary: 0 0% 12%;
--action-primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%; --secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 12%; --secondary-foreground: 0 0% 12%;
--muted: 0 0% 96.1%; --muted: 0 0% 96.1%;
--muted-foreground: 0 0% 42%; --muted-foreground: 0 0% 36%;
--accent: 0 0% 95%; --accent: 0 0% 95%;
--accent-foreground: 0 0% 12%; --accent-foreground: 0 0% 12%;
--destructive: 358 88% 61%; --destructive: 358 88% 61%;
@@ -33,6 +35,11 @@
--sidebar-accent-foreground: 0 0% 12%; --sidebar-accent-foreground: 0 0% 12%;
--sidebar-border: 0 0% 89.8%; --sidebar-border: 0 0% 89.8%;
--sidebar-ring: 0 0% 42%; --sidebar-ring: 0 0% 42%;
--sidebar-active: 207 100% 92%;
--sidebar-active-foreground: 202 100% 30%;
--mail-selected: 210 100% 96%;
--compose-send: 217 89% 43%;
--compose-send-hover: 216 94% 32%;
} }
* { @apply border-border; } * { @apply border-border; }
@@ -61,10 +68,12 @@
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
--action-primary: 240 4% 24%;
--action-primary-foreground: 0 0% 98%;
--secondary: 240 3.7% 15.9%; --secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 72%;
--accent: 240 3.7% 15.9%; --accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
@@ -80,10 +89,25 @@
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-active: 210 48% 24%;
--sidebar-active-foreground: 210 100% 88%;
--mail-selected: 210 38% 20%;
--compose-send: 217 89% 52%;
--compose-send-hover: 214 94% 60%;
} }
} }
@layer components { @layer components {
.compose-send-button.compose-send-button {
background: hsl(var(--compose-send));
color: white;
}
.compose-send-button.compose-send-button:hover {
background: hsl(var(--compose-send-hover));
color: white;
}
.mail-shell-grid { .mail-shell-grid {
display: grid; display: grid;
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr); grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
@@ -106,6 +130,11 @@
max-width: 384px; max-width: 384px;
} }
.mail-selected-row {
background-color: hsl(var(--mail-selected));
}
@media (max-width: 767px) { @media (max-width: 767px) {
.mail-content-grid { .mail-content-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -140,12 +169,50 @@
} }
[data-sidebar="menu-button"][data-active="true"] { [data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent)); background: hsl(var(--sidebar-accent) / 0.82);
color: hsl(var(--sidebar-accent-foreground)); color: hsl(var(--sidebar-accent-foreground));
box-shadow: inset 2px 0 0 hsl(var(--foreground));
} }
[data-sidebar="menu-button"][data-active="true"] svg { [data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground)); color: hsl(var(--foreground));
}
.admin-page [data-slot="card"] {
border-radius: 0.5rem;
box-shadow: none;
}
.admin-page [data-slot="card-header"] {
padding: 1.25rem;
}
.admin-page [data-slot="card-content"] {
padding: 0 1.25rem 1.25rem;
}
.admin-page [data-slot="card-title"] {
font-size: 0.9375rem;
line-height: 1.4;
}
.admin-page [data-slot="table-header"] {
background: hsl(var(--muted) / 0.48);
}
.admin-page [data-slot="table-head"] {
height: 2.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.75rem;
}
.admin-page [data-slot="table-cell"] {
padding: 0.75rem;
}
.admin-page [data-slot="table-row"]:hover {
background: hsl(var(--muted) / 0.32);
} }
} }
+13 -3
View File
@@ -52,10 +52,14 @@ export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string } export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string } export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type AdminOverview = {
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
aliases: number; messages: number; unreadMessages: number; storageBytes: number
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: number
}
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
@@ -202,6 +206,12 @@ export type SystemUpdateResult = {
targetVersion: string targetVersion: string
message: string message: string
} }
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
export type BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus; transfers: BackupTransfer[] }
export type SystemSettings = { export type SystemSettings = {
publicHostname: string publicHostname: string
publicBaseUrl: string publicBaseUrl: string
+16 -4
View File
@@ -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, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types" import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, BackupList, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
export * from "./api-types" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
@@ -172,8 +172,8 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), 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"), defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), 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" }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"), domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -181,7 +181,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), 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" }), deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"), mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), 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" }), deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"), aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -211,6 +211,18 @@ export const api = {
}, },
systemVersion: () => request<SystemVersion>("/api/admin/system/version"), systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }), updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
backups: () => request<BackupList>("/api/admin/backups"),
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }),
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }),
verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }),
sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }),
sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }),
deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }),
systemSettings: () => request<SystemSettings>("/api/admin/settings"), systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"), maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
+3
View File
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom" import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster" import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language" import { LanguageDomSync } from "@/lib/language"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { ProtectedLayout } from "@/components/protected-layout" import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only" import { AdminOnly } from "@/components/admin-only"
import "./index.css" import "./index.css"
@@ -15,6 +16,8 @@ const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ d
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage }))) const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage }))) const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
applyTheme(getInitialTheme())
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { safeReturnPath } from "@/lib/navigation" import { safeReturnPath } from "@/lib/navigation"
import { AuthError, AuthLoading } from "@/components/auth-states" import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function LoginPage() { export function LoginPage() {
const me = useMe() const me = useMe()
@@ -43,7 +44,8 @@ export function LoginPage() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10"> <main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]"> <div className="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
+309 -118
View File
@@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ConfirmDialog } from "@/components/confirm-dialog" import { ConfirmDialog } from "@/components/confirm-dialog"
import { import {
@@ -202,6 +203,8 @@ export function MailPage() {
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all") const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("") const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([]) const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
const [foldersExpanded, setFoldersExpanded] = React.useState(true)
const [labelsExpanded, setLabelsExpanded] = React.useState(true)
const [externalFolder, setExternalFolder] = React.useState("INBOX") const [externalFolder, setExternalFolder] = React.useState("INBOX")
const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [language, setLanguage] = useLanguage() const [language, setLanguage] = useLanguage()
@@ -484,7 +487,16 @@ export function MailPage() {
}, },
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
}) })
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) }) const del = useMutation({
mutationFn: ({ id }: { id: string; permanent: boolean }) => api.delete(id),
onSuccess: async (_, { permanent }) => {
setSelectedId(null)
setPendingConfirm(null)
await refreshMailData()
toast({ title: permanent ? "邮件已永久删除" : "邮件已移入已删除" })
},
onError: (error) => toast({ title: "删除失败", description: error.message }),
})
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
const cancelScheduledSend = useMutation({ const cancelScheduledSend = useMutation({
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id), mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
@@ -641,8 +653,8 @@ export function MailPage() {
const first = newMessages[0] const first = newMessages[0]
const firstSender = senderDisplayName(first) const firstSender = senderDisplayName(first)
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${messageSubject(first)}`
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}` const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender
const openFirstMessage = () => { const openFirstMessage = () => {
setMailView("folder") setMailView("folder")
setFolder("Inbox") setFolder("Inbox")
@@ -812,10 +824,19 @@ export function MailPage() {
await Promise.all(ids.map((id) => api.star(id, starred))) await Promise.all(ids.map((id) => api.star(id, starred)))
} else if (action === "delete") { } else if (action === "delete") {
await Promise.all(ids.map((id) => api.delete(id))) await Promise.all(ids.map((id) => api.delete(id)))
completionTitle = `已永久删除 ${ids.length} 封邮件`
} else { } else {
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam" const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
const result = await api.bulkMove(ids, target) const result = await api.bulkMove(ids, target)
completionTitle = result.ok ? result.message : "批量移动部分失败" completionTitle = result.ok
? target === "Trash"
? `已将 ${result.moved} 封邮件移入已删除`
: target === "Spam"
? `已将 ${result.moved} 封邮件移入垃圾邮件`
: target === "Inbox"
? `已将 ${result.moved} 封邮件移回收件箱`
: `已归档 ${result.moved} 封邮件`
: "批量移动部分失败"
completionDescription = result.ok ? undefined : result.message completionDescription = result.ok ? undefined : result.message
} }
if (selectedId && ids.includes(selectedId)) setSelectedId(null) if (selectedId && ids.includes(selectedId)) setSelectedId(null)
@@ -847,11 +868,15 @@ export function MailPage() {
} }
} }
function confirmDeleteMessage(message: MailMessage) { function confirmDeleteMessage(message: MailMessage) {
const permanent = message.folder === "Trash"
const subject = messageSubject(message)
setPendingConfirm({ setPendingConfirm({
title: "删除这封邮件?", title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
description: `邮件“${message.subject || "无主题"}”将被删除。`, description: permanent
confirmText: "删除邮件", ? `邮件“${subject}”将被永久删除,且无法恢复。`
onConfirm: () => del.mutate(message.id), : `邮件“${subject}”将移入已删除。`,
confirmText: permanent ? "永久删除" : "移入已删除",
onConfirm: () => del.mutate({ id: message.id, permanent }),
}) })
} }
function openCompose(draft?: ComposeDraft) { function openCompose(draft?: ComposeDraft) {
@@ -862,11 +887,11 @@ export function MailPage() {
} }
function openReply(message: MailMessage) { function openReply(message: MailMessage) {
if (!canSendMail) return if (!canSendMail) return
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(messageSubject(message), "Re:"), text: quoteMessage(message) })
} }
function openForward(message: MailMessage) { function openForward(message: MailMessage) {
if (!canSendMail) return if (!canSendMail) return
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(messageSubject(message), "Fwd:"), text: quoteMessage(message) })
} }
async function openDraft(message: MailMessage) { async function openDraft(message: MailMessage) {
if (!canManageDrafts) return if (!canManageDrafts) return
@@ -961,6 +986,14 @@ export function MailPage() {
function closeSidebarContextMenu() { function closeSidebarContextMenu() {
setSidebarContextMenu(null) setSidebarContextMenu(null)
} }
function runAfterClosingMobileSidebar(action: () => void) {
if (!isMobile) {
action()
return
}
if (mobileSidebarOpen) setMobileSidebarOpen(false)
window.setTimeout(action, 250)
}
function activateSidebarItem(item: MailMenuItem) { function activateSidebarItem(item: MailMenuItem) {
if (item.type === "starred") openStarred() if (item.type === "starred") openStarred()
else if (item.type === "scheduled") openScheduled() else if (item.type === "scheduled") openScheduled()
@@ -1275,8 +1308,8 @@ export function MailPage() {
setAdvancedSearchOpen(false) setAdvancedSearchOpen(false)
} }
const sidebarContent = ( const sidebarContent = (
<Sidebar collapsible="none" className="h-full w-full border-r border-border bg-sidebar text-sidebar-foreground"> <Sidebar collapsible="none" className="h-full min-h-0 w-full overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground">
<SidebarHeader className={cn("pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}> <SidebarHeader className={cn("shrink-0 pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
<AccountHeader <AccountHeader
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"} name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"}
@@ -1320,7 +1353,7 @@ export function MailPage() {
</Button> </Button>
)} )}
</SidebarHeader> </SidebarHeader>
<SidebarContent className="px-1"> <SidebarContent className="min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-4">
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />} {!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
<SidebarGroup> <SidebarGroup>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
@@ -1430,15 +1463,18 @@ export function MailPage() {
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup> {(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1"> <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> <Button type="button" variant="ghost" className="h-auto min-w-0 justify-start gap-1 p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground" aria-expanded={foldersExpanded} aria-controls="mail-sidebar-folders" onClick={() => setFoldersExpanded((value) => !value)}>
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", !foldersExpanded && "-rotate-90")} />
<span></span>
</Button>
{canManageFolders && ( {canManageFolders && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => setFolderDialogOpen(true)}> <Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))}>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
</div> </div>
)} )}
<SidebarGroupContent> {foldersExpanded && <SidebarGroupContent id="mail-sidebar-folders">
<SidebarMenu> <SidebarMenu>
{customMailMenuItems.map((item) => ( {customMailMenuItems.map((item) => (
<SidebarMenuItem <SidebarMenuItem
@@ -1484,12 +1520,15 @@ export function MailPage() {
/> />
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>}
</SidebarGroup>} </SidebarGroup>}
{(canReadMail || canManageLabels) && <SidebarGroup> {(canReadMail || canManageLabels) && <SidebarGroup>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1"> <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> <Button type="button" variant="ghost" className="h-auto min-w-0 justify-start gap-1 p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground" aria-expanded={labelsExpanded} aria-controls="mail-sidebar-labels" onClick={() => setLabelsExpanded((value) => !value)}>
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", !labelsExpanded && "-rotate-90")} />
<span></span>
</Button>
{canManageCurrentMailboxLabels && ( {canManageCurrentMailboxLabels && (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}> <Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
@@ -1504,7 +1543,7 @@ export function MailPage() {
)} )}
</div> </div>
)} )}
<SidebarGroupContent> {labelsExpanded && <SidebarGroupContent id="mail-sidebar-labels">
<SidebarMenu> <SidebarMenu>
{canReadMail && labelItems.map((label) => { {canReadMail && labelItems.map((label) => {
const dotColor = labelDotColor(label) const dotColor = labelDotColor(label)
@@ -1550,9 +1589,37 @@ export function MailPage() {
</SidebarMenu> </SidebarMenu>
{labels.isLoading && <FolderSkeleton />} {labels.isLoading && <FolderSkeleton />}
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />} {!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
</SidebarGroupContent> </SidebarGroupContent>}
</SidebarGroup>} </SidebarGroup>}
</SidebarContent> </SidebarContent>
<SidebarContextMenu
state={sidebarContextMenu}
canCreate={canManageFolders}
canReorder={canOrganizeCurrentMailbox}
canDelete={canManageFolders}
pending={reorderFolders.isPending || deleteFolder.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
closeSidebarContextMenu()
activateSidebarItem(item)
}}
onRefresh={() => {
closeSidebarContextMenu()
void refreshMailData()
}}
onCreateFolder={() => {
closeSidebarContextMenu()
runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))
}}
onMove={(item, action) => {
closeSidebarContextMenu()
moveSidebarFolder(item, action)
}}
onDelete={(item) => {
closeSidebarContextMenu()
runAfterClosingMobileSidebar(() => confirmDeleteFolder(item))
}}
/>
</Sidebar> </Sidebar>
) )
@@ -1625,7 +1692,9 @@ export function MailPage() {
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} /> <MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
) : compactMailLayout ? ( ) : compactMailLayout ? (
<CompactMailView <CompactMailView
key={`${mailView}:${mailView === "folder" ? folder : mailView === "label" ? selectedLabelId : selectedExternalAccountId}`}
title={viewTitle} title={viewTitle}
currentFolder={mailView === "folder" ? folder : undefined}
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined} icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined}
messages={visibleMessages} messages={visibleMessages}
total={allMessages.length} total={allMessages.length}
@@ -1734,12 +1803,12 @@ export function MailPage() {
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && ( {selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5"> <div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
<span className="shrink-0 text-[13px] text-muted-foreground"> {selectedCountOnPage} </span> <span className="shrink-0 text-[13px] text-muted-foreground"> {selectedCountOnPage} </span>
<BulkActionToolbar pending={bulkPending} currentFolder={folder} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} /> <BulkActionToolbar pending={bulkPending} currentFolder={mailView === "folder" ? folder : undefined} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
</div> </div>
)} )}
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
{mailMessagesLoading && <MessageSkeleton />} {mailMessagesLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)} {visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => m.folder === "Trash" ? confirmDeleteMessage(m) : move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>} {!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
{!mailMessagesLoading && hasMoreMessages && ( {!mailMessagesLoading && hasMoreMessages && (
<div className="border-b p-4 text-center"> <div className="border-b p-4 text-center">
@@ -1783,7 +1852,7 @@ export function MailPage() {
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button> <Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
))} ))}
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>} {mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
</div> </div>
</div> </div>
<MessageMetaPanel <MessageMetaPanel
@@ -1816,7 +1885,7 @@ export function MailPage() {
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}> <SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
<SheetTitle className="sr-only"></SheetTitle> <SheetTitle className="sr-only"></SheetTitle>
<div className="h-svh">{sidebarContent}</div> <div className="h-svh min-h-0 overflow-hidden">{sidebarContent}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div> <div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
@@ -1832,7 +1901,7 @@ export function MailPage() {
</div> </div>
) : ( ) : (
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden"> <div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
<div className="min-w-0"> <div className="h-full min-h-0 min-w-0 overflow-hidden">
{sidebarContent} {sidebarContent}
</div> </div>
<section className="flex h-full min-h-0 min-w-0 flex-col"> <section className="flex h-full min-h-0 min-w-0 flex-col">
@@ -1871,37 +1940,10 @@ export function MailPage() {
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label }) active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
}} }}
/> />
<SidebarContextMenu
state={sidebarContextMenu}
canCreate={canManageFolders}
canReorder={canOrganizeCurrentMailbox}
canDelete={canManageFolders}
pending={reorderFolders.isPending || deleteFolder.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
closeSidebarContextMenu()
activateSidebarItem(item)
}}
onRefresh={() => {
closeSidebarContextMenu()
void refreshMailData()
}}
onCreateFolder={() => {
closeSidebarContextMenu()
setFolderDialogOpen(true)
}}
onMove={(item, action) => {
closeSidebarContextMenu()
moveSidebarFolder(item, action)
}}
onDelete={(item) => {
closeSidebarContextMenu()
confirmDeleteFolder(item)
}}
/>
<CreateFolderDialog <CreateFolderDialog
open={folderDialogOpen} open={folderDialogOpen}
pending={createFolder.isPending} pending={createFolder.isPending}
scope={isAllMailboxSelected ? "全部邮箱" : selectedMailbox?.address || "当前邮箱"}
onOpenChange={setFolderDialogOpen} onOpenChange={setFolderDialogOpen}
onCreate={(payload) => createFolder.mutate(payload)} onCreate={(payload) => createFolder.mutate(payload)}
/> />
@@ -1911,7 +1953,7 @@ export function MailPage() {
description={pendingConfirm?.description} description={pendingConfirm?.description}
confirmText={pendingConfirm?.confirmText || "确认"} confirmText={pendingConfirm?.confirmText || "确认"}
destructive destructive
pending={del.isPending || bulkPending} pending={del.isPending || bulkPending || deleteFolder.isPending}
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
onConfirm={() => pendingConfirm?.onConfirm()} onConfirm={() => pendingConfirm?.onConfirm()}
/> />
@@ -2558,6 +2600,7 @@ type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "inbox" |
function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) { function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) {
const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
const permanentlyDelete = currentFolder === "Trash"
const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive" const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive"
const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档" const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts") const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
@@ -2586,7 +2629,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
<Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction("trash")} title="移入已删除" aria-label="移入已删除"> <Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction(permanentlyDelete ? "delete" : "trash")} title={permanentlyDelete ? "永久删除" : "移入已删除"} aria-label={permanentlyDelete ? "永久删除" : "移入已删除"}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -2633,7 +2676,7 @@ function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending,
</Button> </Button>
{canCreate && ( {canCreate && (
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}> <Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</Button> </Button>
)} )}
{customFolder && (canReorder || canDelete) && ( {customFolder && (canReorder || canDelete) && (
@@ -2768,7 +2811,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
{canOrganize && ( {canOrganize && (
<> <>
<div className="-mx-1 my-1 h-px bg-border" /> <div className="-mx-1 my-1 h-px bg-border" />
{item("删除", <Trash2 className="h-4 w-4" />, "delete", true)} {item(message.folder === "Trash" ? "永久删除" : "移入已删除", <Trash2 className="h-4 w-4" />, "delete", true)}
</> </>
)} )}
</div> </div>
@@ -2784,7 +2827,7 @@ function contextMenuPosition(x: number, y: number) {
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) } return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
} }
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) { function CreateFolderDialog({ open, pending, scope, onOpenChange, onCreate }: { open: boolean; pending: boolean; scope: string; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
const [name, setName] = React.useState("") const [name, setName] = React.useState("")
const [icon, setIcon] = React.useState("auto") const [icon, setIcon] = React.useState("auto")
const [uploadError, setUploadError] = React.useState("") const [uploadError, setUploadError] = React.useState("")
@@ -2815,6 +2858,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-folder-name"></Label> <Label htmlFor="new-folder-name"></Label>
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" /> <Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
<p className="text-xs text-muted-foreground">{scope}</p>
</div> </div>
<fieldset className="space-y-2"> <fieldset className="space-y-2">
<legend className="text-sm font-medium"></legend> <legend className="text-sm font-medium"></legend>
@@ -2866,6 +2910,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
function CompactMailView({ function CompactMailView({
title, title,
currentFolder,
icon, icon,
messages, messages,
total, total,
@@ -2909,6 +2954,7 @@ function CompactMailView({
tools, tools,
}: { }: {
title: string title: string
currentFolder?: string
icon?: React.ReactNode icon?: React.ReactNode
messages: MailMessage[] messages: MailMessage[]
total: number total: number
@@ -3000,7 +3046,7 @@ function CompactMailView({
{selectedIds.length > 0 ? ( {selectedIds.length > 0 ? (
<> <>
<span className="hidden text-xs text-muted-foreground min-[380px]:inline"> {selectedIds.length} </span> <span className="hidden text-xs text-muted-foreground min-[380px]:inline"> {selectedIds.length} </span>
{canOrganize && <BulkActionToolbar pending={bulkPending} onAction={onBulkAction} />} {canOrganize && <BulkActionToolbar pending={bulkPending} currentFolder={currentFolder} onAction={onBulkAction} />}
</> </>
) : ( ) : (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -3111,7 +3157,7 @@ function CompactMessageDetail({
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>} {canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
</> </>
)} )}
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" /></DropdownMenuItem>} {canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</DropdownMenuItem>}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -3131,7 +3177,7 @@ function CompactMessageDetail({
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>} {selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
</> </>
)} )}
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" /></Button>} {selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}></Button> <Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}></Button>
@@ -3147,7 +3193,7 @@ function CompactMessageDetail({
<div className="w-full px-4 py-4 sm:px-8 sm:py-6"> <div className="w-full px-4 py-4 sm:px-8 sm:py-6">
<div className="space-y-5 border-b pb-5"> <div className="space-y-5 border-b pb-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1> <h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{messageSubject(selected)}</h1>
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}> {canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} /> <Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>} </Button>}
@@ -3171,23 +3217,41 @@ function CompactMessageDetail({
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) { function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
const qc = useQueryClient()
const [translatedText, setTranslatedText] = React.useState("") const [translatedText, setTranslatedText] = React.useState("")
const [translatedHtml, setTranslatedHtml] = React.useState("") const [translatedHtml, setTranslatedHtml] = React.useState("")
const [showTranslated, setShowTranslated] = React.useState(false) const [showTranslated, setShowTranslated] = React.useState(false)
const [truncated, setTruncated] = React.useState(false) const [truncated, setTruncated] = React.useState(false)
const [autoTranslate, setAutoTranslate] = React.useState(() => {
try {
return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false"
} catch {
return true
}
})
const { toast } = useToast() const { toast } = useToast()
const targetLanguage = normalizeTranslationLanguage(language) const targetLanguage = normalizeTranslationLanguage(language)
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language) const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
const translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage])
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
const applyTranslation = React.useCallback((result: Awaited<ReturnType<typeof api.translateMessage>>, display = true) => {
setTranslatedText(result.translatedText)
setTranslatedHtml(result.translatedHtml || "")
setTruncated(result.truncated)
setShowTranslated(display)
}, [])
const translate = useMutation({ const translate = useMutation({
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), mutationFn: async ({ force = false }: { force?: boolean } = {}) => {
onSuccess: (result) => { if (!force) {
setTranslatedText(result.translatedText) const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
setTranslatedHtml(result.translatedHtml || "") if (cached) return cached
setTruncated(result.truncated) }
setShowTranslated(true) const result = message.externalAccountId ? await api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : await api.translateMessage(message.id, targetLanguage!)
qc.setQueryData(translationKey, result)
return result
}, },
onSuccess: (result) => applyTranslation(result),
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }), onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
}) })
@@ -3197,8 +3261,30 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
setShowTranslated(false) setShowTranslated(false)
setTruncated(false) setTruncated(false)
translate.reset() translate.reset()
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
if (cached) applyTranslation(cached, autoTranslate)
}, [message.id, language]) }, [message.id, language])
React.useEffect(() => {
if (!autoTranslate || !shouldShow || translate.isPending) return
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
if (cached) {
applyTranslation(cached)
return
}
translate.mutate({ force: false })
}, [autoTranslate, message.id, shouldShow, targetLanguage])
const changeAutoTranslate = (checked: boolean) => {
setAutoTranslate(checked)
if (!checked) setShowTranslated(false)
try {
window.localStorage.setItem("newszxcn.mail.auto-translate", String(checked))
} catch {
// Keep the setting for this session when browser storage is unavailable.
}
}
return ( return (
<> <>
{(shouldShow || translatedText) && ( {(shouldShow || translatedText) && (
@@ -3209,8 +3295,12 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
{truncated && <span className="ml-1"></span>} {truncated && <span className="ml-1"></span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<Switch checked={autoTranslate} onCheckedChange={changeAutoTranslate} aria-label="自动翻译邮件" />
</label>
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>} {translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate()}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button> <Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate({ force: !!translatedText })}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -3306,7 +3396,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
<div onClick={onClick} onContextMenu={onContextMenu} className={cn( <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
"cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2", "cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2",
message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10", message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
active && "bg-accent" active && "mail-selected-row"
)}> )}>
<div className="flex gap-3 sm:contents"> <div className="flex gap-3 sm:contents">
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" /> <Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
@@ -3329,7 +3419,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
</div> </div>
</div> </div>
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0"> <div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
<span className="truncate font-medium">{message.subject}</span> <span className="truncate font-medium">{messageSubject(message)}</span>
<span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span> <span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span>
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>} {scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)} {visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
@@ -3378,7 +3468,6 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange
} }
return ( return (
<SidebarMenuButton className="text-muted-foreground" onClick={() => setEditingState(true)}> <SidebarMenuButton className="text-muted-foreground" onClick={() => setEditingState(true)}>
<Plus className="h-4 w-4" />
<span></span> <span></span>
</SidebarMenuButton> </SidebarMenuButton>
) )
@@ -3408,7 +3497,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <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} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}> <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" />} {darkMode ? <Sun className="h-3.5 w-3.5 text-amber-500" /> : <Moon className="h-3.5 w-3.5" />}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -3534,6 +3623,10 @@ function senderDisplayName(message: MailMessage) {
return displayNameFromAddress(message.from) return displayNameFromAddress(message.from)
} }
function messageSubject(message: MailMessage) {
return decodeMimeHeader(message.subject?.trim() || "") || "无主题"
}
function displayNameFromAddress(value: string) { function displayNameFromAddress(value: string) {
const text = decodeMimeHeader(value.trim()) const text = decodeMimeHeader(value.trim())
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
@@ -3744,8 +3837,8 @@ function MessageRow({
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn( return <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
"group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors", "group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors",
message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10", message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
active && "bg-accent", active && "mail-selected-row",
checked && "bg-accent/70" checked && "mail-selected-row"
)}> )}>
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<Checkbox <Checkbox
@@ -3767,7 +3860,7 @@ function MessageRow({
<Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}> <Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}>
<Archive className="h-3.5 w-3.5" /> <Archive className="h-3.5 w-3.5" />
</Button> </Button>
<Button type="button" variant="ghost" size="icon" aria-label="移入已删除" title="移入已删除" className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}> <Button type="button" variant="ghost" size="icon" aria-label={message.folder === "Trash" ? "永久删除" : "移入已删除"} title={message.folder === "Trash" ? "永久删除" : "移入已删除"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}> <Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
@@ -3789,7 +3882,7 @@ function MessageRow({
</div> </div>
</div> </div>
<div className="mb-1 flex min-w-0 items-center gap-2"> <div className="mb-1 flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[13px] text-foreground">{message.subject || "无主题"}</span> <span className="min-w-0 truncate text-[13px] text-foreground">{messageSubject(message)}</span>
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>} {scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)} {visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>} {hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
@@ -3830,6 +3923,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "") const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle") const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null) const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
const [closing, setClosing] = React.useState(false)
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false) const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null) const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
const sendStartedRef = React.useRef(false) const sendStartedRef = React.useRef(false)
@@ -3945,7 +4039,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
}, [files]) }, [files])
React.useEffect(() => { React.useEffect(() => {
if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return if (!open || closing || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
const payloadKey = JSON.stringify({ ...composePayload, draftId }) const payloadKey = JSON.stringify({ ...composePayload, draftId })
if (payloadKey === lastSavedPayloadRef.current) return if (payloadKey === lastSavedPayloadRef.current) return
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
@@ -3966,7 +4060,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
} }
}, 5000) }, 5000)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts]) }, [open, closing, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
function buildSendWarnings(attachmentsCount: number) { function buildSendWarnings(attachmentsCount: number) {
const warnings: string[] = [] const warnings: string[] = []
@@ -4018,6 +4112,10 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
const to = splitEmails(toValue) const to = splitEmails(toValue)
const cc = showCc ? splitEmails(ccValue) : [] const cc = showCc ? splitEmails(ccValue) : []
const bcc = showBcc ? splitEmails(bccValue) : [] const bcc = showBcc ? splitEmails(bccValue) : []
if (to.length === 0) {
toast({ title: "请填写收件人" })
return
}
const text = body.text const text = body.text
const html = body.html || plainTextToHtml(text) const html = body.html || plainTextToHtml(text)
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments } const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
@@ -4053,11 +4151,18 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
} }
if (!attachmentsWithinLimit()) return if (!attachmentsWithinLimit()) return
const attachments = await Promise.all(files.map(fileToAttachment)) const attachments = await Promise.all(files.map(fileToAttachment))
const to = splitEmails(toValue)
const cc = showCc ? splitEmails(ccValue) : []
const bcc = showBcc ? splitEmails(bccValue) : []
if (to.length === 0) {
toast({ title: "请填写收件人" })
return
}
const payload: SendPayload & { draftId?: string; sendAt: string } = { const payload: SendPayload & { draftId?: string; sendAt: string } = {
mailboxId: senderMailbox.id, mailboxId: senderMailbox.id,
to: splitEmails(toValue), to,
cc: showCc ? splitEmails(ccValue) : [], cc,
bcc: showBcc ? splitEmails(bccValue) : [], bcc,
subject: subjectValue, subject: subjectValue,
text: body.text, text: body.text,
html: body.html || plainTextToHtml(body.text), html: body.html || plainTextToHtml(body.text),
@@ -4077,15 +4182,45 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
}, },
}) })
} }
async function closeCompose() {
if (closing) return
if (!canManageDrafts || sendStartedRef.current || !hasDraftContent) {
onOpenChange(false)
return
}
setClosing(true)
setDraftStatus("saving")
try {
const attachments = await Promise.all(files.map(fileToAttachment))
const payload: DraftPayload = { ...composePayload, attachments }
const saved = await api.saveDraft(payload, draftId || undefined)
setDraftId(saved.id)
lastSavedPayloadRef.current = JSON.stringify({ ...payload, draftId: saved.id })
setLastSavedAt(new Date())
setDraftStatus("saved")
await Promise.all([
qc.invalidateQueries({ queryKey: ["messages"] }),
qc.invalidateQueries({ queryKey: ["folders"] }),
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
])
onOpenChange(false)
} catch (error) {
setDraftStatus("error")
toast({ title: "草稿保存失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
setClosing(false)
}
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={(nextOpen) => { if (nextOpen) onOpenChange(true); else void closeCompose() }}>
<DialogContent <DialogContent
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(92vw,72rem)]" className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-[min(88vh,48rem)] sm:w-[min(92vw,56rem)]"
onInteractOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()}
> >
<form key={draft?.key || "new"} className="flex min-h-0 flex-1 flex-col sm:max-h-[90vh]" onSubmit={submit}> <form key={draft?.key || "new"} className="flex min-h-0 min-w-0 flex-1 flex-col" onSubmit={submit}>
<DialogHeader className="border-b px-4 py-3 text-left sm:px-6 sm:py-4"> <DialogHeader className="border-b bg-muted/20 px-4 py-3 text-left sm:px-5">
<DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6"> <DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6">
<span>{draftId ? "编辑草稿" : "写信"}</span> <span>{draftId ? "编辑草稿" : "写信"}</span>
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}> <span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
@@ -4133,7 +4268,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
<Input name="bcc" placeholder="bcc@example.com" value={bccValue} onChange={(event) => setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> <Input name="bcc" placeholder="bcc@example.com" value={bccValue} onChange={(event) => setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
</ComposeField> </ComposeField>
)} )}
<ComposeField label="主 题"> <ComposeField label="主题">
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> <Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
</ComposeField> </ComposeField>
{defaultSignature.isError && ( {defaultSignature.isError && (
@@ -4155,10 +4290,25 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
/> />
</div> </div>
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4"> <DialogFooter className="flex flex-row items-center justify-between gap-3 border-t bg-muted/15 px-4 py-3 sm:px-5">
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}></Button> {canSend && (
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !senderMailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" /></Button>} <div className="flex min-w-0 items-center">
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>} <Button className={cn("compose-send-button min-h-10 px-4", canSchedule && "rounded-r-none")} disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>
{canSchedule && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="icon" className="compose-send-button h-10 w-9 shrink-0 rounded-l-none border-l border-white/30 px-0" title="发送选项" aria-label="发送选项" disabled={send.isPending || scheduleSend.isPending || !senderMailbox}>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={8} collisionPadding={12} className="w-[120px] min-w-[120px]">
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => setScheduleDialogOpen(true)}><Clock3 className="h-4 w-4" /></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<Button type="button" variant="ghost" className="min-h-10 px-3" disabled={closing} onClick={() => { void closeCompose() }}>{closing ? "保存中..." : "取消"}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} /> <ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
@@ -4178,8 +4328,8 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div className="flex min-h-14 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:px-6"> <div className="flex min-h-12 flex-col gap-1 border-b px-4 py-1.5 transition-colors focus-within:bg-muted/20 sm:flex-row sm:items-center sm:gap-2 sm:px-5">
<Label className="shrink-0 text-base font-normal text-foreground sm:w-20">{label}</Label> <Label className="shrink-0 text-sm font-normal text-muted-foreground sm:w-16">{label}</Label>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
{children} {children}
{action} {action}
@@ -4251,11 +4401,17 @@ type InsertDialogValue = { url: string; text: string; alt: string }
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"] const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
const composerFontSizeOptions = [ const composerFontSizeOptions = [
["2", "小号"], ["2", "小号"],
["3", "正文"], ["3", "标准"],
["4", "中号"], ["4", "中号"],
["5", "大号"], ["5", "大号"],
] as const ] as const
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" } const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
const composerParagraphOptions: ReadonlyArray<{ key: string; label: string; level?: 1 | 2 | 3 }> = [
{ key: "paragraph", label: "正文" },
{ key: "heading-1", label: "标题 1", level: 1 as const },
{ key: "heading-2", label: "标题 2", level: 2 as const },
{ key: "heading-3", label: "标题 3", level: 3 as const },
] as const
const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"] const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
@@ -4297,7 +4453,15 @@ function fontLabel(value: string) {
function fontSizeLabel(value: string) { function fontSizeLabel(value: string) {
const normalized = normalizeFontSize(value) || "3" const normalized = normalizeFontSize(value) || "3"
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文" return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "标准"
}
function paragraphStyleLabel(editor?: Editor | null) {
if (!editor) return "正文"
for (const option of composerParagraphOptions) {
if (option.level && editor.isActive("heading", { level: option.level })) return option.label
}
return "正文"
} }
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) { function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
@@ -4423,8 +4587,10 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
content: composerInitialHtml(defaultValue, defaultHtml), content: composerInitialHtml(defaultValue, defaultHtml),
editorProps: { editorProps: {
attributes: { attributes: {
class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5", class: "mail-html min-h-[220px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[260px] sm:px-5",
"aria-label": "正文", "aria-label": "正文",
spellcheck: "true",
autocorrect: "on",
}, },
handlePaste(view, event) { handlePaste(view, event) {
const clipboard = event.clipboardData const clipboard = event.clipboardData
@@ -4478,6 +4644,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3" const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
const activeColor = textStyleAttributes?.color || "" const activeColor = textStyleAttributes?.color || ""
const activeHighlight = textStyleAttributes?.backgroundColor || "" const activeHighlight = textStyleAttributes?.backgroundColor || ""
const characterCount = editor?.getText().replace(/\s/g, "").length || 0
void selectionVersion void selectionVersion
function applyFont(font: string) { function applyFont(font: string) {
@@ -4545,9 +4712,9 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
} }
return ( return (
<div className="flex min-h-[330px] flex-1 flex-col bg-background sm:min-h-[420px]"> <div className="flex min-h-[300px] min-w-0 flex-1 flex-col bg-background sm:min-h-[360px]">
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} /> <Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
<div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-6"> <div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-5">
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-2 h-6" />
@@ -4564,7 +4731,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem> <DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground"> {maxAttachmentText}</span> <span className="px-1 text-xs text-muted-foreground" title={`单个附件上限 ${maxAttachmentText}`}> {maxAttachmentText}</span>
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} /> <ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}> <DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -4589,9 +4756,24 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
</div> </div>
</div> </div>
{formatOpen && ( {formatOpen && (
<div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-6"> <div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-5">
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-1 h-6" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[76px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent", paragraphStyleLabel(editor) !== "正文" && "border-primary/35 bg-primary/10 text-primary")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
{paragraphStyleLabel(editor)}<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{composerParagraphOptions.map((option) => (
<DropdownMenuItem key={option.key} className={composerMenuItemClass} onSelect={() => option.level ? editor?.chain().focus().toggleHeading({ level: option.level }).run() : editor?.chain().focus().setParagraph().run()}>
<Check className={cn("h-4 w-4", paragraphStyleLabel(editor) === option.label ? "opacity-100" : "opacity-0")} />
<span className={cn(option.level === 1 && "text-lg font-semibold", option.level === 2 && "text-base font-semibold", option.level === 3 && "text-sm font-semibold")}>{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}> <Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
@@ -4622,7 +4804,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
@@ -4657,30 +4839,39 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="减少缩进" disabled={!editor?.can().liftListItem("listItem")} onClick={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" /></ToolbarButton> <DropdownMenu>
<ToolbarButton label="增加缩进" disabled={!editor?.can().sinkListItem("listItem")} onClick={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></ToolbarButton> <DropdownMenuTrigger asChild>
<Separator orientation="vertical" className="mx-2 h-6" /> <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" title="更多格式" aria-label="更多格式" onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
<ToolbarButton label="左对齐" active={editor?.isActive({ textAlign: "left" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></ToolbarButton> <Ellipsis className="h-4 w-4" />
<ToolbarButton label="居中" active={editor?.isActive({ textAlign: "center" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></ToolbarButton> </Button>
<ToolbarButton label="右对齐" active={editor?.isActive({ textAlign: "right" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></ToolbarButton> </DropdownMenuTrigger>
<ToolbarButton label="引用" active={editor?.isActive("blockquote")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToolbarButton> <DropdownMenuContent align="end" className="w-44">
<ToolbarButton label="代码块" active={editor?.isActive("codeBlock")} disabled={!editor} onClick={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></ToolbarButton> <DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().liftListItem("listItem")} onSelect={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().sinkListItem("listItem")} onSelect={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="ml-auto whitespace-nowrap px-1 text-xs tabular-nums text-muted-foreground" aria-live="polite">{characterCount} </span>
</div> </div>
)} )}
<div className={cn( <div className={cn(
"composer-editor relative flex min-h-[240px] flex-1 border-b focus-within:bg-card/40 sm:min-h-[280px]", "composer-editor relative flex min-h-[220px] min-w-0 flex-1 overflow-hidden border-b focus-within:bg-card/40 sm:min-h-[260px]",
"[&_.ProseMirror]:min-h-[240px] [&_.ProseMirror]:w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none sm:[&_.ProseMirror]:min-h-[280px] sm:[&_.ProseMirror]:px-6 sm:[&_.ProseMirror]:py-5", "[&_.ProseMirror]:min-h-[220px] [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:w-full [&_.ProseMirror]:max-w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-x-hidden [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none [&_.ProseMirror]:[overflow-wrap:anywhere] sm:[&_.ProseMirror]:min-h-[260px] sm:[&_.ProseMirror]:px-5",
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3", "[&_.ProseMirror_a]:break-all [&_.ProseMirror_p]:max-w-full [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:max-w-full [&_.ProseMirror_pre]:whitespace-pre-wrap [&_.ProseMirror_pre]:break-words [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
empty && "bg-background" empty && "bg-background"
)}> )}>
<EditorContent editor={editor} className="flex min-h-0 flex-1" /> <EditorContent editor={editor} className="flex min-h-0 min-w-0 flex-1 overflow-hidden" />
</div> </div>
{files.length > 0 && ( {files.length > 0 && (
<div className="border-t px-4 py-3 sm:px-6"> <div className="border-t px-4 py-3 sm:px-5">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{files.map((file, index) => ( {files.map((file, index) => (
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal"> <Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
@@ -4702,7 +4893,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<div className="mail-html max-h-[60vh] overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} /> <div className="mail-html max-h-[60vh] max-w-full overflow-x-hidden overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7 [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
@@ -5145,7 +5336,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
function quoteMessage(message: MailMessage) { function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n") const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}` return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${messageSubject(message)}\n\n${quote}`
} }
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" } function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
function attachmentLimitBytes(limits?: PermissionLimits) { function attachmentLimitBytes(limits?: PermissionLimits) {
+125 -84
View File
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react" import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react" import { QRCodeSVG } from "qrcode.react"
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailFolder, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils" import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode" import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -23,16 +23,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { ConfirmDialog } from "@/components/confirm-dialog" import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens" type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
type AccountSettingsTab = "account" | "mail" | "clients" | "security" type AccountSettingsTab = "account" | "mail" | "clients" | "security"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> } type RetryableQuery = { isLoading: boolean; isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = { const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> }, profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> }, mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
@@ -356,11 +357,11 @@ export function ProfilePage() {
type="button" type="button"
className={cn( className={cn(
"flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors", "flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
tab === key ? "bg-muted font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground", tab === key ? "bg-[hsl(var(--sidebar-active))] font-semibold text-[hsl(var(--sidebar-active-foreground))]" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)} )}
onClick={() => setTab(key)} onClick={() => setTab(key)}
> >
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-foreground/70")}>{tabs[key].icon}</span> <span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-[hsl(var(--sidebar-active-foreground))]")}>{tabs[key].icon}</span>
<span className="truncate">{tabs[key].label}</span> <span className="truncate">{tabs[key].label}</span>
</button> </button>
))} ))}
@@ -377,7 +378,7 @@ export function ProfilePage() {
</div> </div>
</nav> </nav>
<div className="border-t p-2"> <div className="border-t p-2">
<Button type="button" variant="ghost" size="sm" className="h-9 w-full justify-start gap-2 px-3 text-destructive hover:text-destructive" onClick={logout}> <Button type="button" variant="outline" size="sm" className="h-9 w-full justify-start gap-2 border-destructive/35 px-3 text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
<span>退</span> <span>退</span>
</Button> </Button>
@@ -393,7 +394,7 @@ export function ProfilePage() {
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} /> <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
</div> </div>
: tab === "apiTokens" : 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> ? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://gitea.xzys.me/szx/NewSzxcn-Email/src/branch/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API </a></Button>
: undefined : undefined
return ( return (
@@ -434,7 +435,8 @@ export function ProfilePage() {
</div> </div>
) )
function renderTab() { function renderTab() {
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error) const visibleQueries = visibleTabQueries()
const failedQueries = visibleQueries.filter((query) => query.isError && query.error)
if (failedQueries.length > 0) { if (failedQueries.length > 0) {
return ( return (
<ProfileQueryFailure <ProfileQueryFailure
@@ -443,6 +445,7 @@ export function ProfilePage() {
/> />
) )
} }
if (visibleQueries.some((query) => query.isLoading)) return <ProfileSectionLoading />
if (tab === "profile") return ( if (tab === "profile") return (
<AccountSettingsSection <AccountSettingsSection
activeTab={accountTab} activeTab={accountTab}
@@ -534,6 +537,16 @@ export function ProfilePage() {
} }
} }
function ProfileSectionLoading() {
return (
<div className="space-y-4" aria-label="正在加载页面数据" aria-busy="true">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-56 w-full" />
<span className="sr-only">...</span>
</div>
)
}
function contentFrameClass(tab: Tab) { function contentFrameClass(tab: Tab) {
return cn( return cn(
"w-full", "w-full",
@@ -563,7 +576,7 @@ function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabCh
type="button" type="button"
className={cn( className={cn(
"h-[38px] shrink-0 border-b-2 px-4 text-sm font-medium transition-colors", "h-[38px] shrink-0 border-b-2 px-4 text-sm font-medium transition-colors",
activeTab === item.key ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground", activeTab === item.key ? "border-[hsl(var(--sidebar-active-foreground))] bg-[hsl(var(--sidebar-active))] text-[hsl(var(--sidebar-active-foreground))]" : "border-transparent text-muted-foreground hover:text-foreground",
)} )}
onClick={() => onAccountTabChange(item.key)} onClick={() => onAccountTabChange(item.key)}
> >
@@ -693,8 +706,8 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
<section className={cn("rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]", className)}> <section className={cn("rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]", className)}>
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-[15px] font-semibold leading-6 text-foreground">{title}</h2> <h2 className="break-words text-[15px] font-semibold leading-6 text-foreground [overflow-wrap:anywhere]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>} {subtitle && <p className="mt-0.5 break-words text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">{subtitle}</p>}
</div> </div>
{action && <div className="w-full shrink-0 sm:w-auto sm:justify-end [&>a]:w-full [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto">{action}</div>} {action && <div className="w-full shrink-0 sm:w-auto sm:justify-end [&>a]:w-full [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto">{action}</div>}
</div> </div>
@@ -1626,7 +1639,7 @@ function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }:
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}> <Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
{account ? "编辑" : <><Plus className="h-4 w-4" /></>} {account ? "编辑" : "添加外部邮箱"}
</Button> </Button>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
@@ -2221,16 +2234,13 @@ function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mai
}) })
} }
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{showStats && <StatsSummary stats={stats} />} {showStats && <StatsSummary stats={stats} />}
<Card> <SettingsCard title="清理当前邮箱" subtitle={mailbox ? `当前邮箱:${mailbox.address}` : "请先选择邮箱"} contentClassName="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} /> <CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} />
<CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} /> <CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} />
<CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} /> <CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
</CardContent> </SettingsCard>
</Card>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> <ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div> </div>
) )
@@ -2261,6 +2271,9 @@ const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移
function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) { function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) {
const [editingRule, setEditingRule] = React.useState<MailRule | null>(null) const [editingRule, setEditingRule] = React.useState<MailRule | null>(null)
const [query, setQuery] = React.useState("")
const normalizedQuery = query.trim().toLocaleLowerCase()
const filteredItems = items.map((item, index) => ({ item, index })).filter(({ item }) => !normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery))
function setDialogOpen(next: boolean) { function setDialogOpen(next: boolean) {
if (!next) setEditingRule(null) if (!next) setEditingRule(null)
@@ -2269,12 +2282,18 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-stretch sm:justify-end"> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}></Button> <div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称" aria-label="搜索规则" className="h-10 pl-9 pr-10" />
{query && <Button type="button" variant="ghost" size="icon" className="absolute right-1 top-1 size-8 text-muted-foreground" onClick={() => setQuery("")} aria-label="清除规则搜索" title="清除搜索"><X className="h-4 w-4" /></Button>}
</div>
<Button className="h-10 shrink-0 rounded-md px-3 text-sm font-normal sm:px-4" onClick={() => { setEditingRule(null); onOpenChange(true) }}></Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)} {filteredItems.map(({ item, index }) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />} {items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
{items.length > 0 && filteredItems.length === 0 && <EmptyState icon={<Search />} text="没有找到规则" description={`没有名称包含“${query.trim()}”的规则。`} className="min-h-[180px] border-solid bg-card" />}
</div> </div>
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} /> <RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
</div> </div>
@@ -2292,7 +2311,10 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
const [stopProcessing, setStopProcessing] = React.useState(false) const [stopProcessing, setStopProcessing] = React.useState(false)
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId }) const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
const folderQueryMailboxId = selectedMailboxId || "all"
const folderQuery = useQuery({ queryKey: ["folders", "rule-dialog", folderQueryMailboxId], queryFn: () => api.folders(folderQueryMailboxId), enabled: open })
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
const availableFolders = folderQuery.data?.items || []
React.useEffect(() => { React.useEffect(() => {
if (!open) return if (!open) return
@@ -2336,37 +2358,39 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]"> <DialogContent className="!flex h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden border-0 p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)] sm:border">
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6"> <DialogHeader className="shrink-0 border-b px-4 py-4 pr-16 text-left sm:px-8 sm:py-6 sm:pr-16">
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle> <DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7"> <div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field> <Field label="名称"><Input className="h-11 sm:h-9" value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field> <Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-sm"> <div className="space-y-2 text-sm">
<span></span> <div></div>
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}> <Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
<SelectTrigger className="h-9 w-[132px]"><SelectValue /></SelectTrigger> <SelectTrigger className="h-11 w-full md:h-9 md:w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="any"></SelectItem></SelectContent> <SelectContent><SelectItem value="all"></SelectItem><SelectItem value="any"></SelectItem></SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{conditions.map((condition, index) => ( {conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto]"> <div key={index} className="grid grid-cols-2 gap-2 rounded-md border p-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}> <Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent> <SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
</Select> </Select>
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}> <Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent> <SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select> </Select>
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} /> <Input className="col-span-2 h-11 md:col-span-1 md:h-9" type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button> <div className="col-span-2 flex justify-end gap-2 md:contents">
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -2376,14 +2400,16 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<div className="text-sm"></div> <div className="text-sm"></div>
<div className="space-y-3"> <div className="space-y-3">
{actions.map((action, index) => ( {actions.map((action, index) => (
<div key={index} className={cn("grid gap-3", action.type === "forward" ? "md:grid-cols-[180px_minmax(0,1fr)_auto]" : "md:grid-cols-[180px_minmax(0,1fr)_auto_auto]")}> <div key={index} className="grid gap-2 rounded-md border p-3 md:grid-cols-[180px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}> <Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent> <SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
</Select> </Select>
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /> <div className="min-w-0"><RuleActionValue action={action} folders={availableFolders} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /></div>
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button> <div className="flex justify-end gap-2 md:contents">
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>} <Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -2394,13 +2420,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<div className="space-y-4"> <div className="space-y-4">
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" /> <RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" /> <RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
<div className="flex items-center gap-2"> <div className="flex items-start gap-2">
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" /> <RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
<Info className="h-4 w-4 text-muted-foreground" /> <Info className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
</div> </div>
</div> </div>
</div> </div>
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto"> <DialogFooter className="shrink-0 gap-2 border-t px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:px-8 sm:py-5 [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:min-h-9 sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button> <Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
</DialogFooter> </DialogFooter>
@@ -2410,7 +2436,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
) )
} }
function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action: MailRuleAction; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) { function RuleActionValue({ action, folders, labels, verifiedEmails, onChange }: { action: MailRuleAction; folders: MailFolder[]; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
if (action.type === "label") { if (action.type === "label") {
if (labels.length > 0) { if (labels.length > 0) {
return ( return (
@@ -2424,20 +2450,26 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
} }
if (action.type === "move") { if (action.type === "move") {
const value = action.value || "Archive" const value = action.value || "Archive"
const customFolders = ruleCustomFolders(folders, value)
return ( return (
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]"> <Select value={value} onValueChange={(next) => onChange({ value: next })}>
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}> <SelectTrigger className="h-11 md:h-9"><SelectValue placeholder="选择文件夹" /></SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectContent>
<SelectContent> <SelectGroup>
<SelectLabel className="text-xs font-medium text-muted-foreground"></SelectLabel>
<SelectItem value="Inbox"></SelectItem> <SelectItem value="Inbox"></SelectItem>
<SelectItem value="Archive"></SelectItem> <SelectItem value="Archive"></SelectItem>
<SelectItem value="Spam"></SelectItem> <SelectItem value="Spam"></SelectItem>
<SelectItem value="Trash"></SelectItem> <SelectItem value="Trash"></SelectItem>
<SelectItem value="__custom"></SelectItem> </SelectGroup>
</SelectContent> <SelectSeparator />
</Select> <SelectGroup>
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" /> <SelectLabel className="text-xs font-medium text-muted-foreground"></SelectLabel>
</div> {customFolders.map((folder) => <SelectItem key={folder} value={folder}>{folder}</SelectItem>)}
{customFolders.length === 0 && <div className="px-2 py-2 text-sm text-muted-foreground"></div>}
</SelectGroup>
</SelectContent>
</Select>
) )
} }
if (action.type === "forward") { if (action.type === "forward") {
@@ -2446,6 +2478,14 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
return <Input value="无需填写" readOnly /> return <Input value="无需填写" readOnly />
} }
function ruleCustomFolders(folders: MailFolder[], selectedValue: string) {
const names = folders
.map((folder) => folder.name.trim())
.filter((name) => name && !commonRuleFolders.some((systemName) => systemName.toLowerCase() === name.toLowerCase()) && !["Sent", "Drafts"].some((systemName) => systemName.toLowerCase() === name.toLowerCase()))
if (selectedValue && !commonRuleFolders.includes(selectedValue) && !names.some((name) => name.toLowerCase() === selectedValue.toLowerCase())) names.push(selectedValue)
return Array.from(new Set(names)).sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true, sensitivity: "base" }))
}
function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) { function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) {
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value]) const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" /> return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" />
@@ -2458,7 +2498,7 @@ function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) { function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
const id = React.useId() const id = React.useId()
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div> return <div className="flex min-w-0 items-start gap-3"><Checkbox id={id} className="mt-0.5" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-sm font-medium leading-5 sm:text-base sm:leading-6">{label}</Label></div>
} }
function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) { function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
@@ -2466,26 +2506,26 @@ function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onTog
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains) const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join("") || "无动作" const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join("") || "无动作"
return ( return (
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20"> <div className="grid min-h-[110px] grid-cols-1 gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-2 sm:space-y-1">
<div className="flex min-w-0 items-center gap-2 leading-6"> <div className="flex min-w-0 items-start gap-2 leading-6 sm:items-center">
<h3 className="truncate text-base font-semibold text-foreground">{item.name}</h3> <h3 className="min-w-0 flex-1 break-words text-base font-semibold text-foreground [overflow-wrap:anywhere] sm:truncate">{item.name}</h3>
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span> <span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
</div> </div>
<div className="grid text-sm leading-6 text-muted-foreground"> <div className="grid gap-1 text-sm leading-5 text-muted-foreground sm:gap-0 sm:leading-6">
<p className="truncate"><span className="text-muted-foreground"></span> {mailboxLabel}</p> <p className="flex min-w-0 items-start gap-1"><span className="shrink-0"></span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{mailboxLabel}</span></p>
<p className="truncate"><span className="text-muted-foreground"></span> {conditionText}</p> <p className="flex min-w-0 items-start gap-1"><span className="shrink-0"></span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{conditionText}</span></p>
<p className="truncate"><span className="text-muted-foreground"></span> {actionText}</p> <p className="flex min-w-0 items-start gap-1"><span className="shrink-0"></span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{actionText}</span></p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center justify-end gap-1 border-t pt-3 sm:border-t-0 sm:pt-0">
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><ChevronDown className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><ChevronDown className="h-4 w-4" /></Button>
<span className="mx-1 h-6 w-px bg-border" /> <span className="mx-1 h-6 w-px bg-border" />
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button> <Button type="button" variant="ghost" size="icon" className={cn("size-9 sm:size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-7 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="size-9 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive sm:size-7" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button>
</div> </div>
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} /> <ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
</div> </div>
@@ -2556,17 +2596,18 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
setDialogOpen(false) setDialogOpen(false)
} }
return ( return (
<div className="space-y-4"> <div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <SettingsCard
<div className="text-xs font-medium text-muted-foreground"> {spamCount} · {items.length} </div> title="拦截规则"
<Button type="button" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}><Plus className="h-4 w-4" /></Button> subtitle={`${items.length} 条发件人规则 · 垃圾邮件 ${spamCount}`}
</div> action={<Button type="button" onClick={() => setDialogOpen(true)}></Button>}
<SettingsCard title="被拦截邮件" subtitle="发件人命中拦截规则后会进入垃圾邮件,规则可随时移除。" contentClassName="space-y-2"> contentClassName="space-y-2"
>
{items.map((item) => ( {items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/40"> <div key={item.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/40">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div> <div className="break-all text-sm font-semibold leading-5 text-foreground">{item.email}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div> <div className="mt-1 break-words text-xs leading-5 text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
</div> </div>
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div> </div>
@@ -2795,13 +2836,13 @@ function StatsSummary({ stats }: { stats?: MailStats }) {
{ label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel, icon: <BarChart3 className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel, icon: <BarChart3 className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
] ]
return ( return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{cards.map((card) => ( {cards.map((card) => (
<Card key={card.label} className="shadow-[0_1px_2px_rgba(15,23,42,0.04)]"> <Card key={card.label} className="min-w-0 overflow-hidden shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex min-w-0 items-center gap-3 p-4">
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div> <div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-xl font-semibold leading-6 text-foreground">{card.value}</div> <div className="break-words text-lg font-semibold leading-6 text-foreground [overflow-wrap:anywhere]">{card.value}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{card.label}</div> <div className="mt-0.5 text-xs text-muted-foreground">{card.label}</div>
</div> </div>
</CardContent> </CardContent>
@@ -2811,7 +2852,7 @@ function StatsSummary({ stats }: { stats?: MailStats }) {
) )
} }
function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="h-auto justify-start p-4 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 rounded-lg bg-muted p-2">{icon}</div><div className="font-medium">{title}</div></Button> } function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="min-h-[72px] w-full min-w-0 justify-start whitespace-normal px-4 py-3 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 shrink-0 rounded-md bg-muted p-2">{icon}</div><div className="min-w-0 break-words font-medium leading-5">{title}</div></Button> }
function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all"></SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> } function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all"></SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> } function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
function EmptyState({ text, description, icon, action, className }: { text: string; description?: string; icon?: React.ReactNode; action?: React.ReactNode; className?: string }) { function EmptyState({ text, description, icon, action, className }: { text: string; description?: string; icon?: React.ReactNode; action?: React.ReactNode; className?: string }) {
@@ -2840,7 +2881,7 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}> <Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {darkMode ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4" />}
</Button> </Button>
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}> <Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
+3 -1
View File
@@ -14,6 +14,7 @@ import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box" import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation" import { validatePasswordConfirm } from "@/lib/validation"
import { AuthError, AuthLoading } from "@/components/auth-states" import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function RegisterPage() { export function RegisterPage() {
const me = useMe() const me = useMe()
@@ -63,7 +64,8 @@ export function RegisterPage() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10"> <main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]"> <div className="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
+1 -1
View File
@@ -5,7 +5,7 @@
推荐直接使用仓库根目录的管理脚本: 推荐直接使用仓库根目录的管理脚本:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
``` ```
后续操作: 后续操作:
+2 -1
View File
@@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
ca-certificates tzdata sqlite3 supervisor nginx \ ca-certificates tzdata sqlite3 supervisor nginx \
postfix postfix-sqlite \ postfix postfix-sqlite \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \ dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
rspamd rspamd zstd openssl
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
@@ -43,6 +43,7 @@ COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
COPY deploy/postfix/main.cf /etc/postfix/main.cf COPY deploy/postfix/main.cf /etc/postfix/main.cf
COPY deploy/postfix/master.cf /etc/postfix/master.cf COPY deploy/postfix/master.cf /etc/postfix/master.cf
+1
View File
@@ -21,5 +21,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
EXPOSE 8080 465 587 EXPOSE 8080 465 587
CMD ["lanqin-api"] CMD ["lanqin-api"]
+5
View File
@@ -5,6 +5,8 @@ services:
environment: environment:
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-} LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
LANQIN_BACKUP_SOURCE_DIR: /backup-source
LANQIN_BACKUP_DIR: /backups
ports: ports:
- "${LANQIN_HTTP_BIND:-80}:80" - "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_SMTP_BIND:-25}:25" - "${LANQIN_SMTP_BIND:-25}:25"
@@ -17,6 +19,9 @@ services:
- ./mail:/var/mail/vhosts - ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim - ./dkim:/var/lib/rspamd/dkim
- ./certs:/certs:ro - ./certs:/certs:ro
- ./.env:/backup-source/.env:ro
- ./docker-compose.yml:/backup-source/docker-compose.yml:ro
- ./backups:/backups
labels: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email" com.centurylinklabs.watchtower.scope: "newszxcn-email"
+71
View File
@@ -0,0 +1,71 @@
# NewSzxcn 完整备份与灾难恢复
## 后台创建并推送到 Telegram
1. 使用系统管理员登录 NewSzxcn 后台。
2. 在“系统设置 -> 通知”绑定 Telegram Bot Token 和私聊 Chat ID,并发送测试通知。
3. 打开“备份与恢复”。
4. 在“定时备份与 Telegram 推送”中选择每 3、5、7、30 天,或填写 1 至 365 天的自定义周期。
5. 备份 Chat ID 留空时沿用邮件通知私聊;也可以填写一个仅管理员可见的私有群组 Chat ID,将邮件通知与备份文件分开。Bot 必须已经加入该群组。
6. 填写服务器公网 IP。备份密码可以自己输入,也可以点击“生成 24 位”;必须另外保存到 1Password 等密码管理器。
7. 开启“自动创建并推送”,保存设置。
8. 首次配置建议点击“创建备份”,勾选“完成后发送到 Telegram”,确认机器人能收到说明消息和 `.tar.zst.enc` 加密附件。
Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名、管理员账号、普通用户账号、邮箱账号、文件大小、SHA-256 和恢复步骤。已有域名只列域名,不附加账号身份。
消息不会包含管理员密码、用户密码或备份密码。数据库只保存登录密码哈希,不能反向读取明文;恢复后账号继续使用原登录密码。备份密码与加密附件也不应保存在同一个 Telegram 会话中。
## 原服务器失联后的恢复
准备一台新的 Debian 或 Ubuntu 服务器。先把 Telegram 中的加密附件原样上传到新服务器的 `/root/` 目录,请不要解压、修改或固定填写某个示例文件名。备份日期和版本号每次可能不同。
确认文件已经上传后,首次执行官方脚本:
```bash
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
```
脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。
在“尚未安装”菜单选择:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:尚未安装
--------------------------------------------------
1. 一键安装 NewSzxcn Email
2. 备份恢复
3. 退出
==================================================
```
进入备份恢复菜单后,输入 `1` 选择“本地上传”。脚本会自动扫描 `/root/newszxcn-backup-*`:只有一份时直接选中;多份时按日期从新到旧显示为 `1、2、3` 等序号,输入对应序号,例如输入 `1` 恢复第 1 份。没有找到时才要求手动输入完整路径。选定后输入备份密码,脚本会检查压缩包路径、SQLite 完整性和必要目录,再启动服务。
恢复完成后:
1. 如果新服务器 IP 改变,更新邮件主机的 A/AAAA、邮件域名的 MX/SPF,以及服务商处的 PTR 记录。
2. 检查 DKIM 和 DMARCDKIM 私钥已随备份恢复,但 DNS 仍应核对。
3. 检查 TLS 证书是否适用于当前主机名,必要时重新签发。
4. 登录网页并测试收信、发信、IMAP、POP3 和 SMTP Submission。
5. 打开“备份与恢复”,重新测试 Telegram 推送。
## 备份内容
完整备份包括 SQLite 数据库、附件、Maildir 原始邮件、DKIM 私钥、TLS 证书、`.env`、Compose 配置、版本清单和 SHA-256 校验文件。备份使用 Zstandard 压缩,并以 AES-256-CBC、PBKDF2 200000 次迭代和 SHA-256 加密。
Telegram 适合保存体积较小的应急副本,不应作为唯一备份位置。超过 Telegram 发送上限的文件请从后台下载,并保存到 Google 云端硬盘、另一台服务器、对象存储或离线磁盘。
## Google 云端硬盘
后台“备份与恢复”支持将同一份加密备份保存到 Google 云端硬盘。系统只申请 `drive.file` 权限,只能管理由 NewSzxcn 创建的文件,不会读取云端硬盘中的其他文件。
1. 在 Google Cloud Console 创建项目并启用 Google Drive API。
2. 配置 OAuth 同意屏幕,再创建“Web 应用”类型的 OAuth 客户端。
3. 授权重定向 URI 填写 `https://你的邮局域名/api/admin/backups/google-drive/callback`,必须与后台系统设置中的公开访问地址一致。
4. 在后台填写 OAuth 客户端 ID、客户端密钥和云端文件夹名称,先保存或直接点击“连接 Google”。
5. 在 Google 授权页面确认后会自动返回“备份与恢复”,状态显示“已连接”。
6. 可开启“用于定时备份”,也可在创建备份或已有备份菜单中单独上传。
OAuth 客户端密钥和刷新令牌会使用服务器内部密钥加密保存。Google 云端硬盘中只保存 `.tar.zst.enc` 加密备份,备份密码仍应单独保管。
+2 -2
View File
@@ -7,7 +7,7 @@
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com` 建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
``` ```
安装脚本会依次询问防火墙配置和邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。 安装脚本会依次询问防火墙配置和邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
@@ -170,4 +170,4 @@ sudo newszxcn-email reset-2fa
- [项目说明](../README.md) - [项目说明](../README.md)
- [Docker 部署说明](../deploy/README.md) - [Docker 部署说明](../deploy/README.md)
- [API 文档](API.md) - [API 文档](API.md)
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) - [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases)
+308 -6
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
REPOSITORY="zxyszx/NewSzxcn-Email"
RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}" INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
DEFAULT_RAW_BASE="https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main"
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
RAW_BASE="${LANQIN_RAW_BASE:-}"
if [[ -z "${RAW_BASE}" && -s "${SOURCE_URL_FILE}" ]]; then
RAW_BASE="$(tr -d '\r\n' < "${SOURCE_URL_FILE}")"
fi
RAW_BASE="${RAW_BASE:-${DEFAULT_RAW_BASE}}"
COMMAND="${1:-menu}" COMMAND="${1:-menu}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest" ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
@@ -28,6 +33,7 @@ NewSzxcn Email 管理命令
menu 显示安装与运维菜单 menu 显示安装与运维菜单
install 首次安装;已有安装会先完整备份再重新安装 install 首次安装;已有安装会先完整备份再重新安装
restore 从完整备份目录或压缩包恢复到新服务器
update 备份数据库并更新到最新版 update 备份数据库并更新到最新版
repair 检查并修复现有安装 repair 检查并修复现有安装
status 查看容器与健康状态 status 查看容器与健康状态
@@ -134,6 +140,8 @@ apply_staged_assets() {
install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1 install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1 install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1 mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
printf '%s\n' "${RAW_BASE}" > "${SOURCE_URL_FILE}" || return 1
chmod 0644 "${SOURCE_URL_FILE}" || return 1
rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new" rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
} }
@@ -161,6 +169,25 @@ ensure_cli_alias() {
success "快捷命令已创建:输入 ns 可打开管理菜单。" success "快捷命令已创建:输入 ns 可打开管理菜单。"
} }
ensure_cli_command() {
local source_dir tmp
[[ -x "${CLI_PATH}" ]] && return 0
install -d -m 0755 "$(dirname "${CLI_PATH}")"
source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* && -f "${source_dir}/install.sh" ]]; then
install -m 0755 "${source_dir}/install.sh" "${CLI_PATH}"
else
tmp="$(mktemp)"
if ! curl -fsSL "${RAW_BASE}/install.sh" -o "${tmp}" || ! bash -n "${tmp}"; then
rm -f "${tmp}"
warn "未能安装管理命令;完成安装后可重新运行官方脚本修复。"
return 0
fi
install -m 0755 "${tmp}" "${CLI_PATH}"
rm -f "${tmp}"
fi
}
refresh_assets() { refresh_assets() {
stage_assets stage_assets
apply_staged_assets apply_staged_assets
@@ -1077,6 +1104,277 @@ do_install() {
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。" warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
} }
validate_restore_source() {
local source="$1"
[[ -f "${source}/.env" ]] || { warn "备份缺少 .env。"; return 1; }
[[ -f "${source}/docker-compose.yml" ]] || { warn "备份缺少 docker-compose.yml。"; return 1; }
[[ -s "${source}/data/lanqin.db" ]] || { warn "备份缺少数据库 data/lanqin.db。"; return 1; }
[[ -d "${source}/mail" ]] || { warn "备份缺少 mail 邮件目录。"; return 1; }
[[ -d "${source}/dkim" ]] || { warn "备份缺少 dkim 密钥目录。"; return 1; }
[[ -d "${source}/certs" ]] || { warn "备份缺少 certs 证书目录。"; return 1; }
}
validate_restore_database() {
local database="$1" result
if ! command -v sqlite3 >/dev/null 2>&1; then
log "正在安装 SQLite 校验工具..."
install_packages sqlite3
fi
result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
[[ "${result}" == "ok" ]] || { warn "备份数据库完整性检查未通过。"; return 1; }
}
locate_extracted_restore_root() {
local root="$1" candidate
if validate_restore_source "${root}" >/dev/null 2>&1; then
printf '%s' "${root}"
return 0
fi
candidate="$(find "${root}" -mindepth 1 -maxdepth 2 -type f -name .env -print -quit 2>/dev/null || true)"
[[ -n "${candidate}" ]] || return 1
candidate="$(dirname "${candidate}")"
validate_restore_source "${candidate}" >/dev/null 2>&1 || return 1
printf '%s' "${candidate}"
}
render_restore_menu() {
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 备份恢复\n'
prompt_text '==================================================\n'
prompt_text '1. 本地上传\n'
prompt_text '2. 返回上一级\n'
prompt_text '==================================================\n'
prompt_text '请先将原始加密备份上传到新服务器的 /root/ 目录,不要解压。\n'
prompt_text '系统会自动检测 /root/ 目录中的 NewSzxcn 备份文件。\n'
}
do_restore_menu() {
local choice
render_restore_menu
choice="$(prompt_menu_choice "1" "2")" || return 1
case "${choice}" in
1) do_restore_backup ;;
2) success "已返回,未作任何修改。" ;;
esac
}
prompt_restore_password() {
local password="${LANQIN_RESTORE_PASSWORD:-}"
if [[ -z "${password}" ]] && has_tty; then
read -r -s -p "备份密码: " password </dev/tty
printf '\n' >/dev/tty
fi
[[ -n "${password}" ]] || fail "加密备份必须提供备份密码。"
(( ${#password} >= 8 && ${#password} <= 1024 )) || fail "备份密码必须为 8 至 1024 个字符。"
[[ "${password}" != *$'\n'* && "${password}" != *$'\r'* ]] || fail "备份密码不能包含换行。"
printf '%s' "${password}"
}
discover_restore_backups() {
local search_dir="${LANQIN_RESTORE_SEARCH_DIR:-/root}" path
local -a matches=()
[[ -d "${search_dir}" ]] || return 0
while IFS= read -r path; do
case "${path}" in
*.tar.zst.enc|*.tar.zst|*.tar.gz|*.tgz|*.tar) matches+=("${path}") ;;
esac
done < <(find "${search_dir}" -maxdepth 1 -type f -name 'newszxcn-backup-*' -print 2>/dev/null | LC_ALL=C sort -r)
(( ${#matches[@]} > 0 )) || return 0
printf '%s\n' "${matches[@]}"
}
select_restore_source() {
local selection="${LANQIN_RESTORE_SELECTION:-}" path index
local -a backups=()
while IFS= read -r path; do
[[ -n "${path}" ]] && backups+=("${path}")
done < <(discover_restore_backups)
if (( ${#backups[@]} == 1 )); then
prompt_text "[检测] 已找到备份:${backups[0]}\n"
printf '%s' "${backups[0]}"
return 0
fi
if (( ${#backups[@]} > 1 )); then
prompt_text "[检测] 在 /root/ 找到 ${#backups[@]} 份备份:\n"
for index in "${!backups[@]}"; do
prompt_text "$((index + 1)). ${backups[index]}\n"
done
prompt_text "$(( ${#backups[@]} + 1 )). 手动输入其他路径\n"
if [[ -z "${selection}" ]] && has_tty; then
read -r -p "请输入要恢复的备份序号 [1]: " selection </dev/tty
fi
selection="${selection:-1}"
if [[ "${selection}" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#backups[@]} )); then
prompt_text "[选择] 将使用第 ${selection} 份备份开始恢复。\n"
printf '%s' "${backups[selection-1]}"
return 0
fi
[[ "${selection}" == "$(( ${#backups[@]} + 1 ))" ]] || fail "备份序号无效。"
else
prompt_text "[提示] /root/ 目录没有检测到 NewSzxcn 备份,请手动输入路径。\n"
fi
if has_tty; then
read -r -p "备份文件完整路径: " path </dev/tty
else
path="${LANQIN_RESTORE_SOURCE:-}"
fi
printf '%s' "${path}"
}
archive_has_unsafe_paths() {
awk '
BEGIN { bad=0 }
{
if (substr($0, 1, 1) == "/") bad=1
count=split($0, parts, "/")
for (i=1; i<=count; i++) if (parts[i] == "..") bad=1
}
END { exit bad ? 0 : 1 }
'
}
archive_has_unsafe_types() {
awk '
BEGIN { bad=0 }
/^[[:space:]]*$/ { next }
{
type=substr($0, 1, 1)
if (type != "-" && type != "d") bad=1
}
END { exit bad ? 0 : 1 }
'
}
extract_restore_archive() {
local source="$1" destination="$2" password decrypted
case "${source}" in
*.tar.zst.enc)
command -v openssl >/dev/null 2>&1 || install_packages openssl
command -v zstd >/dev/null 2>&1 || install_packages zstd
password="$(prompt_restore_password)"
decrypted="${destination}/backup.tar.zst"
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -in "${source}" -out "${decrypted}" -pass fd:3 3<<<"${password}" 2>/dev/null; then
fail "备份密码错误或加密备份已损坏。"
fi
if zstd -dc "${decrypted}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if zstd -dc "${decrypted}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
zstd -dc "${decrypted}" 2>/dev/null | tar -xf - -C "${destination}" \
|| fail "加密备份无法解压,请检查文件和密码。"
rm -f "${decrypted}"
;;
*.tar.zst)
command -v zstd >/dev/null 2>&1 || install_packages zstd
if zstd -dc "${source}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if zstd -dc "${source}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
zstd -dc "${source}" 2>/dev/null | tar -xf - -C "${destination}" \
|| fail "Zstandard 备份无法解压。"
;;
*.tar|*.tar.gz|*.tgz)
if tar -tf "${source}" | archive_has_unsafe_paths; then
fail "备份压缩包包含不安全路径,已拒绝恢复。"
fi
if tar -tvf "${source}" | archive_has_unsafe_types; then
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
fi
tar -xf "${source}" -C "${destination}" || fail "备份压缩包无法解压。"
;;
*)
fail "不支持的备份格式;请选择 .tar.zst.enc、.tar.zst、.tar.gz、.tgz 或 .tar。"
;;
esac
}
do_restore_backup() {
local source="${LANQIN_RESTORE_SOURCE:-}" extracted="" restore_root staging image_ref image nginx_backup=""
! installation_configured || fail "当前服务器已经存在安装配置;为防止覆盖运行数据,只能在空白新服务器执行完整恢复。"
[[ -n "${source}" ]] || source="$(select_restore_source)"
[[ -n "${source}" ]] || fail "请提供备份目录或备份压缩包路径。"
source="$(readlink -f "${source}" 2>/dev/null || true)"
[[ -e "${source}" ]] || fail "备份不存在:${source}"
if [[ -d "${source}" ]]; then
restore_root="${source}"
else
command -v tar >/dev/null 2>&1 || install_packages tar
extracted="$(mktemp -d)"
extract_restore_archive "${source}" "${extracted}"
restore_root="$(locate_extracted_restore_root "${extracted}" || true)"
fi
if [[ -z "${restore_root}" ]] || ! validate_restore_source "${restore_root}"; then
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
fail "这不是可恢复的 NewSzxcn 完整备份。"
fi
if ! validate_restore_database "${restore_root}/data/lanqin.db"; then
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
fail "备份数据库已损坏,未写入任何 NewSzxcn 数据。"
fi
staging="${INSTALL_DIR}.restore-staging-$(date -u +%Y%m%dT%H%M%SZ)"
[[ ! -e "${INSTALL_DIR}" || -z "$(find "${INSTALL_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] \
|| fail "${INSTALL_DIR} 已有文件,已取消恢复以免覆盖数据。"
rm -rf "${staging}"
install -d -m 0700 "${staging}"
cp -a "${restore_root}/." "${staging}/"
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
rm -rf "${INSTALL_DIR}"
mv "${staging}" "${INSTALL_DIR}"
chmod 0600 "${INSTALL_DIR}/.env"
if [[ -f "${NGINX_CONFIG}" ]]; then
nginx_backup="$(mktemp)"
cp -a "${NGINX_CONFIG}" "${nginx_backup}"
fi
if ! (
refresh_assets
ensure_update_token
ensure_admin_email_config
configure_runtime_bindings
ensure_docker
configure_firewall
prepare_directories
log "正在拉取恢复所需的 NewSzxcn Email 镜像..."
compose pull
image_ref="$(env_value LANQIN_IMAGE || true)"
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
image="$(docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null || true)"
[[ -n "${image}" ]] || fail "无法检查恢复数据库:镜像不存在。"
sqlite_integrity_check "${INSTALL_DIR}/data/lanqin.db" "${image}" || fail "备份数据库完整性检查未通过,服务未启动。"
log "备份检查通过,正在启动服务..."
compose up -d --remove-orphans
wait_for_health 90 || fail "恢复后的服务未通过健康检查,请执行 newszxcn-email logs。"
configure_web_mode
); then
warn "恢复未完成,正在清理本次未成功的安装。"
compose down --remove-orphans >/dev/null 2>&1 || true
rm -rf "${INSTALL_DIR}"
if [[ -n "${nginx_backup}" && -f "${nginx_backup}" ]]; then
cp -a "${nginx_backup}" "${NGINX_CONFIG}"
elif [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}"
fi
rm -f "${nginx_backup}"
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx >/dev/null 2>&1 || true
fi
fail "恢复失败,原始备份文件未修改;修复问题后可重新执行备份恢复。"
fi
rm -f "${nginx_backup}"
ensure_cli_alias
generate_guide >/dev/null || warn "数据已恢复,但配置指南生成失败,可稍后执行 newszxcn-email guide。"
success "备份恢复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "如果服务器 IP 已更换,请更新 A、MX、SPF、PTR,并重新检查 TLS 证书。"
}
do_update() { do_update() {
require_installation require_installation
ensure_docker ensure_docker
@@ -1233,7 +1531,7 @@ SSL 证书:有效期至 ${certificate_expiry}
重置管理员密码:newszxcn-email reset-password 重置管理员密码:newszxcn-email reset-password
公开教程: 公开教程:
https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md https://gitea.xzys.me/szx/NewSzxcn-Email/src/branch/main/docs/GUIDE.md
EOF EOF
install -m 0600 "${tmp}" "${GUIDE_FILE}" install -m 0600 "${tmp}" "${GUIDE_FILE}"
rm -f "${tmp}" rm -f "${tmp}"
@@ -1503,7 +1801,8 @@ render_uninstalled_menu() {
prompt_text '状态:尚未安装\n' prompt_text '状态:尚未安装\n'
prompt_text '--------------------------------------------------\n' prompt_text '--------------------------------------------------\n'
prompt_text '1. 一键安装 NewSzxcn Email\n' prompt_text '1. 一键安装 NewSzxcn Email\n'
prompt_text '0. 退出\n' prompt_text '2. 备份恢复\n'
prompt_text '3. 退出\n'
prompt_text '==================================================\n' prompt_text '==================================================\n'
} }
@@ -1541,10 +1840,11 @@ do_menu() {
local default_choice="2" public_url="" choice status version local default_choice="2" public_url="" choice status version
if ! installation_configured; then if ! installation_configured; then
render_uninstalled_menu render_uninstalled_menu
choice="$(prompt_menu_choice "1" "1")" || return 1 choice="$(prompt_menu_choice "1" "3")" || return 1
case "${choice}" in case "${choice}" in
0) success "已退出,未作任何修改。" ;; 3) success "已退出,未作任何修改。" ;;
1) do_install ;; 1) do_install ;;
2) do_restore_menu ;;
esac esac
return return
fi fi
@@ -1581,6 +1881,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
fi fi
if [[ "${EUID}" -eq 0 ]]; then if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_command
ensure_cli_alias ensure_cli_alias
fi fi
@@ -1588,6 +1889,7 @@ case "${COMMAND}" in
help|-h|--help) usage ;; help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;; menu) require_root; require_curl; do_menu ;;
install) require_root; require_curl; do_install ;; install) require_root; require_curl; do_install ;;
restore) require_root; require_curl; do_restore_menu ;;
update) require_root; require_curl; do_update ;; update) require_root; require_curl; do_update ;;
repair) require_root; require_curl; do_repair_install ;; repair) require_root; require_curl; do_repair_install ;;
status) require_root; require_curl; do_status ;; status) require_root; require_curl; do_status ;;
+171 -3
View File
@@ -126,6 +126,24 @@ test_compose_configuration() {
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing" grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
} }
test_source_url_persistence() (
local temp_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
CLI_PATH="${temp_dir}/newszxcn-email"
RAW_BASE="https://gitea.example.test/szx/NewSzxcn-Email/raw/branch/main"
mkdir -p "${INSTALL_DIR}"
printf 'services: {}\n' > "${INSTALL_DIR}/.docker-compose.yml.new"
printf 'LANQIN_IMAGE=test\n' > "${INSTALL_DIR}/.env.example.new"
printf '#!/usr/bin/env bash\nexit 0\n' > "${INSTALL_DIR}/.install.sh.new"
apply_staged_assets
assert_eq "${RAW_BASE}" "$(cat "${SOURCE_URL_FILE}")" "persisted installer source URL"
[[ -x "${CLI_PATH}" ]] || fail_test "installed CLI is not executable"
)
test_legacy_configuration_is_preserved() { test_legacy_configuration_is_preserved() {
local temp_dir local temp_dir
temp_dir="$(mktemp -d)" temp_dir="$(mktemp -d)"
@@ -159,6 +177,8 @@ test_menu_rendering() (
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing" [[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing" [[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing" [[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
[[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing"
[[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing"
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action" [[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action" [[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
@@ -186,12 +206,15 @@ test_menu_dispatch() (
mkdir -p "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}"
prompt_text() { :; } prompt_text() { :; }
do_install() { printf 'install\n' > "${action_file}"; } do_install() { printf 'install\n' > "${action_file}"; }
do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; }
do_menu do_menu
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install" grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then LANQIN_MENU_ACTION=2
fail_test "uninstalled menu accepted unavailable update action" do_menu
fi grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore"
LANQIN_MENU_ACTION=3
do_menu >/dev/null
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
@@ -430,6 +453,144 @@ test_cli_alias_safety() (
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten" grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
) )
test_restore_source_validation() (
local temp_dir
temp_dir="$(mktemp -d)"
mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs"
printf 'config\n' > "${temp_dir}/.env"
printf 'services: {}\n' > "${temp_dir}/docker-compose.yml"
sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected"
validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected"
printf 'damaged\n' > "${temp_dir}/data/lanqin.db"
if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then
fail_test "damaged restore database accepted"
fi
rm -f "${temp_dir}/data/lanqin.db"
if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then
fail_test "restore source without database accepted"
fi
)
test_restore_menu_rendering_and_dispatch() (
local output action_file LANQIN_MENU_ACTION=1
action_file="$(mktemp)"
prompt_text() { printf '%b' "$1"; }
output="$(render_restore_menu)"
[[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing"
[[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing"
[[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing"
[[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing"
prompt_text() { :; }
do_restore_backup() { printf 'restore\n' > "${action_file}"; }
do_restore_menu
grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload"
LANQIN_MENU_ACTION=2
do_restore_menu >/dev/null
unset LANQIN_MENU_ACTION
)
test_restore_backup_discovery() (
local temp_dir output selected
temp_dir="$(mktemp -d)"
LANQIN_RESTORE_SEARCH_DIR="${temp_dir}"
touch "${temp_dir}/unrelated.tar.zst.enc"
output="$(discover_restore_backups)"
[[ -z "${output}" ]] || fail_test "unrelated archive was discovered"
touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc"
selected="$(select_restore_source)"
assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup"
touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc"
touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc"
output="$(discover_restore_backups)"
assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering"
LANQIN_RESTORE_SELECTION=2
selected="$(select_restore_source)"
assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup"
)
test_encrypted_restore_archive() (
local temp_dir source_dir archive extracted password='RestorePassword123!'
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source/newszxcn-email"
archive="${temp_dir}/newszxcn-backup.tar.zst.enc"
extracted="${temp_dir}/extracted"
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}"
printf 'config\n' > "${source_dir}/.env"
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
zstd() {
if [[ "$*" == '-q -c' ]]; then
gzip -c
elif [[ "$1" == '-dc' ]]; then
gzip -dc "$2"
else
return 1
fi
}
tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}"
validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed"
)
test_failed_full_restore_cleans_partial_install() (
local temp_dir source_dir archive password='RestorePassword123!'
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source/newszxcn-backup"
archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc"
INSTALL_DIR="${temp_dir}/install"
NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf"
CERT_DIR="${temp_dir}/certs"
LANQIN_RESTORE_SOURCE="${archive}"
LANQIN_RESTORE_PASSWORD="${password}"
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env"
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
zstd() {
if [[ "$*" == '-q -c' ]]; then
gzip -c
elif [[ "$1" == '-dc' ]]; then
gzip -dc "$2"
else
return 1
fi
}
tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
refresh_assets() { return 0; }
ensure_update_token() { return 0; }
ensure_admin_email_config() { return 0; }
configure_runtime_bindings() { return 0; }
ensure_docker() { return 0; }
configure_firewall() { return 0; }
prepare_directories() { return 0; }
compose() {
case "$1" in
pull) return 1 ;;
down) return 0 ;;
esac
return 0
}
if (do_restore_backup >/dev/null 2>&1); then
fail_test "failed full restore unexpectedly succeeded"
fi
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation"
[[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup"
)
test_restore_archive_path_validation() (
printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected"
printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted"
printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted"
printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected"
printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected"
printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted"
)
test_compose_runtime_image_pin() ( test_compose_runtime_image_pin() (
local temp_dir calls local temp_dir calls
temp_dir="$(mktemp -d)" temp_dir="$(mktemp -d)"
@@ -692,6 +853,7 @@ test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "fals
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false" test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_nginx_configuration test_nginx_configuration
test_compose_configuration test_compose_configuration
test_source_url_persistence
test_legacy_configuration_is_preserved test_legacy_configuration_is_preserved
test_menu_choice test_menu_choice
test_menu_rendering test_menu_rendering
@@ -707,6 +869,12 @@ test_offline_database_backup
test_guide_generation test_guide_generation
test_acme_cron_detection test_acme_cron_detection
test_cli_alias_safety test_cli_alias_safety
test_restore_source_validation
test_restore_menu_rendering_and_dispatch
test_restore_backup_discovery
test_encrypted_restore_archive
test_failed_full_restore_cleans_partial_install
test_restore_archive_path_validation
test_compose_runtime_image_pin test_compose_runtime_image_pin
test_update_snapshot_restore test_update_snapshot_restore
test_snapshot_restores_absent_optional_files test_snapshot_restores_absent_optional_files