Compare commits

...

20 Commits

Author SHA1 Message Date
zxyszx c92140c9cc release: prepare v1.2.30
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 05:34:49 +08:00
zxyszx 2fd37bf635 release: prepare v1.2.29
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 04:25:17 +08:00
zxyszx 3d3a251af2 release: prepare v1.2.28
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 01:15:42 +08:00
zxyszx 6ad9164be3 release: prepare v1.2.27
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 23:40:55 +08:00
zxyszx ce98978ebd release: prepare v1.2.26
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 14:09:08 +08:00
zxyszx ee38990ea1 release: prepare v1.2.25
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 22:30:39 +08:00
zxyszx 9cb3f13b02 fix: remove duplicate verified email entry
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 21:16:34 +08:00
zxyszx d2cfad3cc4 feat: streamline forwarding email verification
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 20:24:02 +08:00
zxyszx dbd5b95143 fix: support SMTP LOGIN authentication
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 15:42:05 +08:00
zxyszx 131421a0f1 release: prepare v1.2.21
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 02:55:49 +08:00
云逸 2a26a3b127 [codex] 合并账号级与邮箱单独转发 (#1)
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
* fix: merge account and mailbox forwarding

* docs: prepare v1.2.20 release

---------

Co-authored-by: zxyszx <299979470+zxyszx@users.noreply.github.com>
2026-08-09 11:33:41 +08:00
zxyszx aeaa151e90 docs: mark v1.2.19 release complete 2026-08-07 13:50:26 +08:00
zxyszx 60d87a6960 ci: support older ShellCheck warning code
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-07 13:32:32 +08:00
zxyszx 70dd2cec4e feat: prepare v1.2.19 release 2026-08-07 13:29:03 +08:00
zxyszx a9ec9360a8 docs: mark v1.2.18 release complete 2026-08-06 19:07:37 +08:00
zxyszx 942605b2b6 fix: refine Telegram links and mailbox selector
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 18:53:21 +08:00
zxyszx 635ab02b29 feat: harden Telegram mail notifications
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 17:13:08 +08:00
zxyszx 4c90c1de44 ci: improve Telegram release notification layout 2026-08-06 02:37:47 +08:00
zxyszx 1fa029370d docs: mark v1.2.16 notification release 2026-08-06 02:31:30 +08:00
zxyszx 79f920bf0b feat: add Telegram mail and release notifications
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-06 02:17:11 +08:00
75 changed files with 5220 additions and 922 deletions
+3
View File
@@ -0,0 +1,3 @@
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
+6
View File
@@ -0,0 +1,6 @@
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
+4
View File
@@ -0,0 +1,4 @@
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
+6
View File
@@ -0,0 +1,6 @@
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
+3
View File
@@ -0,0 +1,3 @@
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+8
View File
@@ -0,0 +1,8 @@
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
+5
View File
@@ -0,0 +1,5 @@
- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。
- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。
- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。
- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。
- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。
+5
View File
@@ -0,0 +1,5 @@
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
+3
View File
@@ -0,0 +1,3 @@
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
+9
View File
@@ -0,0 +1,9 @@
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
+7
View File
@@ -0,0 +1,7 @@
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
+6
View File
@@ -0,0 +1,6 @@
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
+8
View File
@@ -0,0 +1,8 @@
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
+13
View File
@@ -0,0 +1,13 @@
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
+9
View File
@@ -0,0 +1,9 @@
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
+3 -2
View File
@@ -44,9 +44,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
bash tests/install_test.sh
bash tests/dkim_sync_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
+90 -2
View File
@@ -31,9 +31,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
bash tests/install_test.sh
bash tests/dkim_sync_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -240,6 +241,7 @@ jobs:
cp generated-release-notes.md release-notes.md
- name: Create or update GitHub release
id: release_result
env:
GH_TOKEN: ${{ github.token }}
shell: bash
@@ -248,6 +250,92 @@ jobs:
title="NewSzxcn Email ${tag}"
if gh release view "${tag}" >/dev/null 2>&1; then
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
echo "created=false" >> "$GITHUB_OUTPUT"
else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
echo "created=true" >> "$GITHUB_OUTPUT"
fi
- name: Notify Telegram release channel
if: steps.release_result.outputs.created == 'true'
continue-on-error: true
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_URL: ${{ needs.release.outputs.release_url }}
shell: bash
run: |
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
echo "::notice::Telegram release notification is not configured; skipping."
exit 0
fi
python3 - <<'PY'
import html
import os
import re
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
entries = []
for raw in notes.splitlines():
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
line = re.sub(r"^[-*+]\s+", "", line)
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
line = re.sub(r"`([^`]+)`", r"\1", line)
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
if line:
entries.append(line)
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} · {html.escape(title)}</b>"
if description:
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
sections.append(section)
tag = os.environ["RELEASE_TAG"]
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
for section in sections:
added = len(section) + (2 if visible_sections else 0)
if used + added > available:
break
visible_sections.append(section)
used += added
body = "\n\n".join(visible_sections)
if len(visible_sections) < len(sections):
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
PY
jq -n \
--arg chat_id "${TELEGRAM_CHAT_ID}" \
--rawfile text telegram-release-message.txt \
'{
chat_id:$chat_id,
text:$text,
parse_mode:"HTML",
disable_web_page_preview:true
}' > telegram-release-payload.json
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
-o telegram-release-response.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
--data-binary @telegram-release-payload.json \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
exit 1
fi
echo "::notice::Telegram release notification sent."
+59 -4
View File
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能
@@ -35,8 +35,60 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员
邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
### 管理面板
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:尚未安装
--------------------------------------------------
1. 一键安装 NewSzxcn Email
0. 退出
==================================================
请选择 [1]
```
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
```text
==================================================
NewSzxcn Email 管理面板
==================================================
状态:运行中
版本:v1.2.19(示例,以实际安装版本为准)
地址:https://mail.example.com
--------------------------------------------------
安装与维护
1. 重新安装(完整备份,失败自动恢复)
2. 更新系统(自动备份,失败自动回滚)
3. 检查并修复现有安装
服务管理
4. 查看运行状态
5. 重启服务
6. 查看实时日志
证书与恢复
7. 管理 SSL 证书
8. 回滚到上次更新前版本
账号与帮助
9. 邮箱后台配置指南
10. 查看管理员登录信息
11. 重置管理员登录密码
危险操作
12. 卸载服务(保留数据)
0. 退出
==================================================
请选择 [2]
```
容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
@@ -44,7 +96,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
- 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 分开确认邮件服务器域名和邮箱地址域名创建唯一管理员邮箱默认 `admin@邮箱地址域名`回车自动生成 12 位密码,自定义密码最少 6 位
- 自动检测并确认邮箱地址域名创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
@@ -87,6 +139,7 @@ sudo ns
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email repair
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
@@ -178,6 +231,8 @@ pnpm install
pnpm run dev
```
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
提交前建议运行:
```bash
+1 -1
View File
@@ -1 +1 @@
1.2.15
1.2.30
+148 -56
View File
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users")
return
}
@@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB int `json:"storageQuotaMb"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
storageQuotaMB := req.StorageQuotaMB
if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if storageQuotaMB == 0 {
storageQuotaMB = defaultUserStorageQuotaMB
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
@@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
var primaryDomainID string
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
badRequest(w, err)
return
}
} else if !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "failed to load account domain")
return
}
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
@@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB *int `json:"storageQuotaMb"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
var storageQuotaMB int
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
return
}
if req.StorageQuotaMB != nil {
storageQuotaMB = *req.StorageQuotaMB
}
if storageQuotaMB < 100 {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
@@ -311,8 +344,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用"))
return
@@ -409,11 +442,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if a.isDefaultAdminUser(target) {
badRequest(w, errors.New("default administrator cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
} else if target.Role == "admin" {
badRequest(w, errors.New("administrator accounts cannot be deleted"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
@@ -553,6 +583,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -563,7 +594,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
@@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
if role == "" {
role = "user"
}
if role != "user" && role != "admin" {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
userID := strings.TrimSpace(req.UserID)
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
@@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
userID := strings.TrimSpace(req.UserID)
displayName := req.DisplayName
if displayName == "" {
displayName = address
}
var disabled, ownerStorageQuotaMB int
var passwordHash, ownerRole string
if userID != "" {
var disabled int
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
return
}
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
} else {
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
} else {
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
@@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if hashErr != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
passwordHash = string(hash)
ownerRole = "user"
ownerStorageQuotaMB = defaultUserStorageQuotaMB
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
@@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
return
}
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
m, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -699,8 +726,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName is required"))
return
}
if req.QuotaMB <= 0 {
req.QuotaMB = 1024
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
status := strings.TrimSpace(req.Status)
if status == "" {
@@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid status"))
return
}
existingMailbox, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if existingMailbox.Primary && status != existingMailbox.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
userID := strings.TrimSpace(req.UserID)
if userID == "" {
badRequest(w, errors.New("userId is required"))
return
}
if existingMailbox.Primary && userID != existingMailbox.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
var disabled int
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("owner user is disabled"))
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if ownerRole == "admin" {
req.QuotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -749,6 +794,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -1118,15 +1171,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
}
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=?
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
var item AdminUser
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err
}
item.Disabled = intBool(disabled)
@@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
return nil, err
}
m.CreatedAt = parseTime(created)
if err := a.markMailboxPrimary(ctx, &m); err != nil {
return nil, err
}
return &m, nil
}
func markPrimaryMailboxes(items []Mailbox) {
primaryByUser := make(map[string]int)
for i := range items {
candidate, ok := primaryByUser[items[i].UserID]
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
primaryByUser[items[i].UserID] = i
}
}
for _, index := range primaryByUser {
items[index].Primary = true
}
}
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
var primaryID string
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE mb.user_id=?
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
if err != nil {
return err
}
mailbox.Primary = mailbox.ID == primaryID
return nil
}
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
mailbox, err := a.mailboxByID(ctx, id)
if err != nil {
return err
}
if mailbox.Primary {
return errors.New("用户默认邮箱不能删除")
}
return nil
}
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
var m Mailbox
+246 -18
View File
@@ -23,19 +23,29 @@ import (
)
type App struct {
cfg Config
cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
turnstileURL string
cfg Config
cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
turnstileURL string
telegramURL string
telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex
}
const (
defaultUserStorageQuotaMB = 100
defaultAdminStorageQuotaMB = 1024
minimumStorageQuotaMB = 100
)
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
@@ -71,7 +81,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
}
db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
@@ -93,6 +103,18 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
db.Close()
return nil, err
@@ -107,6 +129,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
return a, nil
}
@@ -155,6 +178,7 @@ func (a *App) migrate(ctx context.Context) error {
two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER,
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -304,6 +328,7 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'folder',
sort_order INTEGER NOT NULL DEFAULT 0,
uid_validity INTEGER NOT NULL DEFAULT 0,
uid_next INTEGER NOT NULL DEFAULT 1,
@@ -433,6 +458,20 @@ func (a *App) migrate(ctx context.Context) error {
)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
`CREATE TABLE IF NOT EXISTS telegram_mail_outbox (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL UNIQUE,
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
delivered_at TEXT,
lease_until TEXT NOT NULL DEFAULT '',
telegram_message_id INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
AFTER DELETE ON mailboxes BEGIN
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
@@ -645,6 +684,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err
}
if err := a.migrateUserStorageQuota(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
@@ -663,6 +705,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateFolderSortOrder(ctx); err != nil {
return err
}
if err := a.migrateFolderIcons(ctx); err != nil {
return err
}
if err := a.migrateExternalIMAP(ctx); err != nil {
return err
}
@@ -675,12 +720,117 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateAPITokenScopes(ctx); err != nil {
return err
}
if err := a.migrateTelegramNotifications(ctx); err != nil {
return err
}
if err := a.migrateDefaultMailLabels(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
return nil
}
func (a *App) migrateTelegramNotifications(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "lease_until", `ALTER TABLE telegram_mail_outbox ADD COLUMN lease_until TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "telegram_message_id", `ALTER TABLE telegram_mail_outbox ADD COLUMN telegram_message_id INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
return nil
}
func (a *App) migrateDefaultMailLabels(ctx context.Context) error {
const marker = "defaultMailLabelsInitialized"
var initialized int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil {
return err
}
if initialized > 0 {
return nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`)
if err != nil {
return err
}
var mailboxIDs []string
for rows.Next() {
var mailboxID string
if err := rows.Scan(&mailboxID); err != nil {
rows.Close()
return err
}
mailboxIDs = append(mailboxIDs, mailboxID)
}
if err := rows.Close(); err != nil {
return err
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
for _, mailboxID := range mailboxIDs {
if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil {
return err
}
return tx.Commit()
}
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
var mailboxSettingExists int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramMailboxIds'`).Scan(&mailboxSettingExists); err != nil {
return err
}
if mailboxSettingExists == 0 {
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM mailboxes m JOIN users u ON u.id=m.user_id WHERE u.role='admin' AND m.status='active' ORDER BY m.address`)
if err != nil {
return err
}
var mailboxIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
mailboxIDs = append(mailboxIDs, id)
}
if err := rows.Close(); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramMailboxIds',?,?)`, strings.Join(mailboxIDs, ","), now); err != nil {
return err
}
}
var includeSettingExists int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramIncludeUnregistered'`).Scan(&includeSettingExists); err != nil {
return err
}
if includeSettingExists == 0 {
var enabled string
_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='telegramMailEnabled'`).Scan(&enabled)
includeUnregistered := "false"
if strings.EqualFold(enabled, "true") {
includeUnregistered = "true"
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramIncludeUnregistered',?,?)`, includeUnregistered, now); err != nil {
return err
}
}
return nil
}
func (a *App) migrateForwardingVerification(ctx context.Context) error {
columns := []struct {
name string
@@ -1199,6 +1349,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
return err
}
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
return err
}
hasColumn := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "storage_quota_mb" {
hasColumn = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasColumn {
return nil
}
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
return err
}
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
@@ -1377,8 +1560,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1399,7 +1582,7 @@ func (a *App) seed(ctx context.Context) error {
}
// Create mailbox for admin
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
if err != nil {
return err
}
@@ -1520,7 +1703,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
if !errors.Is(err, sql.ErrNoRows) {
return err
}
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
if err != nil {
return err
}
@@ -1682,6 +1865,30 @@ func defaultFolderDefs() []struct{ name, role string } {
}
}
type defaultMailLabel struct {
name string
color string
}
func defaultMailLabelDefs() []defaultMailLabel {
return []defaultMailLabel{
{name: "个人", color: "#10b981"},
{name: "家人", color: "#ec4899"},
{name: "朋友", color: "#06b6d4"},
{name: "工作", color: "#3b82f6"},
{name: "重要", color: "#f59e0b"},
}
}
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
for _, label := range defaultMailLabelDefs() {
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
return err
}
}
return nil
}
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -1711,8 +1918,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
if localPart == "" {
return "", errors.New("invalid local part")
}
if quotaMB <= 0 {
quotaMB = 1024
if quotaMB < 0 {
return "", errors.New("quotaMb must be zero or greater")
}
var ownerRole string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
return "", err
}
if ownerRole == "admin" {
quotaMB = 0
}
if status == "" {
status = "active"
@@ -1739,9 +1953,23 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
return "", err
}
}
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
return "", err
}
return id, nil
}
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
SET quota_mb=0, updated_at=?
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
return err
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
+583 -17
View File
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox
}
func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID)
if err != nil {
t.Fatal(err)
}
if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB {
t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB)
}
domainID := mustDefaultDomainID(t, a)
var secondary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "admin-secondary",
"userId": adminUser.ID,
}, &secondary); code != http.StatusCreated {
t.Fatalf("create admin secondary mailbox code=%d", code)
}
if secondary.QuotaMB != 0 {
t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB)
}
var primaryHash, secondaryHash string
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil {
t.Fatal(err)
}
if primaryHash != secondaryHash {
t.Fatal("admin secondary mailbox did not inherit the owner password")
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "owner@lanqin.local",
"displayName": "Owner",
"password": "OwnerPassword123!",
"role": "user",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular owner code=%d", code)
}
if regular.MailboxCount != 1 {
t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount)
}
if regular.StorageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB)
}
if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil {
t.Fatalf("new account primary mailbox missing: %v", err)
}
var primaryStatusErr map[string]any
primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{
"userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled",
}, &primaryStatusErr); code != http.StatusBadRequest {
t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr)
}
var regularMailbox Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": domainID,
"localPart": "owner-secondary",
"userId": regular.ID,
}, &regularMailbox); code != http.StatusCreated {
t.Fatalf("create regular secondary mailbox code=%d", code)
}
if regularMailbox.QuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB)
}
var userHash, mailboxHash string
if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil {
t.Fatal(err)
}
if userHash != mailboxHash {
t.Fatal("regular secondary mailbox did not inherit the owner password")
}
}
func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
var errBody map[string]any
if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator account delete code=%d body=%v", code, errBody)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody)
}
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99,
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody)
}
var updatedAdmin AdminUser
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100,
}, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 {
t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin)
}
var regular AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!",
}, &regular); code != http.StatusCreated {
t.Fatalf("create regular user code=%d user=%+v", code, regular)
}
primary, err := a.mailboxByAddress(context.Background(), regular.Email)
if err != nil {
t.Fatal(err)
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody)
}
secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID})
if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK {
t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody)
}
}
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
return createTestAPITokenWithScopes(t, client, name, nil)
}
@@ -459,6 +600,12 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
"externalImapGmailClientSecret": "",
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": "",
"telegramMailEnabled": settings.TelegramMailEnabled,
"telegramBotToken": "",
"telegramPrivateChatId": settings.TelegramPrivateChatID,
"telegramBodyMode": settings.TelegramBodyMode,
"telegramMailboxIds": settings.TelegramMailboxIDs,
"telegramIncludeUnregistered": settings.TelegramIncludeUnregistered,
}
}
@@ -543,16 +690,26 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
var labels struct {
Items []MailLabel `json:"items"`
}
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 {
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != len(defaultMailLabelDefs()) {
t.Fatalf("labels code=%d items=%+v", code, labels.Items)
}
var importantLabel MailLabel
for _, label := range labels.Items {
if label.Name == "重要" {
importantLabel = label
break
}
}
if importantLabel.ID == "" || importantLabel.MessageCount != 1 {
t.Fatalf("important label missing or count is wrong: %+v", labels.Items)
}
var labeled struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+importantLabel.ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+importantLabel.ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels)
}
var starred struct {
@@ -1034,6 +1191,191 @@ func TestMailRulesForwardingAction(t *testing.T) {
}
}
func TestMailRulesExactSenderCustomFolderAndStopProcessing(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-exact-sender", "Sender With Name", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil)
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var bad map[string]any
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}},
"actions": []map[string]string{{"type": "archive"}},
}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad)
}
createRule := func(name string, action map[string]string, stop bool) {
t.Helper()
var rule MailRule
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"name": name,
"conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}},
"actions": []map[string]string{action},
"stopProcessing": stop,
}, &rule); code != http.StatusCreated {
t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule)
}
}
createRule("fallback archive", map[string]string{"type": "archive"}, false)
createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true)
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "Netflix code", "text": "123456"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d sent=%+v", code, sent)
}
var custom struct {
Items []MailMessage `json:"items"`
}
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 {
t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items)
}
var archived struct {
Items []MailMessage `json:"items"`
}
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items)
}
var icon string
if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" {
t.Fatalf("rule-created folder icon=%q err=%v", icon, err)
}
}
func TestFolderIconForName(t *testing.T) {
tests := []struct {
name string
requested string
want string
}{
{name: "Netflix 验证码", requested: "auto", want: "netflix"},
{name: "ChatGPT 通知", want: "chatgpt"},
{name: "OpenAI 账单", want: "chatgpt"},
{name: "项目归档", want: "briefcase"},
{name: "其他", want: "folder"},
{name: "Netflix", requested: "heart", want: "heart"},
{name: "Netflix", requested: "unknown", want: "folder"},
{name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},
{name: "Egypt archive", want: "folder"},
{name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"},
{name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"},
}
for _, tt := range tests {
t.Run(tt.name+"/"+tt.requested, func(t *testing.T) {
if got := folderIconForName(tt.name, tt.requested); got != tt.want {
t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want)
}
})
}
}
func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
admin := &testClient{t: t, server: httptest.NewServer(a.Router())}
defer admin.server.Close()
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := createTestDomain(t, admin, "manual-icon.test")
mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil)
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil {
t.Fatalf("create custom folder: %v", err)
}
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil {
t.Fatalf("reuse custom folder: %v", err)
}
var icon string
if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" {
t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err)
}
}
func TestMailRuleApplyExistingWhenDisabledExcludesSent(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-existing-sender", "Existing Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil)
subject := "same inbound and sent subject"
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var incomingSend MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated {
t.Fatalf("incoming send code=%d", code)
}
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var outgoing MailMessage
if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated {
t.Fatalf("outgoing send code=%d", code)
}
var rule MailRule
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
"mailboxId": recipient.ID,
"name": "existing disabled",
"conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}},
"actions": []map[string]string{{"type": "star"}},
"applyToExisting": true,
"enabled": false,
}, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled {
t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule)
}
var inboundStarred, sentStarred int
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil {
t.Fatal(err)
}
if inboundStarred != 1 || sentStarred != 0 {
t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred)
}
}
func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) {
msg := ruleMessage{AttachmentNames: "notes.txt"}
if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) {
t.Fatal("attachment condition must not match MIME type or unrelated extension")
}
if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) {
t.Fatal("attachment condition should match filename")
}
}
func TestMailRulesMailboxIsolation(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1373,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User)
}
var storageQuotaMB int
if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil {
t.Fatal(err)
}
if storageQuotaMB != defaultUserStorageQuotaMB {
t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB)
}
var me struct {
User User `json:"user"`
}
@@ -2014,6 +2363,16 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
insertMessage("msg_multi_primary_read", primary.ID, primaryInboxID, "primary read", 1)
insertMessage("msg_multi_primary_archived", primary.ID, primaryArchiveID, "primary archived unread", 0)
insertMessage("msg_multi_secondary_unread", secondary.ID, secondaryInboxID, "secondary unread", 0)
var primaryImportantID, secondaryImportantID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, primary.ID).Scan(&primaryImportantID); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, secondary.ID).Scan(&secondaryImportantID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?),(?,?,?)`, "msg_multi_primary_unread_1", primaryImportantID, now, "msg_multi_secondary_unread", secondaryImportantID, now); err != nil {
t.Fatal(err)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
@@ -2025,6 +2384,28 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
}
var allLabels struct {
Items []MailLabel `json:"items"`
}
if code := userClient.do("GET", "/api/mail/labels?mailboxId=all", nil, &allLabels); code != http.StatusOK || len(allLabels.Items) != len(defaultMailLabelDefs()) {
t.Fatalf("all labels code=%d items=%+v", code, allLabels.Items)
}
var allImportant MailLabel
for _, label := range allLabels.Items {
if label.Name == "重要" {
allImportant = label
break
}
}
if allImportant.ID == "" || allImportant.MailboxID != "" || allImportant.MessageCount != 2 {
t.Fatalf("aggregated important label=%+v", allImportant)
}
var importantMessages struct {
Items []MailMessage `json:"items"`
}
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&labelId="+url.QueryEscape(allImportant.ID), nil, &importantMessages); code != http.StatusOK || len(importantMessages.Items) != 2 {
t.Fatalf("all important messages code=%d items=%+v", code, importantMessages.Items)
}
unreadByAddress := map[string]int{}
for _, item := range mine.Items {
unreadByAddress[item.Address] = item.UnreadCount
@@ -2245,7 +2626,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
}
var custom MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" {
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
}
var folders struct {
@@ -2254,6 +2635,15 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
}
foundIcon := ""
for _, folder := range folders.Items {
if folder.Name == "客户归档" {
foundIcon = folder.Icon
}
}
if foundIcon != "netflix" {
t.Fatalf("folder icon=%q, want netflix", foundIcon)
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
@@ -2565,6 +2955,25 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
}
}
func TestForwardingVerificationPageDoesNotLinkToMailbox(t *testing.T) {
a := newTestApp(t)
recorder := httptest.NewRecorder()
a.renderForwardingVerificationPage(recorder, http.StatusOK, true, "friend@example.test", "该邮箱已通过转发验证")
body := recorder.Body.String()
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
for _, forbidden := range []string{`href="/"`, "返回邮箱", "登录"} {
if strings.Contains(body, forbidden) {
t.Fatalf("verification page contains forbidden navigation %q: %s", forbidden, body)
}
}
if !strings.Contains(body, "可以关闭此页面") {
t.Fatalf("verification page is missing close guidance: %s", body)
}
}
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
@@ -2708,8 +3117,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
}
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
}
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
@@ -2718,8 +3139,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
t.Fatal(err)
}
if queueCount != 2 {
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
if queueCount != 3 {
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
}
}
@@ -2904,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
}
var owner AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!",
}, &owner); code != http.StatusCreated {
t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner)
}
var mailbox Mailbox
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
"domainId": domain.ID,
"localPart": "api-user",
"displayName": "API User",
"password": "Password123!",
"password": "DifferentPassword123!",
"quotaMb": 256,
"userId": owner.ID,
}, &mailbox); code != http.StatusCreated {
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
}
ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email)
if err != nil {
t.Fatal(err)
}
var protectedMailboxErr map[string]any
if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest {
t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr)
}
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
t.Fatalf("mailbox=%+v", mailbox)
}
var ownerPasswordHash, mailboxPasswordHash string
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil {
t.Fatal(err)
}
if mailboxPasswordHash != ownerPasswordHash {
t.Fatal("open api mailbox did not inherit the owner password")
}
var mailboxes struct {
Items []Mailbox `json:"items"`
}
@@ -2934,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
t.Fatalf("updated mailbox=%+v", updated)
}
if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 {
t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated)
}
var ok map[string]any
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
@@ -4432,6 +4881,49 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
}
}
func TestSubmissionLoginAuthenticationWithAndWithoutInitialResponse(t *testing.T) {
a := newTestApp(t)
for _, withInitialResponse := range []bool{false, true} {
t.Run(fmt.Sprintf("initial-response-%t", withInitialResponse), func(t *testing.T) {
session := &submissionSession{app: a}
if mechanisms := strings.Join(session.AuthMechanisms(), " "); mechanisms != "PLAIN LOGIN" {
t.Fatalf("submission auth mechanisms=%q", mechanisms)
}
server, err := session.Auth(sasl.Login)
if err != nil {
t.Fatal(err)
}
var response []byte
if withInitialResponse {
response = []byte("admin@lanqin.local")
}
challenge, done, err := server.Next(response)
if err != nil || done {
t.Fatalf("initial LOGIN response err=%v done=%t", err, done)
}
if !withInitialResponse {
if string(challenge) != "Username:" {
t.Fatalf("username challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("admin@lanqin.local"))
if err != nil || done {
t.Fatalf("username response err=%v done=%t", err, done)
}
}
if string(challenge) != "Password:" {
t.Fatalf("password challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("ChangeMe123!"))
if err != nil || !done || challenge != nil {
t.Fatalf("password response challenge=%q err=%v done=%t", challenge, err, done)
}
if session.user == nil || session.mailbox == nil {
t.Fatal("LOGIN authentication did not populate submission session")
}
})
}
}
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
@@ -4469,7 +4961,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil {
t.Fatal(err)
}
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
@@ -4754,6 +5246,72 @@ func TestDNSRecords(t *testing.T) {
}
}
func TestDefaultMailLabelsBackfillOrderAndDeletion(t *testing.T) {
a := newTestApp(t)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`DELETE FROM system_settings WHERE key='defaultMailLabelsInitialized'`); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
t.Fatal(err)
}
labels, err := a.labelsForMailbox(context.Background(), mailboxID)
if err != nil {
t.Fatal(err)
}
defaults := defaultMailLabelDefs()
if len(labels) != len(defaults) {
t.Fatalf("labels=%+v", labels)
}
for index, expected := range defaults {
if labels[index].Name != expected.name || labels[index].Color != expected.color {
t.Fatalf("label %d=%+v want name=%q color=%q", index, labels[index], expected.name, expected.color)
}
}
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE id=?`, labels[1].ID); err != nil {
t.Fatal(err)
}
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
t.Fatal(err)
}
labels, err = a.labelsForMailbox(context.Background(), mailboxID)
if err != nil {
t.Fatal(err)
}
if len(labels) != len(defaults)-1 {
t.Fatalf("deleted default label was restored: %+v", labels)
}
}
func TestCheckDKIMRecordRequiresMatchingPublicKey(t *testing.T) {
tests := []struct {
name string
records []string
key string
ok bool
message string
}{
{name: "matching", records: []string{"v=DKIM1; k=rsa; p=ABC123"}, key: "ABC123", ok: true, message: "DKIM 公钥匹配"},
{name: "split whitespace", records: []string{"v=DKIM1; k=rsa; p=ABC 123\n456"}, key: "ABC123456", ok: true, message: "DKIM 公钥匹配"},
{name: "wrong key", records: []string{"v=DKIM1; k=rsa; p=WRONG"}, key: "EXPECTED", ok: false, message: "DKIM 公钥与后台生成的记录不一致"},
{name: "unrelated TXT", records: []string{"google-site-verification=token"}, key: "EXPECTED", ok: false, message: "未找到 DKIM 记录"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status := checkDKIMRecord(tt.records, tt.key)
if status.OK != tt.ok || status.Message != tt.message {
t.Fatalf("status=%+v", status)
}
})
}
}
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -4813,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
}
var regularUpdateErr map[string]any
var updatedRegular PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Changed Regular",
"description": "Should not change",
"permissions": []string{PermissionAdminOverview},
}, &regularUpdateErr); code != http.StatusForbidden {
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
"permissions": regularUserDefaultPermissions(),
"limits": defaultPermissionLimits(),
}, &updatedRegular); code != http.StatusOK {
t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular)
}
if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" {
t.Fatalf("regular system permission group identity changed: %+v", updatedRegular)
}
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
if err != nil {
t.Fatal(err)
}
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -5975,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
ctx := context.Background()
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil {
t.Fatal(err)
}
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
@@ -6051,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats)
}
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
var aliceStorageQuotaMB int64
if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil {
t.Fatal(err)
}
if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats)
}
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
+3 -3
View File
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
+12
View File
@@ -52,6 +52,12 @@ type Config struct {
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
TelegramMailEnabled bool
TelegramBotToken string
TelegramPrivateChatID string
TelegramBodyMode string
TelegramMailboxIDs string
TelegramIncludeUnregistered bool
MailTranslateEnabled bool
MailTranslateMaxChars int
DeliveryWebhookSecret string
@@ -110,6 +116,12 @@ func LoadConfig() Config {
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false),
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")),
TelegramMailboxIDs: getenv("LANQIN_TELEGRAM_MAILBOX_IDS", ""),
TelegramIncludeUnregistered: getenvBool("LANQIN_TELEGRAM_INCLUDE_UNREGISTERED", false),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
+37 -1
View File
@@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
dkimName := d.DKIMSelector + "._domainkey." + d.Name
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录")
checks["dkim"] = checkDKIMRecord(dkimTXT, d.DKIMPublicKey)
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
@@ -85,6 +85,42 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks}
}
func checkDKIMRecord(records []string, expectedPublicKey string) DNSCheckStatus {
found := append([]string{}, records...)
expectedPublicKey = compactDKIMPublicKey(expectedPublicKey)
dkimFound := false
for _, record := range records {
tags := map[string]string{}
for _, part := range strings.Split(record, ";") {
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
if !strings.EqualFold(tags["v"], "DKIM1") {
continue
}
dkimFound = true
if expectedPublicKey != "" && compactDKIMPublicKey(tags["p"]) == expectedPublicKey {
return DNSCheckStatus{OK: true, Message: "DKIM 公钥匹配", Found: found}
}
}
if dkimFound {
return DNSCheckStatus{OK: false, Message: "DKIM 公钥与后台生成的记录不一致", Found: found}
}
return DNSCheckStatus{OK: false, Message: "未找到 DKIM 记录", Found: found}
}
func compactDKIMPublicKey(value string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
}
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
found := append([]string{}, records...)
for _, item := range records {
+8 -2
View File
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
}
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
}
imported++
} else {
skipped++
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
}
type externalIMAPFolderState struct {
LastUID uint32
LastUID uint32
Initialized bool
}
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
var state externalIMAPFolderState
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
state.Initialized = true
}
return state
}
+1 -4
View File
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
if err != nil {
return nil, "", "", err
}
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
if len(targets) == 0 {
return nil, userID, mailboxAddress, nil
}
+8 -4
View File
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
return
}
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
}
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
title := "邮箱转发验证"
heading := "验证失败"
color := "#dc2626"
statusMark := "!"
closingMessage := "请联系验证发起人重新发送链接"
if ok {
heading = "验证完成"
color = "#2563eb"
color = "#16a34a"
statusMark = "&#10003;"
closingMessage = "验证结果已记录,可以关闭此页面"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
title, heading, htmlEscape(message), htmlEscape(email), color)
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:8px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><div aria-hidden="true" style="display:grid;place-items:center;width:44px;height:44px;margin:0 0 20px;border-radius:50%%;background:%s;color:white;font-size:24px;font-weight:700">%s</div><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 24px;font-size:15px;color:#64748b;word-break:break-all">%s</p><p style="margin:0;padding-top:20px;border-top:1px solid #e2e8f0;font-size:15px;color:#64748b">%s</p></section></main></body></html>`,
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
}
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
+4
View File
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
return nil
}
func (a *App) migrateFolderIcons(ctx context.Context) error {
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
+132 -23
View File
@@ -1,12 +1,14 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image/png"
"io"
"net/http"
"net/textproto"
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -266,6 +269,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -280,6 +284,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("system folder already exists"))
return
}
icon := folderIconForName(name, req.Icon)
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
@@ -308,12 +313,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
return
}
for _, mailboxID := range mailboxIDs {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
}
}
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
return
}
mb, err := a.mailboxForCurrentUser(r)
@@ -321,7 +326,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
@@ -527,8 +532,88 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request,
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
}
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
return a.ensureFolder(ctx, mailboxID, name)
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
var existingID string
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
return existingID, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
id, err := a.ensureFolder(ctx, mailboxID, name)
if err != nil {
return "", err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
return id, err
}
func folderIconForName(name, requested string) string {
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
return normalizeFolderIcon(icon)
}
value := strings.ToLower(strings.TrimSpace(name))
for _, match := range []struct {
icon string
terms []string
}{
{"netflix", []string{"netflix", "奈飞", "网飞"}},
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
{"star", []string{"重要", "紧急", "important", "urgent"}},
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
{"bell", []string{"提醒", "通知", "remind", "notification"}},
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
} {
for _, term := range match.terms {
if folderNameContainsTerm(value, term) {
return match.icon
}
}
}
return "folder"
}
func folderNameContainsTerm(value, term string) bool {
if term != "gpt" {
return strings.Contains(value, term)
}
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == term {
return true
}
}
return false
}
func normalizeFolderIcon(raw string) string {
icon := strings.TrimSpace(raw)
const customPrefix = "data:image/png;base64,"
if strings.HasPrefix(icon, customPrefix) {
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
config, configErr := png.DecodeConfig(bytes.NewReader(data))
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
return icon
}
return "folder"
}
icon = strings.ToLower(icon)
switch icon {
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
return icon
default:
return "folder"
}
}
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
@@ -546,11 +631,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
if !ok {
respondError(w, http.StatusNotFound, "label not found")
return
}
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
return
}
folder := r.URL.Query().Get("folder")
@@ -1131,6 +1217,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
continue
}
@@ -1144,6 +1231,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
}
}
continue
@@ -1155,12 +1243,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg := base
copyMsg.MailboxID = rcptMailbox.ID
copyMsg.FolderID = inboxID
copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
}
}
}
@@ -2225,14 +2317,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
var f MailFolder
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
return nil, err
}
return &f, nil
@@ -2528,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
return nil
}
var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); err != nil {
var userID string
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&quotaMB, &userID); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
@@ -2599,7 +2692,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE l.mailbox_id=?
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)`, mailboxID)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID)
if err != nil {
return nil, err
}
@@ -2616,13 +2709,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
}
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
FROM mail_labels l
JOIN mailboxes mb ON mb.id=l.mailbox_id
LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE mb.user_id=? AND mb.status='active'
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)`, userID)
GROUP BY lower(l.name)
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
if err != nil {
return nil, err
}
@@ -2642,7 +2735,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
WHERE ml.message_id=?
ORDER BY lower(l.name)`, messageID)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID)
if err != nil {
return nil, err
}
@@ -2673,7 +2766,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
ORDER BY lower(l.name)`, args...)
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
if err != nil {
return err
}
@@ -2691,6 +2784,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
return rows.Err()
}
func mailLabelOrderSQL(alias string) string {
return mailLabelNameOrderSQL(alias + `.name`)
}
func mailLabelNameOrderSQL(expression string) string {
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
}
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
name = normalizeLabelName(name)
if name == "" {
@@ -2734,6 +2835,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
return count > 0
}
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
var name string
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
return "", false
}
return name, true
}
func normalizeLabelName(name string) string {
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
if len([]rune(name)) > 32 {
@@ -122,8 +122,17 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
args = append(args, labelID)
if isAllMailboxID(mailboxID) {
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
if !ok {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))")
args = append(args, labelName)
} else {
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
args = append(args, labelID)
}
default:
return nil, errors.New("unsupported mail view")
}
+7 -4
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
t.Fatalf("delete final mailbox code=%d", code)
if !mailboxes.Items[0].Primary {
t.Fatal("administrator mailbox should be marked as primary")
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("delete primary mailbox code=%d", code)
}
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
}
var me map[string]any
+46 -1
View File
@@ -292,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
_, err = a.insertMessage(ctx, msg, attachments)
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
return err == nil, err
}
@@ -333,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
if strings.TrimSpace(msg.RecipientAddr) == "" {
msg.RecipientAddr = mb.Address
}
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path
if msg.MessageUID == "" {
@@ -367,6 +373,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
a.processInboundForwarding(ctx, id, mb.ID, raw)
if a.shouldNotifyTelegramMessage(ctx, id) {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
}
return err == nil, err
}
@@ -585,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
return address
}
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
address = normalizeEmail(address)
if strings.HasSuffix(address, "@"+domain) {
@@ -609,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if len(to) == 0 {
to = []string{fallbackTo}
}
recipientAddr := originalMailRecipient(m.Header)
sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err
}
if looksLikeHTMLDocument(parsed.Text) {
if strings.TrimSpace(parsed.HTML) == "" {
parsed.HTML = parsed.Text
}
parsed.Text = telegramHTMLToText(parsed.Text)
}
bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" {
@@ -629,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
RecipientAddr: recipientAddr,
Subject: subject,
From: from,
FromName: fromName,
@@ -644,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
}, parsed.Attachments, nil
}
func originalMailRecipient(header netmail.Header) string {
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
value := strings.TrimSpace(header.Get(key))
if key == "Original-Recipient" {
if _, suffix, ok := strings.Cut(value, ";"); ok {
value = strings.TrimSpace(suffix)
}
}
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
return address
}
}
return ""
}
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
@@ -682,6 +717,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil
}
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
if converted, readErr := io.ReadAll(reader); readErr == nil {
decoded = converted
}
}
}
decoded = []byte(strings.ToValidUTF8(string(decoded), ""))
}
switch strings.ToLower(mediaType) {
case "text/html":
if parsed.HTML == "" {
+51 -6
View File
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
respondMailboxOwnerError(w, err)
return
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
var ownerPasswordHash, ownerRole string
var ownerStorageQuotaMB int
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
displayName = current.DisplayName
}
quotaMB := req.QuotaMB
if quotaMB <= 0 {
if quotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
if quotaMB == 0 {
quotaMB = current.QuotaMB
}
status := strings.TrimSpace(req.Status)
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
if userID == "" {
userID = current.UserID
}
if current.Primary && userID != current.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
if current.Primary && status != current.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
respondMailboxOwnerError(w, err)
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
if ownerRole == "admin" {
quotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
if displayName == "" {
displayName = email
}
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
return userID, err
}
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
var existingName, existingDescription string
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if intBool(existingSystem) {
if intBool(existingSystem) && id != PermissionGroupRegular {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return
}
name := strings.TrimSpace(req.Name)
if id == PermissionGroupRegular {
name = existingName
req.Description = existingDescription
}
if name == "" {
badRequest(w, errors.New("name is required"))
return
+64 -59
View File
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
quotaMB := defaultUserStorageQuotaMB
if user.Role == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
return
}
appliedCount := int64(0)
if req.ApplyToExisting && enabled {
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
if req.ApplyToExisting {
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
return
}
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
item, err := scanRule(row)
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return
}
if mailboxID != "" && !isAllMailboxID(mailboxID) {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
if !ruleMatches(rule, msg) {
continue
}
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
continue
}
if rule.StopProcessing {
break
}
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
parts = append(parts, filename)
}
return strings.Join(parts, " ")
}
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
if operator == "" {
operator = "contains"
}
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
if !validRuleConditionOperator(field, operator) {
return MailRuleCondition{}, false
}
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func validRuleConditionOperator(field, operator string) bool {
switch field {
case "size":
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
case "date":
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
default:
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
}
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
for _, action := range normalizeRuleActions(actions, "") {
switch action.Type {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "move":
target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
target, err := normalizeFolderNameForUser(action.Value)
if err != nil {
return err
}
var folderID string
if isSystemFolderName(target) {
folderID, err = a.ensureFolder(ctx, mailboxID, target)
} else {
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
}
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "star":
starred := true
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
return err
}
func ruleTargetFolder(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "inbox":
return "Inbox"
case "archive":
return "Archive"
case "spam":
return "Spam"
case "trash":
return "Trash"
default:
return "Archive"
}
}
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
args := []any{userID}
where := `mb.user_id=?`
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
if err != nil {
return 0, err
}
+3
View File
@@ -171,6 +171,9 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
+69 -2
View File
@@ -40,6 +40,12 @@ type SystemSettings struct {
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type systemSettingsUpdate struct {
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotToken string `json:"telegramBotToken"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
}
type PublicSettings struct {
@@ -127,6 +139,8 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
var req systemSettingsUpdate
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -204,8 +218,32 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
next.TelegramMailEnabled = req.TelegramMailEnabled
if strings.TrimSpace(req.TelegramBotToken) != "" {
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
}
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
if next.TelegramMailEnabled {
if next.TelegramBotToken == "" {
badRequest(w, errors.New("Telegram Bot Token 未设置"))
return
}
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
return
}
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
return
}
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
previous := a.config()
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
@@ -318,6 +356,12 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
TelegramMailEnabled: cfg.TelegramMailEnabled,
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
}
}
@@ -402,6 +446,18 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
cfg.ExternalIMAPOutlookClientSecret = value
case "telegramMailEnabled":
cfg.TelegramMailEnabled = value == "true"
case "telegramBotToken":
cfg.TelegramBotToken = value
case "telegramPrivateChatId":
cfg.TelegramPrivateChatID = value
case "telegramBodyMode":
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
case "telegramMailboxIds":
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
case "telegramIncludeUnregistered":
cfg.TelegramIncludeUnregistered = value == "true"
}
}
if err := rows.Err(); err != nil {
@@ -411,7 +467,7 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
@@ -443,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
"telegramBotToken": cfg.TelegramBotToken,
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
@@ -456,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
return err
}
}
if clearPendingTelegram {
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
return err
}
}
return tx.Commit()
}
+44 -6
View File
@@ -121,21 +121,59 @@ type submissionSession struct {
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
return []string{sasl.Plain, sasl.Login}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
authenticate := func(username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
switch {
case strings.EqualFold(mech, sasl.Plain):
return sasl.NewPlainServer(func(_, username, password string) error {
return authenticate(username, password)
}), nil
case strings.EqualFold(mech, sasl.Login):
return &submissionLoginServer{authenticate: authenticate}, nil
default:
return nil, smtpserver.ErrAuthUnknownMechanism
}
}
type submissionLoginServer struct {
authenticate func(username, password string) error
username string
step int
}
func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) {
switch s.step {
case 0:
if response == nil {
s.step = 1
return []byte("Username:"), false, nil
}
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 1:
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 2:
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
s.step = 3
return nil, true, nil
default:
return nil, false, sasl.ErrUnexpectedClientResponse
}
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
+992
View File
@@ -0,0 +1,992 @@
package app
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
nethtml "golang.org/x/net/html"
)
const (
telegramMailMaxAttempts = 8
telegramMessageBudget = 3800
telegramPairingTTL = 10 * time.Minute
)
type telegramPairing struct {
TokenFingerprint string
ExpiresAt time.Time
}
type telegramMailPayload struct {
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
Body string `json:"body"`
BodyMode string `json:"bodyMode"`
OTP string `json:"otp,omitempty"`
AttachmentNames []string `json:"attachmentNames,omitempty"`
AttachmentCount int `json:"attachmentCount,omitempty"`
}
type telegramCredentialsRequest struct {
BotToken string `json:"botToken"`
ChatID string `json:"chatId"`
PairingCode string `json:"pairingCode"`
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *struct {
Text string `json:"text"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"chat"`
} `json:"message"`
}
type telegramAPIError struct {
HTTPStatus int
ErrorCode int
Description string
RetryAfter time.Duration
}
func (e *telegramAPIError) Error() string {
description := strings.TrimSpace(e.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", e.HTTPStatus)
}
return "Telegram 发送失败: " + description
}
type telegramSentMessage struct {
MessageID int64 `json:"message_id"`
}
type telegramFormattedMessage struct {
HTML string
PlainText string
OTP string
}
func normalizeTelegramBodyMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "full") {
return "full"
}
return "summary"
}
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
var bot struct {
Username string `json:"username"`
}
if err := a.callTelegram(r.Context(), token, "getMe", map[string]any{}, &bot); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
if strings.TrimSpace(bot.Username) == "" {
respondError(w, http.StatusBadGateway, "Telegram 机器人没有可用的用户名")
return
}
code, err := newTelegramPairingCode()
if err != nil {
respondError(w, http.StatusInternalServerError, "无法生成 Telegram 绑定码")
return
}
expiresAt := a.now().UTC().Add(telegramPairingTTL)
a.telegramPairMu.Lock()
for value, pairing := range a.telegramPairs {
if !pairing.ExpiresAt.After(a.now().UTC()) {
delete(a.telegramPairs, value)
}
}
a.telegramPairs[code] = telegramPairing{TokenFingerprint: telegramTokenFingerprint(token), ExpiresAt: expiresAt}
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{
"code": code,
"botUsername": bot.Username,
"deepLink": "https://t.me/" + url.PathEscape(bot.Username) + "?start=" + url.QueryEscape(code),
"expiresAt": expiresAt.Format(time.RFC3339Nano),
})
}
func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(a.config().TelegramBotToken)
}
code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
if token == "" || code == "" {
badRequest(w, errors.New("请先生成 Telegram 一次性绑定码"))
return
}
a.telegramPairMu.Lock()
pairing, ok := a.telegramPairs[code]
a.telegramPairMu.Unlock()
if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
badRequest(w, errors.New("Telegram 绑定码无效或已过期,请重新生成"))
return
}
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token, code)
if err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
a.telegramPairMu.Lock()
delete(a.telegramPairs, code)
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName})
}
func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) {
var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
token, chatID := a.telegramCredentials(req)
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
if !validTelegramPrivateChatID(chatID) {
badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID"))
return
}
now := a.now().Local().Format("2006-01-02 15:04:05 MST")
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html.EscapeString(now)
if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) {
cfg := a.config()
token := strings.TrimSpace(req.BotToken)
if token == "" {
token = strings.TrimSpace(cfg.TelegramBotToken)
}
chatID := strings.TrimSpace(req.ChatID)
if chatID == "" {
chatID = strings.TrimSpace(cfg.TelegramPrivateChatID)
}
return token, chatID
}
func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCode string) (string, string, error) {
var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100,
"timeout": 0,
"allowed_updates": []string{"message"},
}, &updates); err != nil {
return "", "", err
}
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
continue
}
text := strings.TrimSpace(message.Text)
if text != pairingCode && text != "/start "+pairingCode {
continue
}
name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
if name == "" && message.Chat.Username != "" {
name = "@" + message.Chat.Username
}
return strconv.FormatInt(message.Chat.ID, 10), name, nil
}
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(raw)), nil
}
func telegramTokenFingerprint(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func telegramMailboxAllowed(cfg Config, mailboxID string) bool {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" {
return cfg.TelegramIncludeUnregistered
}
for _, id := range cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")) {
if id == mailboxID {
return true
}
}
return false
}
func (a *App) activeTelegramMailboxIDs(ctx context.Context, values []string) []string {
ids := cleanIDList(values)
active := make([]string, 0, len(ids))
for _, id := range ids {
var exists int
if err := a.db.QueryRowContext(ctx, `SELECT 1 FROM mailboxes WHERE id=? AND status='active'`, id).Scan(&exists); err == nil && exists == 1 {
active = append(active, id)
}
}
return active
}
func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) || !telegramMailboxAllowed(cfg, msg.MailboxID) {
return
}
recipient := normalizeEmail(msg.RecipientAddr)
if recipient == "" && len(msg.To) > 0 {
recipient = normalizeEmail(msg.To[0])
}
body := telegramMessageBody(msg)
otp := detectTelegramOTP(msg.Subject, body)
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
limit := 800
if mode == "full" {
limit = 2600
}
body, truncated := truncateRunes(body, limit)
if truncated {
body += "..."
}
if body == "" {
body = normalizeTelegramText(msg.Snippet)
}
from, _ := truncateRunes(strings.TrimSpace(msg.From), 254)
fromName, _ := truncateRunes(strings.TrimSpace(msg.FromName), 160)
subject, _ := truncateRunes(strings.TrimSpace(msg.Subject), 240)
names := make([]string, 0, min(len(attachments), 5))
for _, attachment := range attachments {
name := sanitizeTelegramAttachmentName(attachment.Filename)
if name != "" {
names = append(names, name)
}
if len(names) >= 5 {
break
}
}
payload := telegramMailPayload{
From: from,
FromName: fromName,
Recipient: recipient,
Subject: subject,
ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
Body: body,
BodyMode: mode,
OTP: otp,
AttachmentNames: names,
AttachmentCount: len(attachments),
}
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil {
a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err)
}
}
func telegramMessageBody(msg storedMessage) string {
text := strings.TrimSpace(msg.BodyText)
text, _ = truncateRunes(text, 128*1024)
if text != "" && looksLikeHTMLDocument(text) {
text = telegramHTMLToText(text)
}
if strings.TrimSpace(text) == "" {
text = telegramHTMLToText(msg.BodyHTML)
}
return stripTelegramQuotedContent(normalizeTelegramText(text))
}
func looksLikeHTMLDocument(value string) bool {
value, _ = truncateRunes(value, 128*1024)
value = strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(value, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
return true
}
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
return len(matches) >= 3
}
func telegramHTMLToText(value string) string {
value = strings.ToValidUTF8(value, "")
value, _ = truncateRunes(value, 128*1024)
doc, err := nethtml.Parse(strings.NewReader(value))
if err != nil {
return stripTags(value)
}
var out strings.Builder
var walk func(*nethtml.Node, bool)
walk = func(node *nethtml.Node, skipped bool) {
if node.Type == nethtml.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "head", "noscript", "svg":
skipped = true
case "br":
if !skipped {
out.WriteByte('\n')
}
}
}
if node.Type == nethtml.TextNode && !skipped {
out.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, skipped)
}
if node.Type == nethtml.ElementNode && !skipped {
switch strings.ToLower(node.Data) {
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
out.WriteByte('\n')
}
}
}
walk(doc, false)
return normalizeTelegramText(out.String())
}
func normalizeTelegramText(value string) string {
value = strings.ReplaceAll(strings.ToValidUTF8(value, ""), "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
lines := strings.Split(value, "\n")
out := make([]string, 0, len(lines))
empty := false
for _, line := range lines {
line = strings.TrimSpace(strings.Map(func(r rune) rune {
if r == '\t' {
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, line))
line = strings.Join(strings.Fields(line), " ")
if line == "" {
if !empty && len(out) > 0 {
out = append(out, "")
}
empty = true
continue
}
empty = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[:]|from[:].+|_{5,})$`)
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
func stripTelegramQuotedContent(value string) string {
lines := strings.Split(value, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
lines = lines[:i]
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func sanitizeTelegramAttachmentName(value string) string {
value = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.ToValidUTF8(value, "")))
value = strings.Join(strings.Fields(value), " ")
value, truncated := truncateRunes(value, 100)
if truncated {
value += "..."
}
return value
}
var (
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
telegramEmailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
)
func detectTelegramOTP(subject, body string) string {
text := normalizeTelegramText(strings.TrimSpace(subject) + "\n" + body)
keywords := telegramOTPKeywordRe.FindAllStringIndex(text, -1)
if len(keywords) == 0 {
return ""
}
type candidateScore struct {
value string
score int
count int
}
scores := map[string]candidateScore{}
subjectEnd := len(strings.TrimSpace(subject))
excludedRanges := append(telegramEmailRe.FindAllStringIndex(text, -1), telegramURLRe.FindAllStringIndex(text, -1)...)
for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) {
if telegramRangeOverlaps(match, excludedRanges) {
continue
}
if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) {
continue
}
if match[1] < len(text) && isTelegramOTPAlphaNumeric(rune(text[match[1]])) {
continue
}
value := strings.ToUpper(text[match[0]:match[1]])
hasDigit := false
for _, r := range value {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
continue
}
if isTelegramOTPNonCode(value) {
continue
}
best := 0
for _, keyword := range keywords {
distance := match[0] - keyword[1]
if distance < 0 {
distance = keyword[0] - match[1]
}
if distance < 0 {
distance = 0
}
score := 0
switch {
case distance <= 16:
score = 100
case distance <= 48:
score = 80
case distance <= 100:
score = 55
}
if match[0] <= subjectEnd {
score += 15
}
if score > best {
best = score
}
}
if best == 0 {
continue
}
current := scores[value]
current.value = value
current.count++
if best > current.score {
current.score = best
}
scores[value] = current
}
items := make([]candidateScore, 0, len(scores))
for _, item := range scores {
item.score += min(item.count-1, 2) * 5
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
if len(items) == 0 || items[0].score < 55 {
return ""
}
if len(items) > 1 && items[1].score >= items[0].score-25 {
return ""
}
return items[0].value
}
func telegramRangeOverlaps(candidate []int, ranges [][]int) bool {
for _, item := range ranges {
if len(item) == 2 && candidate[0] < item[1] && candidate[1] > item[0] {
return true
}
}
return false
}
func isTelegramOTPNonCode(value string) bool {
if len(value) == 4 {
if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 {
return true
}
}
if len(value) == 8 {
if _, err := time.Parse("20060102", value); err == nil {
return true
}
}
return false
}
func isTelegramOTPAlphaNumeric(r rune) bool {
return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r))
}
func (a *App) shouldNotifyTelegramMessage(ctx context.Context, messageID string) bool {
var folder string
if err := a.db.QueryRowContext(ctx, `SELECT lower(COALESCE(NULLIF(f.role,''),f.name,'')) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, messageID).Scan(&folder); err != nil {
return false
}
switch strings.TrimSpace(folder) {
case "spam", "junk", "trash", "deleted":
return false
default:
return true
}
}
func (a *App) telegramMailWorker(ctx context.Context) {
a.log.Info("Telegram mail notification worker started")
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.log.Warn("Telegram mail notification worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("Telegram mail notification worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
_, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil
}
nowText := a.now().UTC().Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
if err != nil {
return err
}
type queueItem struct {
id string
payload telegramMailPayload
attempt int
invalid bool
}
items := []queueItem{}
for rows.Next() {
var item queueItem
var raw string
if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil {
rows.Close()
return err
}
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
item.invalid = true
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
if item.invalid {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
return err
}
continue
}
now := a.now().UTC()
leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
continue
}
formatted := formatTelegramMailMessage(item.payload)
telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
now = a.now().UTC()
if err != nil {
attempts := item.attempt + 1
delay := sendRetryDelay(attempts)
var apiErr *telegramAPIError
if errors.As(err, &apiErr) {
if apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
code := apiErr.ErrorCode
if code == 0 {
code = apiErr.HTTPStatus
}
if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
attempts = telegramMailMaxAttempts
}
}
next := now.Add(delay)
if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
return updateErr
}
continue
}
stamp := now.Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=?,lease_until='',telegram_message_id=?,payload_json='{}' WHERE id=? AND delivered_at IS NULL`, stamp, stamp, telegramMessageID, item.id); err != nil {
return err
}
}
return nil
}
func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
subject, _ = truncateRunes(subject, 180)
from, _ = truncateRunes(from, 220)
recipient, _ := truncateRunes(strings.TrimSpace(payload.Recipient), 160)
lines := []string{
"📩 <b>新邮件通知</b>",
"",
"<b>主题:</b>" + escapeTelegramWithinBudget(subject, 420),
"<b>发件人:</b>" + escapeTelegramWithinBudget(from, 500),
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
"<b>收件时间:</b>" + html.EscapeString(timeText),
}
if payload.OTP != "" {
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
}
if len(payload.AttachmentNames) > 0 {
names := make([]string, 0, len(payload.AttachmentNames))
for _, name := range payload.AttachmentNames {
names = append(names, escapeTelegramWithinBudget(name, 180))
}
attachmentText := strings.Join(names, "、")
if payload.AttachmentCount > len(payload.AttachmentNames) {
attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames))
}
lines = append(lines, "", fmt.Sprintf("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
}
body := strings.TrimSpace(payload.Body)
if body != "" {
label := "正文摘要"
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
label = "邮件正文"
}
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
lines = []string{prefix + body + suffix}
}
htmlText := strings.Join(lines, "\n")
plain := formatTelegramMailPlainText(payload)
return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP}
}
func formatTelegramBodyHTML(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
appendEscaped := func(text string) bool {
for _, r := range text {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
return false
}
out.WriteString(escaped)
used += length
}
return true
}
last := 0
for _, match := range telegramURLRe.FindAllStringIndex(value, -1) {
if !appendEscaped(value[last:match[0]]) {
truncated = true
break
}
rawURL, trailing := trimTelegramURL(value[match[0]:match[1]])
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
if !appendEscaped(value[match[0]:match[1]]) {
truncated = true
break
}
last = match[1]
continue
}
display := rawURL
if utf8.RuneCountInString(display) > 72 {
display = "🔗 " + parsed.Hostname() + " 链接"
}
anchor := `<a href="` + html.EscapeString(rawURL) + `">` + html.EscapeString(display) + `</a>`
length := utf8.RuneCountInString(anchor)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(anchor)
used += length
if !appendEscaped(trailing) {
truncated = true
break
}
last = match[1]
}
if !truncated && last < len(value) && !appendEscaped(value[last:]) {
truncated = true
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func trimTelegramURL(value string) (string, string) {
trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】")
return trimmed, value[len(trimmed):]
}
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
_, err := a.sendTelegramPayload(ctx, token, map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": true,
})
return err
}
func (a *App) deliverTelegramMailMessage(ctx context.Context, token, chatID string, message telegramFormattedMessage) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"text": message.HTML,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
payload["reply_markup"] = markup
}
result, err := a.sendTelegramPayload(ctx, token, payload)
if err == nil {
return result.MessageID, nil
}
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != http.StatusBadRequest {
return 0, err
}
fallback := map[string]any{
"chat_id": chatID,
"text": message.PlainText,
"disable_web_page_preview": true,
}
if markup := telegramCopyMarkup(message.OTP); markup != nil {
fallback["reply_markup"] = markup
}
result, err = a.sendTelegramPayload(ctx, token, fallback)
if err != nil {
return 0, err
}
return result.MessageID, nil
}
func (a *App) sendTelegramPayload(ctx context.Context, token string, payload map[string]any) (telegramSentMessage, error) {
var result telegramSentMessage
err := a.callTelegram(ctx, token, "sendMessage", payload, &result)
return result, err
}
func telegramCopyMarkup(otp string) map[string]any {
otp = strings.TrimSpace(otp)
if otp == "" || utf8.RuneCountInString(otp) > 256 {
return nil
}
return map[string]any{"inline_keyboard": [][]map[string]any{{{
"text": "复制验证码",
"copy_text": map[string]string{"text": otp},
}}}}
}
func escapeTelegramWithinBudget(value string, budget int) string {
if budget <= 3 {
return ""
}
var out strings.Builder
used := 0
truncated := false
for _, r := range value {
escaped := html.EscapeString(string(r))
length := utf8.RuneCountInString(escaped)
if used+length > budget-3 {
truncated = true
break
}
out.WriteString(escaped)
used += length
}
if truncated {
out.WriteString("...")
}
return out.String()
}
func formatTelegramMailPlainText(payload telegramMailPayload) string {
subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" {
subject = "(无主题)"
}
from := strings.TrimSpace(payload.From)
if name := strings.TrimSpace(payload.FromName); name != "" {
from = name + " <" + from + ">"
}
receivedAt := parseTime(payload.ReceivedAt)
timeText := strings.TrimSpace(payload.ReceivedAt)
if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
}
parts := []string{"新邮件通知", "", "主题:" + subject, "发件人:" + from, "收件邮箱:" + payload.Recipient, "收件时间:" + timeText}
if payload.OTP != "" {
parts = append(parts, "", "验证码", payload.OTP)
}
if payload.AttachmentCount > 0 {
parts = append(parts, "", fmt.Sprintf("附件:%d 个", payload.AttachmentCount))
}
if body := strings.TrimSpace(payload.Body); body != "" {
parts = append(parts, "", "正文摘要", body)
}
text := normalizeTelegramText(strings.Join(parts, "\n"))
text, truncated := truncateRunes(text, telegramMessageBudget-3)
if truncated {
text += "..."
}
return text
}
func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error {
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, "/\\\r\n") {
return errors.New("Telegram Bot Token 无效")
}
base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/")
endpoint := base + "/bot" + url.PathEscape(token) + "/" + method
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0")
client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
return errors.New("Telegram 请求失败,请检查网络连接和机器人配置")
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
var apiResponse telegramAPIResponse
if err := json.Unmarshal(raw, &apiResponse); err != nil {
return fmt.Errorf("Telegram 返回了无效响应(HTTP %d", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK {
description := strings.TrimSpace(apiResponse.Description)
if description == "" {
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return &telegramAPIError{HTTPStatus: resp.StatusCode, ErrorCode: apiResponse.ErrorCode, Description: description, RetryAfter: time.Duration(apiResponse.Parameters.RetryAfter) * time.Second}
}
if result != nil && len(apiResponse.Result) > 0 {
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
return err
}
}
return nil
}
+420
View File
@@ -0,0 +1,420 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
type sentMessage struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
var sent []sentMessage
var pairingCode atomic.Value
pairingCode.Store("")
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/bottest-token/getMe":
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
case "/bottest-token/getUpdates":
code, _ := pairingCode.Load().(string)
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
case "/bottest-token/sendMessage":
var message sentMessage
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
t.Fatalf("decode Telegram message: %v", err)
}
sent = append(sent, message)
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
default:
http.NotFound(w, r)
}
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
payload["telegramBotToken"] = "test-token"
payload["telegramPrivateChatId"] = "123456789"
payload["telegramBodyMode"] = "full"
var adminMailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
t.Fatal(err)
}
payload["telegramMailboxIds"] = []string{adminMailboxID}
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
}
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
t.Fatalf("unexpected Telegram settings: %+v", settings)
}
if a.config().TelegramBotToken != "test-token" {
t.Fatal("Telegram token was not persisted in runtime config")
}
var pairing struct {
Code string `json:"code"`
DeepLink string `json:"deepLink"`
}
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
}
pairingCode.Store(pairing.Code)
var discovered map[string]string
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
t.Fatalf("discover chat code=%d response=%v", code, discovered)
}
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
t.Fatalf("unexpected discovered chat: %v", discovered)
}
var testResult map[string]any
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
}
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
t.Fatalf("unexpected Telegram test message: %+v", sent)
}
sent = nil
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
MailboxID: adminMailboxID,
RecipientAddr: "admin@example.com",
Subject: "账单 <已生成>",
From: "billing@example.net",
FromName: "Billing & Support",
ReceivedAt: receivedAt,
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatalf("process Telegram mail queue: %v", err)
}
if len(sent) != 1 {
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
}
text := sent[0].Text
for _, expected := range []string{"新邮件通知", "Billing &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "&lt;VIP&gt; &amp; 续费信息"} {
if !strings.Contains(text, expected) {
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
}
}
if sent[0].ReplyMarkup == nil {
t.Fatal("Telegram OTP copy button was not included")
}
var delivered, storedPayload string
var telegramMessageID int64
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
}
if storedPayload != "{}" || telegramMessageID != 8 {
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
}
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
disablePayload := systemSettingsPayload(settings)
disablePayload["telegramMailEnabled"] = false
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
t.Fatalf("disable Telegram settings code=%d", code)
}
var pending int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
}
}
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
a := newTestApp(t)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
payload := systemSettingsPayload(settings)
payload["telegramMailEnabled"] = true
var body map[string]any
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
}
}
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
serverURL := server.URL
server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = serverURL
const token = "123456:secret-token-value"
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
if err == nil {
t.Fatal("expected Telegram network request to fail")
}
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
t.Fatalf("Telegram error exposed Bot Token: %v", err)
}
}
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
cleaned := stripTelegramQuotedContent(body)
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
t.Fatalf("unexpected OTP %q", otp)
}
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: strings.Repeat("R&D <team@example.com> ", 30),
Recipient: "admin@example.com",
Subject: strings.Repeat("超长主题 & <test> ", 50),
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
Body: strings.Repeat("正文内容 & <重要> ", 1000),
BodyMode: "full",
OTP: "846981",
AttachmentNames: []string{
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
},
AttachmentCount: 12,
})
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
t.Fatalf("Telegram HTML exceeds budget: %d", got)
}
if !strings.Contains(message.HTML, "&amp;") || !strings.Contains(message.HTML, "&lt;") || !strings.Contains(message.HTML, "<code>846981</code>") {
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
}
if markup := telegramCopyMarkup(message.OTP); markup == nil {
t.Fatal("copy_text markup missing")
}
}
func TestTelegramIQiyiOTPDetection(t *testing.T) {
subject := "825534 是您的动态安全验证码"
body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved"
otp := detectTelegramOTP(subject, body)
if otp != "825534" {
t.Fatalf("iQiyi OTP not detected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp})
if !strings.Contains(message.HTML, "<code>825534</code>") || telegramCopyMarkup(message.OTP) == nil {
t.Fatalf("iQiyi OTP section or copy button missing: %+v", message)
}
}
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
body := `---------- Forwarded message ---------
Date: 2026年8月6日周四 17:59
Subject: 登录验证码 (https://www.gate.com)
Gate 检测到您的账号正试图从此 IP 获得登录验证码
IP: 87.83.105.229
如为您本人登录请输入如下验证码完成操作
311665
如非本人操作请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
}
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
})
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
t.Fatalf("normal URL was not linkified: %s", message.HTML)
}
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
}
if strings.Contains(message.HTML, "&lt;a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
}
markup := telegramCopyMarkup(message.OTP)
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
t.Fatalf("copy OTP button missing: %#v", markup)
}
}
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
text := telegramMessageBody(storedMessage{BodyText: pseudo})
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
t.Fatalf("pseudo HTML was not cleaned: %q", text)
}
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
if err != nil {
t.Fatal(err)
}
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
tests := []struct {
name string
status int
response string
retryAfter time.Duration
}{
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.response))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
t.Fatalf("unexpected Telegram error: %#v", err)
}
})
}
}
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
cfg.TelegramMailboxIDs = mailboxID
})
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
}
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if msg.RecipientAddr != "admin@lanqin.local" {
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
return
}
if _, exists := payload["parse_mode"]; exists {
t.Fatal("plain-text fallback still included parse_mode")
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
if err != nil || messageID != 99 || calls.Load() != 2 {
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
}
}
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
})
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
t.Fatal(err)
}
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatal(err)
}
var badAttempts int
var delivered string
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
t.Fatal(err)
}
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
}
}
+5 -2
View File
@@ -21,8 +21,9 @@ type User struct {
type AdminUser struct {
User
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
StorageQuotaMB int `json:"storageQuotaMb"`
}
type APIToken struct {
@@ -71,6 +72,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
Primary bool `json:"primary"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -88,6 +90,7 @@ type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
+1 -1
View File
@@ -36,7 +36,7 @@
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.12",
"dompurify": "3.4.13",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
+7 -3
View File
@@ -1,15 +1,19 @@
import React from "react"
import { Navigate, useLocation } from "react-router-dom"
import { useMe, isTimeoutError } from "@/hooks/use-me"
import { useMe } from "@/hooks/use-me"
import { AuthLoading, AuthError } from "@/components/auth-states"
import { isUnauthorizedError } from "@/lib/api"
export function AuthGuard({ children }: { children: React.ReactNode }) {
const me = useMe()
const location = useLocation()
if (me.isLoading) return <AuthLoading />
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) {
const from = `${location.pathname}${location.search}${location.hash}`
return <Navigate to="/login" replace state={{ from }} />
}
return <>{children}</>
}
+5 -5
View File
@@ -1,17 +1,17 @@
import { Button } from "@/components/ui/button"
export function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
return <main className="grid min-h-screen place-items-center text-muted-foreground">...</main>
}
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
</main>
)
}
+2 -2
View File
@@ -26,12 +26,12 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <div className="text-sm text-muted-foreground">{description}</div>}
<DialogFooter>
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelText}
</Button>
@@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
@@ -110,9 +109,6 @@ function ProtectedContent() {
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
+5 -5
View File
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
+5 -2
View File
@@ -8,8 +8,9 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card"
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
+1 -1
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
+4 -1
View File
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
>(({ className, ...props }, ref) => (
<tr
ref={ref}
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+2 -6
View File
@@ -1,5 +1,5 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { api, isUnauthorizedError } from "@/lib/api"
import type { User } from "@/lib/api"
type MeResponse = { user: User }
@@ -10,11 +10,7 @@ export function useMe(
return useQuery({
queryKey: ["me"],
queryFn: api.me,
retry: 1,
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
...options,
})
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.includes("请求超时")
}
+59 -21
View File
@@ -5,34 +5,34 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 224 44% 12%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 12%;
--primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 84% 4.9%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 12%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 42%;
--accent: 0 0% 95%;
--accent-foreground: 0 0% 12%;
--destructive: 358 88% 61%;
--destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%;
--border: 0 0% 89.8%;
--input: 0 0% 82%;
--ring: 0 0% 32%;
--radius: 0.5rem;
--app-sidebar-width: 18rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%;
--sidebar-foreground: 0 0% 16%;
--sidebar-primary: 0 0% 12%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 215.4 16.3% 46.9%;
--sidebar-accent: 0 0% 95%;
--sidebar-accent-foreground: 0 0% 12%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 0 0% 42%;
}
* { @apply border-border; }
@@ -140,12 +140,50 @@
}
[data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent));
background: hsl(var(--sidebar-accent) / 0.82);
color: hsl(var(--sidebar-accent-foreground));
box-shadow: inset 2px 0 0 hsl(var(--foreground));
}
[data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground));
color: hsl(var(--foreground));
}
.admin-page [data-slot="card"] {
border-radius: 0.5rem;
box-shadow: none;
}
.admin-page [data-slot="card-header"] {
padding: 1.25rem;
}
.admin-page [data-slot="card-content"] {
padding: 0 1.25rem 1.25rem;
}
.admin-page [data-slot="card-title"] {
font-size: 0.9375rem;
line-height: 1.4;
}
.admin-page [data-slot="table-header"] {
background: hsl(var(--muted) / 0.48);
}
.admin-page [data-slot="table-head"] {
height: 2.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.75rem;
}
.admin-page [data-slot="table-cell"] {
padding: 0.75rem;
}
.admin-page [data-slot="table-row"]:hover {
background: hsl(var(--muted) / 0.32);
}
}
+12 -4
View File
@@ -52,12 +52,12 @@ export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
@@ -233,8 +233,16 @@ export type SystemSettings = {
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
telegramMailEnabled: boolean
telegramBotTokenSet: boolean
telegramPrivateChatId: string
telegramBodyMode: "summary" | "full"
telegramMailboxIds: string[]
telegramIncludeUnregistered: boolean
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
export type TelegramPrivateChat = { chatId: string; displayName: string }
export type TelegramPairing = { code: string; botUsername: string; deepLink: string; expiresAt: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
+31 -10
View File
@@ -1,9 +1,20 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
export class ApiError extends Error {
constructor(message: string, readonly status: number) {
super(message)
this.name = "ApiError"
}
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401
}
export type MailSearchParams = {
q?: string
from?: string
@@ -45,27 +56,34 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs, ...requestInit } = init
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
let timedOut = false
const timeout = window.setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs || REQUEST_TIMEOUT_MS)
const externalSignal = requestInit.signal
const abortFromExternalSignal = () => controller.abort()
if (externalSignal) {
if (externalSignal.aborted) controller.abort()
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
}
try {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
throw new ApiError(message, res.status)
}
return res.json() as Promise<T>
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("请求超时,请检查后端服务是否正常")
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
}
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
throw error instanceof Error ? error : new Error("网络请求失败")
} finally {
window.clearTimeout(timeout)
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
}
}
@@ -154,8 +172,8 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -163,7 +181,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -197,6 +215,9 @@ export const api = {
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
@@ -212,9 +233,9 @@ export const api = {
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
},
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
+4
View File
@@ -0,0 +1,4 @@
export function safeReturnPath(value: unknown, fallback = "/"): string {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
return value
}
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { Link, Navigate } from "react-router-dom"
import { Link, Navigate, useLocation } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
import { api } from "@/lib/api"
@@ -10,9 +10,12 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
import { safeReturnPath } from "@/lib/navigation"
import { AuthError, AuthLoading } from "@/components/auth-states"
export function LoginPage() {
const me = useMe()
const location = useLocation()
const qc = useQueryClient()
const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
@@ -33,9 +36,12 @@ export function LoginPage() {
onError: (e) => toast({ title: "登录失败", description: e.message }),
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
if (me.data?.user) return <Navigate to={returnPath} replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -82,6 +88,6 @@ export function LoginPage() {
</div>
)}
</div>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
export function NotFoundPage() {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
@@ -24,6 +24,6 @@ export function NotFoundPage() {
</Button>
</div>
</div>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -13,6 +13,7 @@ import { useToast } from "@/hooks/use-toast"
import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation"
import { AuthError, AuthLoading } from "@/components/auth-states"
export function RegisterPage() {
const me = useMe()
@@ -57,8 +58,10 @@ export function RegisterPage() {
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -103,7 +106,6 @@ export function RegisterPage() {
<div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label>
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -130,6 +132,6 @@ export function RegisterPage() {
</div>
)}
</div>
</div>
</main>
)
}
+38 -13
View File
@@ -1,19 +1,44 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { defineConfig, loadEnv } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/healthz": "http://localhost:8080",
server: {
port: 5173,
proxy: {
"/api": apiTarget,
"/healthz": apiTarget,
},
},
},
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "prosemirror",
test: /node_modules[\\/]prosemirror-/,
priority: 30,
},
{
name: "tiptap",
test: /node_modules[\\/]@tiptap/,
priority: 20,
},
],
},
},
},
},
}
})
+16
View File
@@ -183,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
# =========================
# Telegram 私聊邮件通知
# =========================
# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。
# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。
LANQIN_TELEGRAM_MAIL_ENABLED=false
# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。
LANQIN_TELEGRAM_BOT_TOKEN=
# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。
LANQIN_TELEGRAM_PRIVATE_CHAT_ID=
# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。
LANQIN_TELEGRAM_BODY_MODE=summary
# =========================
# 系统
# =========================
+29 -2
View File
@@ -23,7 +23,7 @@ sudo newszxcn-email reset-2fa
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式邮件服务器域名邮箱地址域名、管理员邮箱/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。管理员邮箱默认 `admin@邮箱地址域名`自定义管理员密码最少 6 位,留空则生成 12 位密码。
首次安装会依次询问防火墙模式邮件服务器域名,自动检测邮箱地址域名,再选择默认 `admin` 前缀或自行创建管理员邮箱前缀,最后输入密码并选择 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。例如服务器域名 `mail.newszxcn.com`、选择默认前缀会创建 `admin@newszxcn.com`自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
@@ -148,13 +148,40 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
配置完成后点击“检测”。
## Telegram 通知
### 私聊新邮件通知
每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知:
1. 使用 `@BotFather` 创建机器人并填写 Bot Token。
2. 在 Telegram 中打开该机器人并发送 `/start`
3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。
4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。
5. 测试成功后开启“私聊新邮件通知”并保存。
Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。
手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED``LANQIN_TELEGRAM_BOT_TOKEN``LANQIN_TELEGRAM_PRIVATE_CHAT_ID``LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。
### GitHub Release 版本频道通知
版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets
```text
TELEGRAM_RELEASE_BOT_TOKEN
TELEGRAM_RELEASE_CHAT_ID
```
`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。
## 邮件服务边界
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
- 第三方客户端可使用 IMAP SSL `993`、POP3 SSL `995`、SMTP SSL `465` 或 Submission `587`
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`;仅当密钥内容变化时重新载入签名配置,避免继续使用内存中的旧密钥
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
+23 -2
View File
@@ -13,8 +13,20 @@ chown_dkim_dir() {
fi
}
reload_rspamd() {
if command -v rspamadm >/dev/null 2>&1 && rspamadm control reload >/dev/null 2>&1; then
echo "Rspamd reloaded after DKIM key update"
return 0
fi
if command -v pkill >/dev/null 2>&1 && pkill -HUP -x rspamd 2>/dev/null; then
echo "Rspamd reloaded after DKIM key update"
fi
}
sync_keys() {
changed_marker="$LANQIN_RSPAMD_DKIM_DIR/.reload-required.$$"
mkdir -p "$LANQIN_RSPAMD_DKIM_DIR"
rm -f "$changed_marker"
if [ ! -f "$LANQIN_DB_PATH" ]; then
chown_dkim_dir
return 0
@@ -24,13 +36,22 @@ sync_keys() {
[ -n "$domain" ] || continue
[ -n "$selector" ] || selector="lanqin"
keyfile="$LANQIN_RSPAMD_DKIM_DIR/${domain}.${selector}.key"
tmpfile="${keyfile}.tmp"
tmpfile="${keyfile}.tmp.$$"
printf '%s' "$private_key" | base64 -d > "$tmpfile"
chmod 0640 "$tmpfile"
mv "$tmpfile" "$keyfile"
if [ -f "$keyfile" ] && cmp -s "$tmpfile" "$keyfile"; then
rm -f "$tmpfile"
else
mv "$tmpfile" "$keyfile"
: > "$changed_marker"
fi
done
chown_dkim_dir
if [ -f "$changed_marker" ]; then
rm -f "$changed_marker"
reload_rspamd
fi
}
if [ "${1:-}" = "--once" ]; then
+20 -2
View File
@@ -1,4 +1,4 @@
# NewSzxcn 邮箱指南
# NewSzxcn 邮箱后台配置指南
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
@@ -10,7 +10,7 @@
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
安装脚本会依次询问防火墙配置邮件服务器域名邮箱地址域名、管理员邮箱和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装脚本会依次询问防火墙配置邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
@@ -59,6 +59,24 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
## Telegram 私聊邮件通知
管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊:
1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。
2. 进入“管理后台 -> 系统设置 -> 通知”,填写 Bot Token。
3. 点击“安全绑定”生成一次性绑定码,再点击“打开机器人”。
4. 在机器人会话中发送页面生成的绑定码,然后点击“完成绑定”。
5. 勾选需要通知的邮箱;需要接收未注册地址邮件时,另行勾选“未知收件”。
6. 选择正文显示方式并点击“测试通知”。
7. 测试成功后开启“私聊新邮件通知”,保存设置。
一次性绑定码有效期为 10 分钟,只会匹配发送了该绑定码的私聊账号。通知会显示主题、发件人、收件邮箱、服务器收件时间、正文和附件摘要;识别到唯一高可信验证码时,会高亮显示并提供“复制验证码”按钮。外部 IMAP 第一次同步导入的历史邮件不会发送通知,后续新邮件才会通知。
Telegram 连接失败不会影响邮局收件。系统会保留通知任务并自动重试;关闭通知、更换机器人、更换私聊账号或修改通知邮箱范围时,尚未发送的旧任务会被清除。Telegram Bot API 不提供客户端幂等键,因此网络超时发生在 Telegram 已收到请求但服务器未收到响应时,极少数通知可能重复发送。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。
邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。
## SSL 证书与自动续期
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
+225
View File
@@ -39,6 +39,15 @@
| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 |
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
## NSX-20260804-001
@@ -198,3 +207,219 @@
| --- | --- |
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
| 2026-08-06 | 已移除永久占位列,改为具体邮箱状态覆盖显示复制按钮,状态流转为待验收。 |
## NSX-20260806-005
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-005 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 后端/通知;前端/设置;部署运维/CI;质量复核 |
| 需求 | 后台配置 Telegram 机器人,将新邮件排版后发送到管理员私聊;GitHub Release 成功后统一向版本频道发送一次更新通知。 |
| 边界 | 邮件通知由各部署实例独立配置;版本通知只由 GitHub Release 工作流发送,不依赖已部署邮局是否更新。 |
| 实现 | 新增 Telegram 通知设置、私聊 Chat ID 自动获取、测试发送、正文模式、持久化通知队列、去重与失败重试;Release 工作流在全部镜像和 Release 成功后发送频道消息。 |
| 安全 | Bot Token 不通过设置查询接口返回,不写入仓库;频道密钥使用 GitHub Actions SecretsTelegram 失败不阻塞收件或版本发布。 |
| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 |
| 目标版本 | v1.2.16 |
| 测试结果 | Go 全量测试和 vet、前端 check/build、安装脚本语法/ShellCheck/回归、工作流 YAML、密钥扫描、桌面和移动端页面检查均通过。 |
| 发布状态 | 随 v1.2.16 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户确认后台只保留机器人私聊邮件通知,版本频道通知交由 GitHub Release 工作流统一发送。 |
| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 |
| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |
| 2026-08-06 | v1.2.16 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功。 |
## NSX-20260806-006
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-006 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 后端/通知;邮件核心;前端/设置;质量复核 |
| 现象 | 原自动获取 Chat ID 可能匹配错误私聊;通知范围默认覆盖全部本地邮箱;关闭后重开可能补发旧任务;收件邮箱、正文编码、长度预算、错误重试和验证码复制不完整。 |
| 根因 | 通知功能首版只覆盖基础发送,没有建立安全配对、显式邮箱范围、发送租约、Telegram 错误分类和统一的 MIME/正文规范化流程。 |
| 实现 | 使用 10 分钟一次性绑定码;通知范围改为显式邮箱和未知收件选择;目的地或范围变化时事务清理旧任务;增加发送租约、Telegram 消息编号、送达后正文清除、400 纯文本降级、429 `retry_after`、401/403 停止重试;新增验证码评分与 `copy_text` 按钮、全消息长度预算、附件清理、GBK 等正文字符集解码和伪 HTML 清理。 |
| 收件链路 | 修正实际收件地址解析;本地互发、未知收件和后续外部 IMAP 新邮件统一通知;首次外部 IMAP 历史导入不通知;收信规则先执行,垃圾邮件和已删除邮件不通知。 |
| 兼容性 | 数据库仅增加可空闲迁移列和设置项;现有 Bot Token 保留且不回传;升级后管理员邮箱自动成为默认通知范围,已开启通知的实例继续包含未知收件。 |
| 目标版本 | v1.2.17 |
| 测试结果 | Telegram 专项测试、Go 全量测试、`go vet`、竞态检测、前端 shadcn 检查和生产构建通过;桌面端与移动端页面视觉验收通过。 |
| 发布状态 | 随 v1.2.17 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 完成通知全链路复核,确认安全绑定、旧队列、收件地址、来源覆盖、长度预算、错误分类和 MIME 处理问题。 |
| 2026-08-06 | 用户确认继续修改,并明确保留现有机器人 Token。 |
| 2026-08-06 | 完成实现和自动化回归,状态流转为待验收。 |
| 2026-08-06 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |
## NSX-20260806-007
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260806-007 |
| 日期 | 2026-08-06 |
| 状态 | 已完成 |
| 模块 | 前端/UI;后端/通知;部署运维/CI;质量复核 |
| 现象 | 邮箱选择器展开层超过侧栏边框;含日期年份的验证码邮件未显示复制按钮;邮件长链接难以阅读;Release 通知底部按钮需改为正文文字链接。 |
| 根因 | 展开层固定为 21rem,未跟随触发按钮;年份与真实验证码同时进入评分后触发歧义保护;正文仅转义未生成显式链接;Release 工作流使用 inline keyboard。 |
| 实现 | 展开层宽度跟随触发按钮;排除年份和紧凑日期候选;正文 URL 安全转义并生成链接,长追踪地址缩短显示;Release 移除按钮并在正文末尾加入“查看本次更新”链接。 |
| 目标版本 | v1.2.18 |
| 测试结果 | Gate 转发邮件验证码与链接专项测试、Telegram 全部竞态测试、Go 全量测试和 vet、前端 check/build、工作流 YAML、差异格式检查均通过;桌面端“全部邮箱”和具体邮箱状态下触发按钮与下拉层均为 263px,左右边界一致且无控制台错误。 |
| 发布状态 | 随 v1.2.18 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户提供三张截图并确认本批修改范围,问题进入处理中。 |
| 2026-08-06 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 |
| 2026-08-06 | v1.2.18 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-008
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-008 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 |
| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 |
| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 |
| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 |
| 目标版本 | v1.2.19 |
| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-009
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-009 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 部署运维/安装;文档;质量复核 |
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
| 实现 | 未安装服务器只显示一键安装和退出;已安装服务器按安装维护、服务管理、证书恢复、账号帮助、危险操作分组,动态显示运行状态、镜像版本和访问地址;安装残缺时默认进入修复;新增 `repair` 命令;服务命令统一校验 `.env` 与 Compose 文件;日志、状态、回滚和卸载的 Docker 检查收回各自函数;修复成功文案和缺失版本标签显示已纠正;README 加入两套主菜单示例。 |
| 兼容性 | 保持既有菜单编号 `112``ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-010
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-010 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
## NSX-20260807-011
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-011 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 后端/通知;验证码识别;质量复核 |
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02``newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-012
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-012 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 前端/UI;域名管理;质量复核 |
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local``v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-013
| 字段 | 内容 |
| --- | --- |
| 编号 | NSX-20260807-013 |
| 日期 | 2026-08-07 |
| 状态 | 已完成 |
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
+180 -76
View File
@@ -29,12 +29,13 @@ NewSzxcn Email 管理命令
menu 显示安装与运维菜单
install 首次安装;已有安装会先完整备份再重新安装
update 备份数据库并更新到最新版
repair 检查并修复现有安装
status 查看容器与健康状态
logs 持续查看运行日志
restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次更新前版本
guide 显示并更新 NewSzxcn 邮箱指南
guide 显示并更新邮箱后台配置指南
credentials 查看管理员登录信息和记录密码
reset-password 重置管理员统一登录密码(含名下邮箱)
reset-2fa 应急关闭唯一管理员双因素认证
@@ -202,6 +203,18 @@ env_value() {
sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1
}
installation_configured() {
[[ -f "${INSTALL_DIR}/.env" ]]
}
installation_complete() {
installation_configured && [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]
}
require_installation() {
installation_complete || fail "尚未完成安装,请先运行 newszxcn-email install;如果配置残缺,请运行 newszxcn-email repair。"
}
prompt_value() {
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
local value="${!variable:-}"
@@ -318,8 +331,8 @@ prompt_mail_domain() {
suggestion="$(suggest_mail_domain "${hostname}")"
value="${LANQIN_MAIL_DOMAIN:-}"
if [[ -z "${value}" ]] && has_tty; then
prompt_text "[提示] 邮件服务器域名${hostname}邮箱地址域名可以使用 ${suggestion},请确认。\n"
read -r -p "邮箱地址域名 [${suggestion}]: " value </dev/tty
prompt_text "[检测] 邮件服务器域名${hostname}\n[检测] 邮箱地址域名@${suggestion}\n"
read -r -p "邮箱地址域名 [${suggestion}](直接回车确认): " value </dev/tty
fi
value="${value:-${suggestion}}"
if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -z "${admin_email}" ]] && ! has_tty; then
@@ -342,10 +355,10 @@ prompt_admin_email() {
return
fi
if has_tty; then
prompt_text "\n创建管理员邮箱 [1]:\n1. 默认 admin,自动创建 admin@${mail_domain}\n2. 自定义前缀\n"
prompt_text "\n检测到邮箱地址域名:@${mail_domain}\n创建管理员邮箱 [1]:\n1. 使用默认前缀 admin\n2. 自定义管理员邮箱前缀\n"
choice="$(prompt_choice LANQIN_ADMIN_EMAIL_MODE "请选择 [1]: " "1" "2")"
if [[ "${choice}" == "2" ]]; then
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱前缀" "admin")"
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱账号前缀" "admin")"
else
prefix="admin"
fi
@@ -353,7 +366,9 @@ prompt_admin_email() {
prefix="${LANQIN_ADMIN_PREFIX:-admin}"
fi
valid_mail_local_part "${prefix}" || fail "管理员邮箱前缀格式不正确。"
printf '%s@%s' "$(lowercase "${prefix}")" "${mail_domain}"
email="$(lowercase "${prefix}")@${mail_domain}"
prompt_text "[提示] 将创建管理员邮箱:${email}\n"
printf '%s' "${email}"
}
ensure_admin_email_config() {
@@ -974,42 +989,67 @@ restore_update_snapshot() {
}
do_repair_install() {
installation_configured || fail "尚未安装,无法执行修复。"
local snapshot_created="false"
ensure_docker
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
snapshot_created="true"
else
warn "安装缺少 docker-compose.yml,将保留现有配置和数据并重新生成运行文件。"
fi
stage_assets
clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
fi
fail "修复准备失败,原配置和数据未删除。"
fi
if ! (configure_firewall && prepare_directories); then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
fi
fail "修复环境准备失败,原配置和数据未删除。"
fi
log "正在拉取并修复 NewSzxcn Email 服务..."
if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
fail "修复镜像拉取失败,原配置和数据未删除。"
fi
log "正在启动服务..."
if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后容器启动失败,请查看实时日志;原配置和数据未删除。"
fi
if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后健康检查失败,请查看实时日志;原配置和数据未删除。"
fi
if ! (configure_web_mode); then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
fi
fail "Web 配置修复失败,原配置和数据未删除。"
fi
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
generate_guide >/dev/null || warn "修复成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "修复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
}
do_install() {
@@ -1031,14 +1071,14 @@ do_install() {
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "安装成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
}
do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
require_installation
ensure_docker
create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
stage_assets
@@ -1063,11 +1103,12 @@ do_update() {
fail "更新失败,已恢复到更新前版本。"
fi
ensure_cli_alias
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "更新成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
require_installation
[[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
if [[ -z "${confirm}" ]] && has_tty; then
@@ -1085,7 +1126,7 @@ do_rollback() {
}
reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
require_installation
ensure_docker
compose restart lanqin-email >/dev/null
if [[ -f "${NGINX_CONFIG}" ]]; then
@@ -1100,14 +1141,14 @@ do_restart() {
}
do_certificate() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
reload_services
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
generate_guide >/dev/null || warn "证书已应用,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。"
}
@@ -1140,7 +1181,7 @@ generate_guide() {
tmp="$(mktemp)"
cat > "${tmp}" <<EOF
==================================================
NewSzxcn 邮箱指南
NewSzxcn 邮箱后台配置指南
==================================================
【安装信息】
@@ -1199,7 +1240,7 @@ EOF
}
do_guide() {
generate_guide || fail "尚未安装,无法生成邮箱指南。"
generate_guide || fail "尚未安装,无法生成邮箱后台配置指南。"
cat "${GUIDE_FILE}"
success "指南已更新并保存到 ${GUIDE_FILE}"
}
@@ -1233,7 +1274,7 @@ generate_admin_password_hash() {
}
do_reset_admin_password() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
ensure_docker
ensure_admin_email_config
@@ -1277,7 +1318,7 @@ do_reset_admin_password() {
}
do_reset_admin_two_factor() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email user_id image timestamp backup result user_changes recovery_changes challenge_changes
ensure_docker
ensure_admin_email_config
@@ -1306,7 +1347,8 @@ do_reset_admin_two_factor() {
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
compose ps
if wait_for_health 1; then
success "Web 与 API 健康检查正常。"
@@ -1315,8 +1357,15 @@ do_status() {
fi
}
do_logs() {
require_installation
ensure_docker
compose logs -f --tail=200 lanqin-email updater
}
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
@@ -1415,58 +1464,112 @@ do_backup_reinstall() {
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}"
}
do_menu() {
local installed="false" default_choice="1" public_url="" choice
if [[ -f "${INSTALL_DIR}/.env" ]]; then
installed="true"
default_choice="2"
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
menu_service_status() {
local container_id
if ! installation_complete; then
printf '安装不完整'
return
fi
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 一键安装与管理\n'
prompt_text '==================================================\n'
if [[ "${installed}" == "true" ]]; then
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
printf '状态未知'
return
fi
container_id="$(compose ps -q lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
printf '运行中'
else
prompt_text ' 状态:未安装\n'
printf '已停止'
fi
prompt_text '--------------------------------------------------\n'
prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复)\n'
prompt_text ' 2. 更新系统(数据库备份,失败自动回滚)\n'
prompt_text ' 3. 检查并修复现有安装\n'
prompt_text ' 4. 查看运行状态\n'
prompt_text ' 5. 重启服务\n'
prompt_text ' 6. 查看实时日志\n'
prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
prompt_text ' 8. 回滚到上次更新前版本\n'
prompt_text ' 9. NewSzxcn 邮箱指南\n'
prompt_text ' 10. 查看管理员登录信息\n'
prompt_text ' 11. 重置管理员统一登录密码\n'
prompt_text ' 12. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n'
}
menu_installed_version() {
local image version
if ! installation_complete || ! command -v docker >/dev/null 2>&1; then
printf '未知'
return
fi
image="$(current_image_id 2>/dev/null || true)"
if [[ -n "${image}" ]]; then
version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
fi
[[ "${version:-}" == "<no value>" ]] && version=""
printf '%s' "${version:-未知}"
}
render_uninstalled_menu() {
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 管理面板\n'
prompt_text '==================================================\n'
prompt_text '状态:尚未安装\n'
prompt_text '--------------------------------------------------\n'
prompt_text '1. 一键安装 NewSzxcn Email\n'
prompt_text '0. 退出\n'
prompt_text '==================================================\n'
}
choice="$(prompt_menu_choice "${default_choice}" "12")"
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。"
render_installed_menu() {
local status="$1" version="$2" public_url="$3"
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 管理面板\n'
prompt_text '==================================================\n'
prompt_text "状态:${status}\n"
prompt_text "版本:${version}\n"
prompt_text "地址:${public_url:-未配置}\n"
prompt_text '--------------------------------------------------\n'
prompt_text '安装与维护\n'
prompt_text '1. 重新安装(完整备份,失败自动恢复)\n'
prompt_text '2. 更新系统(自动备份,失败自动回滚)\n'
prompt_text '3. 检查并修复现有安装\n\n'
prompt_text '服务管理\n'
prompt_text '4. 查看运行状态\n'
prompt_text '5. 重启服务\n'
prompt_text '6. 查看实时日志\n\n'
prompt_text '证书与恢复\n'
prompt_text '7. 管理 SSL 证书\n'
prompt_text '8. 回滚到上次更新前版本\n\n'
prompt_text '账号与帮助\n'
prompt_text '9. 邮箱后台配置指南\n'
prompt_text '10. 查看管理员登录信息\n'
prompt_text '11. 重置管理员登录密码\n\n'
prompt_text '危险操作\n'
prompt_text '12. 卸载服务(保留数据)\n\n'
prompt_text '0. 退出\n'
prompt_text '==================================================\n'
}
do_menu() {
local default_choice="2" public_url="" choice status version
if ! installation_configured; then
render_uninstalled_menu
choice="$(prompt_menu_choice "1" "1")" || return 1
case "${choice}" in
0) success "已退出,未作任何修改。" ;;
1) do_install ;;
esac
return
fi
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
status="$(menu_service_status)"
version="$(menu_installed_version)"
[[ "${status}" == "安装不完整" ]] && default_choice="3"
render_installed_menu "${status}" "${version}" "${public_url}"
choice="$(prompt_menu_choice "${default_choice}" "12")" || return 1
case "${choice}" in
0) success "已退出,未作任何修改。" ;;
1) do_install ;;
2) do_update ;;
3) do_repair_install ;;
4) ensure_docker; do_status ;;
4) do_status ;;
5) do_restart ;;
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
6) do_logs ;;
7) do_certificate ;;
8) ensure_docker; do_rollback ;;
8) do_rollback ;;
9) do_guide ;;
10) do_show_admin_credentials ;;
11) do_reset_admin_password ;;
12) ensure_docker; do_uninstall ;;
12) do_uninstall ;;
esac
}
@@ -1486,16 +1589,17 @@ case "${COMMAND}" in
menu) require_root; require_curl; do_menu ;;
install) require_root; require_curl; do_install ;;
update) require_root; require_curl; do_update ;;
status) require_root; require_curl; ensure_docker; do_status ;;
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
repair) require_root; require_curl; do_repair_install ;;
status) require_root; require_curl; do_status ;;
logs) require_root; require_curl; do_logs ;;
restart) require_root; require_curl; do_restart ;;
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
rollback) require_root; require_curl; do_rollback ;;
guide) require_root; require_curl; do_guide ;;
credentials) require_root; require_curl; do_show_admin_credentials ;;
reset-password) require_root; require_curl; do_reset_admin_password ;;
reset-2fa) require_root; require_curl; do_reset_admin_two_factor ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
uninstall) require_root; require_curl; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
+5 -5
View File
@@ -81,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.12
version: 3.4.12
specifier: 3.4.13
version: 3.4.13
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -1022,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -2447,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.12:
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TEMP_DIR}"' EXIT
fail_test() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
mkdir -p "${TEMP_DIR}/bin" "${TEMP_DIR}/keys"
touch "${TEMP_DIR}/lanqin.db" "${TEMP_DIR}/reload.log"
cat > "${TEMP_DIR}/bin/sqlite3" <<'EOF'
#!/bin/sh
printf 'example.com|lanqin|%s\n' "$(cat "${FAKE_PRIVATE_KEY_FILE}")"
EOF
cat > "${TEMP_DIR}/bin/id" <<'EOF'
#!/bin/sh
exit 1
EOF
cat > "${TEMP_DIR}/bin/rspamadm" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "${FAKE_RELOAD_LOG}"
EOF
cat > "${TEMP_DIR}/bin/pkill" <<'EOF'
#!/bin/sh
printf 'unexpected pkill fallback\n' >&2
exit 1
EOF
chmod 0755 "${TEMP_DIR}/bin/sqlite3" "${TEMP_DIR}/bin/id" "${TEMP_DIR}/bin/rspamadm" "${TEMP_DIR}/bin/pkill"
export PATH="${TEMP_DIR}/bin:${PATH}"
export LANQIN_DB_PATH="${TEMP_DIR}/lanqin.db"
export LANQIN_RSPAMD_DKIM_DIR="${TEMP_DIR}/keys"
export FAKE_PRIVATE_KEY_FILE="${TEMP_DIR}/private-key.b64"
export FAKE_RELOAD_LOG="${TEMP_DIR}/reload.log"
printf 'first-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "first-private-key" ]] || fail_test "initial DKIM key was not exported"
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "initial DKIM key did not reload Rspamd"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "unchanged DKIM key reloaded Rspamd"
printf 'second-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}"
sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once
[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "second-private-key" ]] || fail_test "changed DKIM key was not exported"
[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "2" ]] || fail_test "changed DKIM key did not reload Rspamd"
printf 'DKIM sync tests passed.\n'
+128
View File
@@ -46,6 +46,10 @@ test_password_validation() {
test_mail_domain_and_admin_email_validation() {
assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion"
assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion"
assert_eq "newszxcn.com" "$(suggest_mail_domain "mail.newszxcn.com")" "NewSzxcn mail host domain suggestion"
assert_eq "admin@newszxcn.com" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.com")" "NewSzxcn default administrator email"
assert_eq "newszxcn.cm" "$(suggest_mail_domain "mail.newszxcn.cm")" "two-label suffix mail host domain suggestion"
assert_eq "admin@newszxcn.cm" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.cm")" "matching administrator email for entered domain"
LANQIN_MAIL_DOMAIN="example.com"
LANQIN_ADMIN_EMAIL="admin@example.com"
assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain"
@@ -147,6 +151,123 @@ test_menu_choice() {
unset LANQIN_MENU_ACTION
}
test_menu_rendering() (
local output
prompt_text() { printf '%b' "$1"; }
output="$(render_uninstalled_menu)"
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
output="$(render_installed_menu "运行中" "v1.2.19" "https://mail.example.com")"
for expected in \
'状态:运行中' \
'版本:v1.2.19' \
'地址:https://mail.example.com' \
'安装与维护' \
'服务管理' \
'证书与恢复' \
'账号与帮助' \
'危险操作' \
'9. 邮箱后台配置指南' \
'12. 卸载服务(保留数据)'; do
[[ "${output}" == *"${expected}"* ]] || fail_test "installed menu item missing: ${expected}"
done
)
test_menu_dispatch() (
local temp_dir action_file LANQIN_MENU_ACTION=1
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
action_file="${temp_dir}/action"
mkdir -p "${INSTALL_DIR}"
prompt_text() { :; }
do_install() { printf 'install\n' > "${action_file}"; }
do_menu
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then
fail_test "uninstalled menu accepted unavailable update action"
fi
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
menu_service_status() { printf '运行中'; }
menu_installed_version() { printf 'v1.2.19'; }
do_update() { printf 'update\n' > "${action_file}"; }
LANQIN_MENU_ACTION=2
do_menu
grep -Fq 'update' "${action_file}" || fail_test "installed menu did not dispatch update"
unset LANQIN_MENU_ACTION
)
test_menu_runtime_metadata() (
local temp_dir running="true" image_version="v1.2.19"
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
compose() {
if [[ "$*" == 'ps -q lanqin-email' ]]; then
printf 'container-id\n'
fi
}
current_image_id() { printf 'sha256:test-image\n'; }
docker() {
if [[ "$*" == 'compose version' ]]; then
return 0
fi
if [[ "$*" == *'.State.Running'* ]]; then
printf '%s\n' "${running}"
return 0
fi
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
printf '%s\n' "${image_version}"
fi
}
assert_eq "运行中" "$(menu_service_status)" "running menu service status"
assert_eq "v1.2.19" "$(menu_installed_version)" "installed menu version"
running="false"
assert_eq "已停止" "$(menu_service_status)" "stopped menu service status"
image_version="<no value>"
assert_eq "未知" "$(menu_installed_version)" "missing image version label"
)
test_incomplete_install_defaults_to_repair() (
local temp_dir action_file LANQIN_MENU_ACTION=3
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
action_file="${temp_dir}/action"
mkdir -p "${INSTALL_DIR}"
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
prompt_text() { :; }
menu_installed_version() { printf '未知'; }
do_repair_install() { printf 'repair\n' > "${action_file}"; }
do_menu
grep -Fq 'repair' "${action_file}" || fail_test "incomplete installation did not dispatch repair"
unset LANQIN_MENU_ACTION
)
test_service_commands_require_complete_installation() (
local temp_dir command_name
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
# Invoked indirectly by the service command functions under test.
# shellcheck disable=SC2317,SC2329
ensure_docker() { fail_test "service command checked Docker before installation"; }
for command_name in do_update do_status do_logs do_restart do_certificate do_rollback do_reset_admin_password do_reset_admin_two_factor do_uninstall; do
if ("${command_name}" >/dev/null 2>&1); then
fail_test "${command_name} accepted missing installation"
fi
done
)
test_admin_credentials() (
local temp_dir output
temp_dir="$(mktemp -d)"
@@ -186,6 +307,7 @@ LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
LANQIN_ADMIN_PASSWORD=old-password
EOF
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
@@ -226,6 +348,7 @@ LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
EOF
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
@@ -571,6 +694,11 @@ test_nginx_configuration
test_compose_configuration
test_legacy_configuration_is_preserved
test_menu_choice
test_menu_rendering
test_menu_dispatch
test_menu_runtime_metadata
test_incomplete_install_defaults_to_repair
test_service_commands_require_complete_installation
test_admin_credentials
test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account