Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e3b69608f | |||
| 1dbc33b0dc | |||
| 48a1d53133 | |||
| ff5578368a | |||
| fc3a3462cf | |||
| 9a572e0100 | |||
| c92140c9cc | |||
| 2fd37bf635 | |||
| 3d3a251af2 | |||
| 6ad9164be3 | |||
| ce98978ebd | |||
| ee38990ea1 | |||
| 9cb3f13b02 | |||
| d2cfad3cc4 | |||
| dbd5b95143 | |||
| 131421a0f1 | |||
| 2a26a3b127 | |||
| aeaa151e90 | |||
| 60d87a6960 | |||
| 70dd2cec4e | |||
| a9ec9360a8 | |||
| 942605b2b6 | |||
| 635ab02b29 | |||
| 4c90c1de44 | |||
| 1fa029370d | |||
| 79f920bf0b | |||
| c77f63b5af | |||
| 86773c64ca | |||
| a11e1cd2f1 | |||
| 94efc2c62b | |||
| cefbee4f7f | |||
| 06388465a2 | |||
| e6504ef492 | |||
| 39ff9ce01d | |||
| df50f8b3ef | |||
| 397ce51800 | |||
| e789cf9b14 | |||
| 7e480795fc | |||
| a2eb1fbc52 | |||
| cab71c0539 | |||
| 6301182cfc | |||
| 6d0147b639 | |||
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 | |||
| ccd5c4efd5 | |||
| da888234b9 | |||
| 2f7494e5e6 | |||
| 1350611908 | |||
| 9a489992ed | |||
| 18f8d870e8 | |||
| 6059954596 | |||
| 65bc16bd92 | |||
| 7eac123f0a | |||
| 550d40a023 | |||
| 476f1250a3 | |||
| ea7d58b610 | |||
| fbbd126ef4 | |||
| a1469da670 | |||
| 12eb9f7a7f | |||
| dd82a3b975 | |||
| a8170dfe4d | |||
| 92ff7dbfaf | |||
| a05c9db83c | |||
| 8b9250c2a7 | |||
| 969a9cb6c4 | |||
| 9edfee38ad | |||
| 72c96765ef | |||
| aa87315e94 | |||
| 8f85dc6482 | |||
| cfa84c39de | |||
| 25cbd58e1f | |||
| b8632feb86 | |||
| 89e46b5748 | |||
| 0d55200485 | |||
| 9b59629993 | |||
| 7c6b0838b5 | |||
| 2fffb27033 | |||
| 704b976812 | |||
| 39f783d3b0 | |||
| c7be61d3f0 | |||
| a46f375816 | |||
| 4ab38c6c69 | |||
| e9806b4a2a | |||
| 50d7c091bf | |||
| 21be227d7b | |||
| 68bcce8091 | |||
| 83ca0dd7d7 | |||
| 9f330fdf57 | |||
| 7f05a70f60 | |||
| 77a0fd254c | |||
| 5141917d93 | |||
| 47f782a03c | |||
| 25f54bc42f | |||
| 3afbdc4d4a | |||
| cb07d5d501 | |||
| f87ce5fa4f | |||
| eb8b999ce5 | |||
| 6a30f8ac60 | |||
| d2901dd51f | |||
| a05a7ee503 | |||
| ecc3e8a246 | |||
| ed68abb00d | |||
| b8d4553036 | |||
| 587bd77f6a | |||
| ef7af15010 | |||
| bce259ca8a | |||
| 707f11687d | |||
| cc75382869 | |||
| efcdc691de | |||
| 081cfad02d | |||
| 39f5249008 | |||
| 11734bf119 | |||
| 2558aa96ed | |||
| 98e7190512 | |||
| 4ab7815886 | |||
| 361daf8693 | |||
| d2a762a426 | |||
| 7799d5d5b2 | |||
| 07054ca342 | |||
| 84657f91f9 | |||
| a5f5adf752 | |||
| 947e3ad248 | |||
| 2a572bf13a | |||
| 2b846ac671 | |||
| b3669f189e | |||
| d28ed4adcc | |||
| b36adfdac2 | |||
| 632a8a4896 | |||
| f8d058f7e4 | |||
| 1788d49a59 |
@@ -0,0 +1,48 @@
|
||||
name: Bug report / Bug 报告
|
||||
description: Report something that is not working as expected. / 报告异常或不可用的问题。
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include passwords, tokens, API keys, verification codes, session cookies, private keys, real mailbox lists, or unreleased security vulnerability details.
|
||||
|
||||
请不要粘贴密码、Token、API Key、验证码、Session Cookie、私钥、真实邮箱列表,或未公开安全漏洞细节。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What happened? What did you expect instead? / 发生了什么?你原本期望什么?
|
||||
placeholder: "Example / 示例:发送邮件失败,页面提示 500。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment / 环境(可选)
|
||||
description: Add browser, OS, deployment mode, or related config only if helpful. / 如果有帮助,可以补充浏览器、系统、部署方式或相关配置。
|
||||
placeholder: |
|
||||
Browser and OS:
|
||||
Deployment: Docker / local / not sure
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, or steps / 日志、截图或复现步骤(可选)
|
||||
description: Redact secrets and personal data before posting. / 粘贴前请先隐藏敏感信息和个人数据。
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability / 安全漏洞
|
||||
url: https://github.com/zxyszx/NewSzxcn-Email/security/advisories/new
|
||||
about: Please report security vulnerabilities privately. / 请通过私密渠道报告安全漏洞,不要公开提交漏洞细节。
|
||||
- name: Community chat / 社区交流
|
||||
url: https://t.me/+EhII7MSyi3QwNDQ5
|
||||
about: For informal discussion and quick usage questions. / 适合非正式交流和快速使用问题。
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Deployment or configuration / 部署或配置问题
|
||||
description: Get help with Docker, domains, DNS, SMTP, IMAP, or runtime configuration. / 获取 Docker、域名、DNS、SMTP、IMAP 或运行配置帮助。
|
||||
title: "[Deploy]: "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include real passwords, tokens, API keys, private keys, full mail headers with personal data, or production mailbox lists.
|
||||
|
||||
请不要粘贴真实密码、Token、API Key、私钥、包含个人数据的完整邮件头,或生产邮箱列表。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What are you trying to do, and what failed? / 你想完成什么?哪里失败了?
|
||||
placeholder: "Example / 示例:Docker Compose 启动后 Web 可以打开,但 SMTP 587 连接失败。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment and config / 环境与配置(可选)
|
||||
description: Add OS, Docker version, DNS status, or sanitized `.env` lines only if helpful. / 如果有帮助,可以补充系统、Docker 版本、DNS 状态或脱敏后的 `.env` 行。
|
||||
placeholder: |
|
||||
OS:
|
||||
Docker / Compose:
|
||||
Domain or DNS status:
|
||||
Related config, redacted:
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots / 日志或截图(可选)
|
||||
description: Redact secrets, mailbox addresses, Message-ID values, and IPs when needed. / 请按需隐藏密钥、邮箱地址、Message-ID 和 IP。
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature request / 功能建议
|
||||
description: Suggest an improvement for LanQin Email. / 提出一个改进建议。
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What do you want to improve, and why? / 你想改进什么?为什么?
|
||||
placeholder: "Example / 示例:希望管理员可以批量创建邮箱,因为目前一个个添加比较慢。"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Question or usage help / 使用问题
|
||||
description: Ask a usage question that is not clearly a bug or feature request. / 提交不明显属于 Bug 或功能建议的使用问题。
|
||||
title: "[Question]: "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include secrets, private mailbox data, or full production logs.
|
||||
|
||||
请不要包含密钥、私人邮箱数据或完整生产日志。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What are you trying to do, and where are you stuck? / 你想完成什么?卡在哪里?
|
||||
placeholder: "Example / 示例:如何给第三方客户端配置 SMTP 和 IMAP?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本(可选)
|
||||
description: Add the LanQin Email version if relevant. / 如相关,可以补充 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 优化邮箱选择器动态宽度
|
||||
|
||||
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
|
||||
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
|
||||
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
|
||||
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 移除“加载邮箱”过渡文案
|
||||
|
||||
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
|
||||
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
|
||||
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
|
||||
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复文件夹管理
|
||||
|
||||
- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。
|
||||
- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。
|
||||
- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。
|
||||
|
||||
### 修复导入邮件的存储显示
|
||||
|
||||
- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`。
|
||||
- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。
|
||||
- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。
|
||||
|
||||
### 改进邮件下载
|
||||
|
||||
- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。
|
||||
- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。
|
||||
- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。
|
||||
|
||||
### 优化设置与后台切换
|
||||
|
||||
- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。
|
||||
- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。
|
||||
- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已通过安装脚本语法和自动化回归测试。
|
||||
- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。
|
||||
|
||||
**完整更新日志**:[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12)
|
||||
@@ -0,0 +1,30 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复选中邮件下载
|
||||
|
||||
- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。
|
||||
- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。
|
||||
|
||||
### 改进 EML 文件信息
|
||||
|
||||
- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。
|
||||
- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。
|
||||
- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。
|
||||
|
||||
### 调整邮箱与设置交互
|
||||
|
||||
- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。
|
||||
- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。
|
||||
- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。
|
||||
|
||||
### 修复最后一个邮箱删除
|
||||
|
||||
- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。
|
||||
- 删除邮箱流程的错误提示改为简体中文。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。
|
||||
|
||||
**完整更新日志**:[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13)
|
||||
@@ -0,0 +1,35 @@
|
||||
## 本次更新
|
||||
|
||||
### 统一邮箱登录与注册
|
||||
|
||||
- 前台和后台统一使用主登录邮箱与密码登录,不再接受旧用户名登录。
|
||||
- 自助注册会一次性创建账号和邮箱,并要求填写显示名称、邮箱前缀、域名和密码确认。
|
||||
- 只有主登录邮箱可以登录网页,账号下的其他邮箱不会成为额外登录入口。
|
||||
- 网页修改或管理员重置密码时,会同步更新该账号全部邮箱的 SMTP/IMAP 密码。
|
||||
|
||||
### 加强管理员与安全管理
|
||||
|
||||
- 系统只保留一名安装时创建的管理员,后台和 API 均不能新增或提升第二名管理员。
|
||||
- 内置“管理员”和“普通用户”权限配置不可修改或删除,自定义权限配置仍可正常创建和分配。
|
||||
- 双因素认证新增恢复码,服务器菜单支持重置管理员密码和管理员 2FA。
|
||||
- 保留 Cloudflare Turnstile 登录与注册防刷验证。
|
||||
|
||||
### 修复邮件批量移动与界面布局
|
||||
|
||||
- “全部邮箱”模式可以将跨邮箱选中的邮件批量移动到各邮箱对应的自定义文件夹。
|
||||
- 固定邮箱选择器、复制按钮和展开菜单宽度,切换具体邮箱时不再缩窄。
|
||||
- 邮箱页与设置页统一侧栏宽度,点击设置时分隔线和内容区域不再左右跳动。
|
||||
|
||||
### 改进安装与升级兼容
|
||||
|
||||
- 新安装默认创建 `admin@邮箱域名` 管理员邮箱,并支持自定义管理员邮箱前缀。
|
||||
- 旧数据库升级时自动保留现有管理员、邮箱、密码和双因素认证数据,不会生成错误的占位域名账号。
|
||||
- 管理员在后台修改主登录邮箱后,服务重启不会恢复成安装时的旧邮箱。
|
||||
- 安装指南、环境变量示例和管理员恢复命令已同步更新。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查和生产构建。
|
||||
- 已通过安装脚本测试、ShellCheck,以及桌面端和移动端浏览器验证。
|
||||
|
||||
**完整更新日志**:[v1.2.13...v1.2.14](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.13...v1.2.14)
|
||||
@@ -0,0 +1,14 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮箱选择器右侧空白
|
||||
|
||||
- “全部邮箱”状态下移除复制按钮的永久空白占位,选择器恢复为整行宽度。
|
||||
- 选择具体邮箱时,复制按钮覆盖显示在选择器右侧,不再挤压或缩窄选择器。
|
||||
- 下拉箭头、未读数量和复制按钮保持独立位置,长邮箱地址仍会正确截断。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、shadcn/ui 检查和生产构建。
|
||||
- 已在桌面端和 390×844 移动端验证“全部邮箱”与具体邮箱两种状态,无横向溢出或控制台错误。
|
||||
|
||||
**完整更新日志**:[v1.2.14...v1.2.15](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.14...v1.2.15)
|
||||
@@ -0,0 +1,3 @@
|
||||
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
|
||||
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
|
||||
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
|
||||
@@ -0,0 +1,6 @@
|
||||
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
|
||||
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
|
||||
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
|
||||
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
|
||||
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
|
||||
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
|
||||
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
|
||||
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
|
||||
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
|
||||
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
|
||||
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
|
||||
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
|
||||
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
|
||||
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
|
||||
@@ -0,0 +1,3 @@
|
||||
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
|
||||
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
|
||||
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
|
||||
@@ -0,0 +1,8 @@
|
||||
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
|
||||
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
|
||||
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
|
||||
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
|
||||
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
|
||||
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
|
||||
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
|
||||
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
|
||||
@@ -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 和完整发信回归测试。
|
||||
@@ -0,0 +1,5 @@
|
||||
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
|
||||
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
|
||||
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
|
||||
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
|
||||
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
|
||||
@@ -0,0 +1,3 @@
|
||||
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
|
||||
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
|
||||
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
|
||||
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
|
||||
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
|
||||
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
|
||||
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
|
||||
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
|
||||
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
|
||||
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
|
||||
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
|
||||
@@ -0,0 +1,7 @@
|
||||
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
|
||||
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
|
||||
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
|
||||
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
|
||||
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
|
||||
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
|
||||
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
|
||||
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
|
||||
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
|
||||
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
|
||||
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
|
||||
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
|
||||
@@ -0,0 +1,8 @@
|
||||
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
|
||||
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
|
||||
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
|
||||
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
|
||||
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
|
||||
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
|
||||
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
|
||||
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
|
||||
@@ -0,0 +1,13 @@
|
||||
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
|
||||
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
|
||||
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
|
||||
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
|
||||
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
|
||||
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
|
||||
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
|
||||
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
|
||||
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
|
||||
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
|
||||
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
|
||||
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
|
||||
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
|
||||
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
|
||||
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
|
||||
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
|
||||
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
|
||||
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
|
||||
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
|
||||
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
|
||||
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
|
||||
@@ -0,0 +1,7 @@
|
||||
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
|
||||
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
|
||||
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
|
||||
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
|
||||
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
|
||||
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
|
||||
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
|
||||
@@ -0,0 +1,11 @@
|
||||
- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。
|
||||
- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。
|
||||
- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。
|
||||
- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。
|
||||
- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。
|
||||
- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。
|
||||
- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。
|
||||
- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。
|
||||
- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。
|
||||
|
||||
**完整更新日志**:[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32)
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。
|
||||
- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。
|
||||
- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。
|
||||
- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。
|
||||
- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。
|
||||
- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。
|
||||
- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。
|
||||
|
||||
**完整更新日志**:[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33)
|
||||
@@ -0,0 +1,9 @@
|
||||
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
|
||||
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
|
||||
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
|
||||
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
|
||||
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
|
||||
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
|
||||
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
|
||||
|
||||
**完整更新日志**:[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
|
||||
@@ -0,0 +1,7 @@
|
||||
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
|
||||
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
|
||||
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
|
||||
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
|
||||
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
|
||||
|
||||
**完整更新日志**:[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
|
||||
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 一键部署与运维
|
||||
|
||||
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
|
||||
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
|
||||
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
|
||||
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
|
||||
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
|
||||
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
|
||||
|
||||
### 邮箱与账号体验
|
||||
|
||||
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
|
||||
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
|
||||
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
|
||||
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
|
||||
|
||||
### 自助申请邮箱
|
||||
|
||||
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`。
|
||||
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
|
||||
- 管理员可通过“前往设置”直接进入后台邮件设置页。
|
||||
- 普通用户无法自行申请时会提示联系管理员处理。
|
||||
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
|
||||
- 补充简体中文、繁体中文和英文界面文案。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
|
||||
|
||||
**完整更新日志**:[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
|
||||
@@ -0,0 +1,36 @@
|
||||
## 本次更新
|
||||
|
||||
### 邮箱管理跳转
|
||||
|
||||
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
|
||||
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
|
||||
|
||||
### 管理员密码恢复
|
||||
|
||||
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
|
||||
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
|
||||
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
|
||||
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
|
||||
|
||||
### 一键安装与运维安全
|
||||
|
||||
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
|
||||
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
|
||||
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
|
||||
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
|
||||
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
|
||||
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
|
||||
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
|
||||
|
||||
### 邮箱指南与快捷命令
|
||||
|
||||
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
|
||||
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
|
||||
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 本次更新不删除现有邮件、域名、账号、证书和配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
|
||||
|
||||
**完整更新日志**:[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
|
||||
@@ -0,0 +1,21 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复网页更新出现 502
|
||||
|
||||
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
|
||||
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
|
||||
- 前端遇到更新期间的 `502`、`503`、`504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
|
||||
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
|
||||
|
||||
### 更新说明
|
||||
|
||||
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
|
||||
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
|
||||
- 从 `v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
|
||||
|
||||
### 验证
|
||||
|
||||
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
|
||||
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
|
||||
@@ -0,0 +1,18 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮箱选择器默认状态
|
||||
|
||||
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
|
||||
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
|
||||
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
|
||||
|
||||
### 修复邮箱下拉菜单宽度
|
||||
|
||||
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
|
||||
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
|
||||
@@ -0,0 +1,25 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮件导入 413
|
||||
|
||||
- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。
|
||||
- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。
|
||||
- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。
|
||||
|
||||
### 支持大批量导入
|
||||
|
||||
- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。
|
||||
- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。
|
||||
- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。
|
||||
|
||||
### 保留历史邮件时间
|
||||
|
||||
- 导入时继续保留邮件头中的原始 `Date` 时间。
|
||||
- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。
|
||||
- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9)
|
||||
@@ -5,6 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
|
||||
permissions:
|
||||
@@ -29,6 +40,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh 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
|
||||
with:
|
||||
@@ -42,7 +62,7 @@ jobs:
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install web dependencies
|
||||
run: pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
|
||||
- name: Check shadcn/ui usage
|
||||
run: pnpm --dir apps/web run check:shadcn
|
||||
|
||||
+151
-66
@@ -1,6 +1,7 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -17,7 +18,6 @@ concurrency:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
PLATFORMS: linux/amd64
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
@@ -27,6 +27,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh 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
|
||||
with:
|
||||
@@ -35,12 +44,12 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install web dependencies
|
||||
run: pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
|
||||
- name: Check shadcn/ui usage
|
||||
run: pnpm --dir apps/web run check:shadcn
|
||||
@@ -73,10 +82,14 @@ jobs:
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
version="${tag#v}"
|
||||
version="${version#.}"
|
||||
version="${version#-}"
|
||||
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_url=${release_url}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "version=${version}"
|
||||
echo "release_url=${release_url}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Docker release tag: ${tag}"
|
||||
|
||||
docker:
|
||||
@@ -91,51 +104,60 @@ jobs:
|
||||
suffix: ""
|
||||
context: .
|
||||
file: ./deploy/all-in-one/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: api
|
||||
suffix: -api
|
||||
context: .
|
||||
file: ./deploy/api.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: web
|
||||
suffix: -web
|
||||
context: .
|
||||
file: ./deploy/web.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: postfix
|
||||
suffix: -postfix
|
||||
context: ./deploy/postfix
|
||||
file: ./deploy/postfix/Dockerfile
|
||||
platforms: linux/amd64
|
||||
- name: dovecot
|
||||
suffix: -dovecot
|
||||
context: ./deploy/dovecot
|
||||
file: ./deploy/dovecot/Dockerfile
|
||||
platforms: linux/amd64
|
||||
- name: rspamd
|
||||
suffix: -rspamd
|
||||
context: ./deploy/rspamd
|
||||
file: ./deploy/rspamd/Dockerfile
|
||||
platforms: linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare image name
|
||||
id: image
|
||||
shell: bash
|
||||
run: |
|
||||
image="${REGISTRY}/${GITHUB_REPOSITORY}${{ matrix.suffix }}"
|
||||
echo "name=${image,,}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${{ needs.release.outputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${{ needs.release.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "name=${image,,}"
|
||||
echo "tag=${{ needs.release.outputs.tag }}"
|
||||
echo "version=${{ needs.release.outputs.version }}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
@@ -148,7 +170,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
|
||||
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
|
||||
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -156,9 +178,11 @@ jobs:
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.release.outputs.tag }}
|
||||
APP_COMMIT=${{ github.sha }}
|
||||
VITE_APP_VERSION=${{ needs.release.outputs.tag }}
|
||||
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -182,75 +206,136 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
version="${{ needs.release.outputs.version }}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
repo_url="https://github.com/${repo}"
|
||||
image_base="${REGISTRY}/${repo}"
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
fi
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
if [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md
|
||||
else
|
||||
echo "- 首个公开版本。" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
fi
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
{
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||
echo
|
||||
echo
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||
else
|
||||
echo "- 首个公开版本。"
|
||||
echo
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
cp generated-release-notes.md release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
id: release_result
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin Email ${tag}"
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Notify Telegram release channel
|
||||
if: steps.release_result.outputs.created == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
RELEASE_URL: ${{ needs.release.outputs.release_url }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
|
||||
echo "::notice::Telegram release notification is not configured; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
|
||||
entries = []
|
||||
for raw in notes.splitlines():
|
||||
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
|
||||
line = re.sub(r"^[-*+]\s+", "", line)
|
||||
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
|
||||
line = re.sub(r"`([^`]+)`", r"\1", line)
|
||||
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
|
||||
if line:
|
||||
entries.append(line)
|
||||
|
||||
sections = []
|
||||
for index, entry in enumerate(entries, 1):
|
||||
parts = re.split(r"[::]", entry, maxsplit=1)
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# NewSzxcn Email
|
||||
|
||||
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
|
||||
|
||||
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Webmail with compose, drafts, attachments, search, labels, folders, reminders, import, and export
|
||||
- Multiple domains and mailboxes, DKIM, DNS checks, verified forwarding, and external IMAP
|
||||
- Incoming mail rules with conditions, ordering, forwarding, moving, and bulk application
|
||||
- Administration for users, permission quotas, domains, mailboxes, messages, and send queues
|
||||
- SMTP, IMAP, POP3, Postfix, Dovecot, Rspamd, and SMTP Submission
|
||||
- Release checks, admin-only web updates, pre-update database backups, and CLI rollback
|
||||
|
||||
## One-command install
|
||||
|
||||
Debian and Ubuntu on `amd64` or `arm64` are supported.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||
|
||||
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
|
||||
|
||||
## Update
|
||||
|
||||
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
|
||||
|
||||
CLI update and rollback:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
|
||||
|
||||
## Required ports
|
||||
|
||||
Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Public delivery also requires correct MX, SPF, DKIM, and DMARC records.
|
||||
|
||||
## Manual source deployment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -1,206 +1,245 @@
|
||||
# LanQin Email
|
||||
# NewSzxcn-Email
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
## 功能特性
|
||||
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
|
||||
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
|
||||
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
|
||||
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
|
||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||
## 主要功能
|
||||
|
||||
## 目录结构
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
|
||||
├── apps/web # React/Vite Webmail 与管理后台
|
||||
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
|
||||
└── .github/workflows # Docker 镜像发布流水线
|
||||
## 一键安装
|
||||
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
### 开发环境
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2(可通过 corepack 启用)
|
||||
### 管理面板
|
||||
|
||||
### 部署环境
|
||||
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
|
||||
```text
|
||||
==================================================
|
||||
NewSzxcn Email 管理面板
|
||||
==================================================
|
||||
状态:尚未安装
|
||||
--------------------------------------------------
|
||||
1. 一键安装 NewSzxcn Email
|
||||
0. 退出
|
||||
==================================================
|
||||
请选择 [1]:
|
||||
```
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
|
||||
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
|
||||
|
||||
## 快速开始
|
||||
```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,失败时执行完整恢复。
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 自动检测并确认邮箱地址域名;创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`;回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
### 后台页面更新
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email repair
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
至少需要以下 DNS 记录:
|
||||
|
||||
| 类型 | 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
|
||||
| MX | `example.com -> mail.example.com` | 接收邮件 |
|
||||
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
|
||||
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
|
||||
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
|
||||
|
||||
服务器防火墙和云安全组应按需开放:
|
||||
|
||||
| 端口 | 协议 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 25 | TCP | SMTP 服务器间收发信 |
|
||||
| 80 / 443 | TCP | Webmail 与证书签发 |
|
||||
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
|
||||
| 993 | TCP | IMAP SSL |
|
||||
| 995 | TCP | POP3 SSL |
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认部署目录为 `/opt/newszxcn-email`:
|
||||
|
||||
```text
|
||||
/opt/newszxcn-email/
|
||||
|-- .env # 环境配置与内部更新令牌
|
||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
|-- data/ # SQLite、附件和更新前备份
|
||||
|-- mail/ # Maildir 邮件原文
|
||||
|-- dkim/ # DKIM 私钥
|
||||
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
本地源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
go mod download
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
前端(新终端):
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问:
|
||||
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||
|
||||
- Web:`http://localhost:5173`
|
||||
- API:`http://localhost:8080`
|
||||
|
||||
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
|
||||
|
||||
### Docker 部署(单容器)
|
||||
|
||||
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
cd apps/api && go test ./...
|
||||
cd apps/web && pnpm run check
|
||||
```
|
||||
|
||||
常用命令:
|
||||
## 开源协议
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose logs -f lanqin-email
|
||||
|
||||
# 更新镜像并重启
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
如需在完整源码仓库中本地构建镜像:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
|
||||
|
||||
## 首次部署清单
|
||||
|
||||
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME`、`LANQIN_PUBLIC_BASE_URL`、`LANQIN_ADMIN_EMAIL`、`LANQIN_ADMIN_PASSWORD`。
|
||||
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`。
|
||||
3. 登录管理后台,添加邮件域名。
|
||||
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
|
||||
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
|
||||
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
|
||||
|
||||
| 变量 | 说明 | 默认/示例 |
|
||||
|------|------|-----------|
|
||||
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
|
||||
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
|
||||
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
|
||||
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
|
||||
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
|
||||
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
|
||||
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
|
||||
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
|
||||
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
|
||||
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
|
||||
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
|
||||
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
|
||||
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
|
||||
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ lanqin-email 单容器 │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
|
||||
│ │ Web 静态│ │ Webmail API│ └──────┬───────┘ │
|
||||
│ └─────────┘ └─────┬──────┘ │ │
|
||||
│ │ Maildir sync │ maps │
|
||||
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
|
||||
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
|
||||
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
|
||||
│ └─────────┘ └────────────┘ └──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
邮件流转:
|
||||
|
||||
1. **收件**:Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
|
||||
2. **发件**:Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
|
||||
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
|
||||
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```bash
|
||||
# API 测试
|
||||
cd apps/api
|
||||
go test ./...
|
||||
|
||||
# Web 检查与构建
|
||||
cd apps/web
|
||||
pnpm run check
|
||||
|
||||
# 单容器源码构建验证
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
## 生产注意事项
|
||||
|
||||
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
|
||||
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
|
||||
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
|
||||
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
|
||||
|
||||
## SMTP 提交
|
||||
|
||||
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
||||
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。
|
||||
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
||||
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
||||
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
||||
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`。
|
||||
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
[MIT](LICENSE)
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# NewSzxcn-Email
|
||||
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
|
||||
## 一键安装
|
||||
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
|
||||
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
|
||||
|
||||
### 后台页面更新
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
至少需要以下 DNS 记录:
|
||||
|
||||
| 类型 | 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
|
||||
| MX | `example.com -> mail.example.com` | 接收邮件 |
|
||||
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
|
||||
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
|
||||
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
|
||||
|
||||
服务器防火墙和云安全组应按需开放:
|
||||
|
||||
| 端口 | 协议 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 25 | TCP | SMTP 服务器间收发信 |
|
||||
| 80 / 443 | TCP | Webmail 与证书签发 |
|
||||
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
|
||||
| 993 | TCP | IMAP SSL |
|
||||
| 995 | TCP | POP3 SSL |
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认部署目录为 `/opt/newszxcn-email`:
|
||||
|
||||
```text
|
||||
/opt/newszxcn-email/
|
||||
├── .env # 环境配置与内部更新令牌
|
||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
├── data/ # SQLite、附件和更新前备份
|
||||
├── mail/ # Maildir 邮件原文
|
||||
├── dkim/ # DKIM 私钥
|
||||
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
本地源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
cd apps/api && go test ./...
|
||||
cd apps/web && pnpm run build
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
|
||||
[MIT](LICENSE)
|
||||
+9
-6
@@ -3,9 +3,14 @@ module lanqin-email-api
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.38.0
|
||||
modernc.org/sqlite v1.31.1
|
||||
)
|
||||
@@ -13,16 +18,14 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
+45
-8
@@ -2,12 +2,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -26,21 +30,54 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
@@ -49,10 +49,10 @@ 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.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,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.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
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")
|
||||
return
|
||||
@@ -62,13 +62,15 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created, mailboxCSV string
|
||||
if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &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
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
@@ -92,26 +94,34 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
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 {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
primaryEmail, err := cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
@@ -121,12 +131,28 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
if role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -142,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,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), 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
|
||||
@@ -171,10 +208,14 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
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 {
|
||||
badRequest(w, err)
|
||||
@@ -198,7 +239,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
if existing.Role == "admin" && role != "admin" {
|
||||
badRequest(w, errors.New("唯一管理员不能降级"))
|
||||
return
|
||||
}
|
||||
if existing.Role != "admin" && role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
primaryEmail := existing.Email
|
||||
loginName := existing.LoginName
|
||||
if strings.TrimSpace(emailInput) != "" {
|
||||
primaryEmail, err = cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
loginName = primaryEmail
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
@@ -210,6 +273,33 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" && disabled {
|
||||
badRequest(w, errors.New("唯一管理员不能停用"))
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||
if req.MailboxLimitOverride != nil {
|
||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -254,8 +344,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), 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
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -269,6 +363,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" {
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.AdminEmail = primaryEmail
|
||||
})
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
@@ -296,8 +395,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -343,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 {
|
||||
@@ -487,19 +583,20 @@ 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})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Role string `json:"role"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
OwnerLoginName string `json:"ownerLoginName"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -513,25 +610,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if req.QuotaMB < 0 {
|
||||
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||
return
|
||||
}
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "user" && role != "admin" {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
@@ -547,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 {
|
||||
@@ -563,34 +645,34 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if intBool(disabled) {
|
||||
badRequest(w, errors.New("owner user is disabled"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ownerEmail := normalizeEmail(req.OwnerEmail)
|
||||
if ownerEmail == "" {
|
||||
ownerEmail = address
|
||||
}
|
||||
if !strings.Contains(ownerEmail, "@") {
|
||||
badRequest(w, errors.New("invalid owner email"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
||||
ownerEmailInput := req.OwnerEmail
|
||||
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||
ownerEmailInput = req.OwnerLoginName
|
||||
}
|
||||
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id,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,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, 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
|
||||
}
|
||||
@@ -599,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")
|
||||
@@ -634,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 == "" {
|
||||
@@ -645,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 {
|
||||
@@ -663,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
|
||||
@@ -684,26 +794,17 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
} else {
|
||||
badRequest(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if current != nil && owner == current.ID {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -715,16 +816,16 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(r.Context(), messageID)
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -757,6 +858,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
user := currentUser(r)
|
||||
isSystemAdmin := user != nil && user.Role == "admin"
|
||||
wantsUnregistered := mailboxID == "unregistered" || strings.EqualFold(folder, "Unregistered")
|
||||
if wantsUnregistered && !isSystemAdmin {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -765,6 +873,9 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if !isSystemAdmin {
|
||||
where = append(where, "m.mailbox_id IS NOT NULL")
|
||||
}
|
||||
if mailboxID == "unregistered" {
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else if mailboxID != "" && mailboxID != "all" {
|
||||
@@ -786,7 +897,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -822,6 +933,11 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
user := currentUser(r)
|
||||
if msg.MailboxID == "" && (user == nil || user.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'')
|
||||
FROM messages m
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -833,6 +949,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
|
||||
event := strings.TrimSpace(r.URL.Query().Get("event"))
|
||||
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "sae.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if messageID != "" {
|
||||
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if event != "" && event != "all" {
|
||||
if !isSendAuditEvent(event) {
|
||||
badRequest(w, errors.New("invalid event"))
|
||||
return
|
||||
}
|
||||
where = append(where, "sae.event=?")
|
||||
args = append(args, event)
|
||||
}
|
||||
if from != "" {
|
||||
where = append(where, "sae.created_at>=?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "sae.created_at<=?")
|
||||
args = append(args, to)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
|
||||
FROM send_audit_events sae
|
||||
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
|
||||
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
|
||||
LEFT JOIN messages m ON m.id=sae.sent_message_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
if endOfDay {
|
||||
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
return "", errors.New("invalid time filter")
|
||||
}
|
||||
|
||||
func isSendAuditEvent(event string) bool {
|
||||
switch event {
|
||||
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
@@ -854,7 +1080,7 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
destination := normalizeEmail(req.Destination)
|
||||
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
||||
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
|
||||
badRequest(w, errors.New("invalid alias"))
|
||||
return
|
||||
}
|
||||
@@ -899,7 +1125,7 @@ func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
destination := normalizeEmail(req.Destination)
|
||||
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
||||
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
|
||||
badRequest(w, errors.New("invalid alias"))
|
||||
return
|
||||
}
|
||||
@@ -945,18 +1171,20 @@ 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.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,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.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,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.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &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)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||
@@ -1016,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
|
||||
@@ -1039,6 +1306,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
||||
}
|
||||
role := strings.ToLower(folder)
|
||||
id = newID("fld")
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
|
||||
sortOrder := 0
|
||||
if !isSystemFolderName(folder) {
|
||||
var err error
|
||||
sortOrder, err = a.nextCustomFolderSortOrder(ctx, mailboxID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, sortOrder, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const defaultAPITokenTTL = 90 * 24 * time.Hour
|
||||
|
||||
var validAPITokenScopes = map[string]bool{
|
||||
"*": true,
|
||||
"domains:read": true,
|
||||
"domains:write": true,
|
||||
"mailboxes:read": true,
|
||||
"mailboxes:write": true,
|
||||
"messages:read": true,
|
||||
"messages:send": true,
|
||||
"messages:manage": true,
|
||||
"aliases:read": true,
|
||||
"aliases:write": true,
|
||||
"dns:read": true,
|
||||
"dns:check": true,
|
||||
}
|
||||
|
||||
func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
|
||||
FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []APIToken{}
|
||||
for rows.Next() {
|
||||
item, err := scanAPIToken(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan api tokens")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Scopes json.RawMessage `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseOptionalFutureTime(req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if expiresAt == nil {
|
||||
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
|
||||
expiresAt = &defaultExpiry
|
||||
}
|
||||
var requestedScopes []string
|
||||
if len(req.Scopes) > 0 {
|
||||
if string(req.Scopes) == "null" || json.Unmarshal(req.Scopes, &requestedScopes) != nil {
|
||||
badRequest(w, errors.New("scopes must be an array of strings"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requestedScopes = nil
|
||||
}
|
||||
scopes, err := normalizeAPITokenScopes(requestedScopes)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
id := newID("apt")
|
||||
token := "lq_" + randomToken()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var expiresValue any
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,scopes_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, jsonEncode(scopes), now, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create api token")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"token": token, "item": item})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
current, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
name := current.Name
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
var expiresValue any
|
||||
if current.ExpiresAt != nil {
|
||||
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
if strings.TrimSpace(*req.ExpiresAt) == "" {
|
||||
badRequest(w, errors.New("expiresAt must be an RFC3339 timestamp"))
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
expiresValue = nil
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
disabled := current.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
scopes := current.Scopes
|
||||
if req.Scopes != nil {
|
||||
scopes, err = normalizeAPITokenScopes(*req.Scopes)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,scopes_json=?,updated_at=? WHERE id=? AND user_id=?`,
|
||||
name, expiresValue, boolInt(disabled), jsonEncode(scopes), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM api_tokens WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
|
||||
FROM api_tokens WHERE id=? AND user_id=?`, id, userID)
|
||||
return scanAPIToken(row)
|
||||
}
|
||||
|
||||
type apiTokenScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAPIToken(row apiTokenScanner) (APIToken, error) {
|
||||
var item APIToken
|
||||
var lastUsed, expires sql.NullString
|
||||
var disabled int
|
||||
var scopesJSON, created, updated string
|
||||
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &scopesJSON, &created, &updated); err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.LastUsedAt = nullableTime(lastUsed)
|
||||
item.ExpiresAt = nullableTime(expires)
|
||||
item.Disabled = intBool(disabled)
|
||||
item.Scopes = jsonDecodeSlice(scopesJSON)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeAPITokenScopes(scopes []string) ([]string, error) {
|
||||
if scopes == nil {
|
||||
return []string{"*"}, nil
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil, errors.New("at least one api token scope is required")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
if !validAPITokenScopes[scope] {
|
||||
return nil, fmt.Errorf("invalid api token scope: %s", scope)
|
||||
}
|
||||
if !seen[scope] {
|
||||
seen[scope] = true
|
||||
out = append(out, scope)
|
||||
}
|
||||
}
|
||||
if seen["*"] && len(out) != 1 {
|
||||
return nil, errors.New("wildcard scope cannot be combined with other scopes")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return nil, errors.New("expiresAt must be an RFC3339 timestamp")
|
||||
}
|
||||
t = t.UTC()
|
||||
if !t.After(now) {
|
||||
return nil, errors.New("expiresAt must be in the future")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
+1087
-141
File diff suppressed because it is too large
Load Diff
+4415
-108
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
@@ -34,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
||||
respondError(w, http.StatusUnauthorized, "验证码错误")
|
||||
return
|
||||
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
|
||||
if consumeErr != nil || !ok {
|
||||
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
@@ -49,17 +54,25 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
email, err := cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||
if err != nil || user.Disabled {
|
||||
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
|
||||
@@ -76,7 +89,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.OpenRegistration {
|
||||
if !a.config().OpenRegistration {
|
||||
respondError(w, http.StatusForbidden, "当前未开放注册")
|
||||
return
|
||||
}
|
||||
@@ -96,23 +109,54 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
email, err := cleanPrimaryEmail(req.Email)
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.Split(email, "@")[0]
|
||||
badRequest(w, errors.New("请输入显示名称"))
|
||||
return
|
||||
}
|
||||
if len([]rune(displayName)) > 80 {
|
||||
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
|
||||
if mailboxLocalPart == "" {
|
||||
mailboxLocalPart = normalizeLocalPart(parts[0])
|
||||
}
|
||||
mailboxDomainID := strings.TrimSpace(req.DomainID)
|
||||
var mailboxDomain string
|
||||
if mailboxDomainID != "" {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
badRequest(w, errors.New("所选邮箱域名不可用"))
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
|
||||
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
|
||||
return
|
||||
}
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
if item == mailboxLocalPart {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
return
|
||||
@@ -127,8 +171,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,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
|
||||
@@ -136,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -145,46 +207,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a mailbox for the registered user
|
||||
var mailboxDomainID string
|
||||
var mailboxLocalPart string
|
||||
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
|
||||
// User selected a specific domain and local part
|
||||
mailboxDomainID = strings.TrimSpace(req.DomainID)
|
||||
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
|
||||
} else {
|
||||
// Auto-detect: use the first active domain and email local part
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
|
||||
mailboxDomainID = ""
|
||||
}
|
||||
if mailboxDomainID != "" {
|
||||
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
|
||||
}
|
||||
}
|
||||
if mailboxDomainID != "" && mailboxLocalPart != "" {
|
||||
// Check reserved prefixes
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[mailboxLocalPart] {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
|
||||
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
|
||||
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -194,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
@@ -234,8 +268,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
||||
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
|
||||
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
|
||||
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
|
||||
}))
|
||||
defer telegramServer.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = telegramServer.URL
|
||||
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
|
||||
t.Fatalf("unexpected groups: %+v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != int64(len("encrypted backup")) {
|
||||
t.Fatalf("upload size = %d", size)
|
||||
}
|
||||
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||
}
|
||||
var metadata struct {
|
||||
Name string `json:"name"`
|
||||
Parents []string `json:"parents"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||
t.Fatalf("metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||
var updates []int64
|
||||
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||
updates = append(updates, uploaded)
|
||||
}}
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil || string(raw) != "encrypted backup" {
|
||||
t.Fatalf("read = %q, %v", raw, err)
|
||||
}
|
||||
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||
t.Fatalf("progress updates = %v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||
if !strings.Contains(message, test.want) {
|
||||
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||
size := int64(googleDriveUploadChunkSize + 3)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = file.Close()
|
||||
var ranges []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
if len(ranges) == 1 {
|
||||
w.WriteHeader(http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
if !a.startBackupTransfer("googleDrive", path) {
|
||||
t.Fatal("failed to start transfer")
|
||||
}
|
||||
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRanges := []string{
|
||||
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||
}
|
||||
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||
}
|
||||
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||
if transfer == nil || transfer.Uploaded != size {
|
||||
t.Fatalf("transfer = %+v", transfer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
|
||||
})
|
||||
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
|
||||
t.Fatal("backup password encryption succeeded without a deployment secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordValidation(t *testing.T) {
|
||||
for _, valid := range []string{"12345678", "Restore Password 123!"} {
|
||||
if !validBackupPassword(valid) {
|
||||
t.Errorf("valid password rejected: %q", valid)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
|
||||
if validBackupPassword(invalid) {
|
||||
t.Errorf("invalid password accepted: %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordHint(t *testing.T) {
|
||||
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||
t.Fatalf("password hint = %q", got)
|
||||
}
|
||||
if got := backupPasswordHint("ab"); got != "ab" {
|
||||
t.Fatalf("two-character password hint = %q", got)
|
||||
}
|
||||
if got := backupPasswordHint(""); got != "" {
|
||||
t.Fatalf("empty password hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "A23456789Z" {
|
||||
t.Fatalf("saved password = %q, %v", password, err)
|
||||
}
|
||||
schedule, err := a.loadBackupSchedule(context.Background())
|
||||
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for key, value := range map[string]string{
|
||||
"backupScheduleEnabled": "true",
|
||||
"backupScheduleDays": "30",
|
||||
"backupTelegramMode": "custom",
|
||||
"backupTelegramChatId": "-1001234567890",
|
||||
"backupGoogleFolderName": "Existing Backups",
|
||||
} {
|
||||
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("password update code=%d body=%v", code, response)
|
||||
}
|
||||
if response["passwordHint"] != "N••••••••••9" {
|
||||
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "NewSharedPassword9" {
|
||||
t.Fatalf("saved password = %q, %v", password, err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"backupScheduleEnabled": "true",
|
||||
"backupScheduleDays": "30",
|
||||
"backupTelegramMode": "custom",
|
||||
"backupTelegramChatId": "-1001234567890",
|
||||
"backupGoogleFolderName": "Existing Backups",
|
||||
} {
|
||||
var got string
|
||||
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deployDir := filepath.Join(dir, "deploy")
|
||||
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "SharedBackupPassword9" {
|
||||
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicServerIPValidation(t *testing.T) {
|
||||
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||
if !isPublicIP(net.ParseIP(value)) {
|
||||
t.Errorf("public IP rejected: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
|
||||
if isPublicIP(net.ParseIP(value)) {
|
||||
t.Errorf("non-public IP accepted: %s", value)
|
||||
}
|
||||
}
|
||||
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
|
||||
t.Fatalf("literal public IP = %q", got)
|
||||
}
|
||||
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
|
||||
t.Fatalf("literal private IP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRuntimeBackupEnv(t *testing.T) {
|
||||
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
|
||||
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
|
||||
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
|
||||
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
|
||||
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := writeRuntimeBackupEnv(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := string(raw)
|
||||
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
|
||||
if !strings.Contains(contents, expected) {
|
||||
t.Errorf("backup environment missing %q: %s", expected, contents)
|
||||
}
|
||||
}
|
||||
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
|
||||
if strings.Contains(contents, excluded) {
|
||||
t.Fatalf("backup environment included excluded value %q", excluded)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
if !a.backupAssetsAvailable() {
|
||||
t.Fatal("bundled compose did not enable complete backups")
|
||||
}
|
||||
if err := os.Remove(compose); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.backupAssetsAvailable() {
|
||||
t.Fatal("missing bundled compose incorrectly enabled complete backups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
|
||||
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
|
||||
if err != nil || ciphertext == "BackupPassword123!" {
|
||||
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
|
||||
}
|
||||
plain, err := a.decryptBackupPassword(ciphertext)
|
||||
if err != nil || plain != "BackupPassword123!" {
|
||||
t.Fatalf("password decryption = %q, %v", plain, err)
|
||||
}
|
||||
if !validTelegramPrivateChatID("-1001234567890") {
|
||||
t.Fatal("private Telegram group chat ID was rejected")
|
||||
}
|
||||
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
|
||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := a.backupTelegramReport(context.Background(), path, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
|
||||
if !strings.Contains(report, expected) {
|
||||
t.Errorf("report missing %q: %s", expected, report)
|
||||
}
|
||||
}
|
||||
if strings.Contains(report, "newszxcn.com(管理员)") {
|
||||
t.Fatal("domain list incorrectly contains account role")
|
||||
}
|
||||
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
|
||||
t.Fatal("report leaked a password")
|
||||
}
|
||||
}
|
||||
+122
-66
@@ -8,77 +8,133 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
Addr string
|
||||
AppVersion string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
MailDomain string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
ExternalIMAPEnabled bool
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
ExternalIMAPGmailClientID string
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
TelegramMailEnabled bool
|
||||
TelegramBotToken string
|
||||
TelegramPrivateChatID string
|
||||
TelegramBodyMode string
|
||||
TelegramMailboxIDs string
|
||||
TelegramIncludeUnregistered bool
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
DeliveryWebhookSecret string
|
||||
StatusWebhookURL string
|
||||
StatusWebhookSecret string
|
||||
StatusWebhookAllowPrivateHosts bool
|
||||
ReleaseAPIURL string
|
||||
UpdateServiceURL string
|
||||
UpdateServiceToken string
|
||||
BackupSourceDir string
|
||||
BackupDir string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
dataDir := getenv("LANQIN_DATA_DIR", "./data")
|
||||
return Config{
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
AppVersion: getenv("LANQIN_APP_VERSION", BuildVersion),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
|
||||
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
ExternalIMAPEnabled: getenvBool("LANQIN_EXTERNAL_IMAP_ENABLED", false),
|
||||
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
|
||||
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
|
||||
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
|
||||
ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""),
|
||||
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", ""),
|
||||
StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""),
|
||||
StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""),
|
||||
StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false),
|
||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
|
||||
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
|
||||
name := strings.TrimSuffix(d.Name, ".")
|
||||
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
|
||||
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
|
||||
return []DNSRecord{
|
||||
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
|
||||
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
|
||||
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
for _, item := range mx {
|
||||
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
|
||||
found = append(found, entry)
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const forwardingHeaderName = "X-LanQin-Forwarded-By"
|
||||
|
||||
func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) {
|
||||
targets, userID, mailboxAddress, err := a.inboundForwardingTargets(ctx, mailboxID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err)
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 || userID == "" || mailboxAddress == "" {
|
||||
return
|
||||
}
|
||||
self := normalizeEmail(mailboxAddress)
|
||||
filteredTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == self {
|
||||
continue
|
||||
}
|
||||
filteredTargets = append(filteredTargets, target)
|
||||
}
|
||||
targets = dedupeEmails(filteredTargets)
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
raw, err = a.forwardingRawMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to load raw message for forwarding", "message", messageID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasForwardingHeader(raw) {
|
||||
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||
return
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
rfcMessageID = messageID
|
||||
}
|
||||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mailboxID,
|
||||
SentMessageID: messageID,
|
||||
MessageID: rfcMessageID,
|
||||
Source: sendSourceForwarding,
|
||||
MailFrom: mailboxAddress,
|
||||
HeaderFrom: mailboxAddress,
|
||||
Recipients: targets,
|
||||
MIMEBytes: forwarded,
|
||||
Now: a.now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","), "error", err)
|
||||
return
|
||||
}
|
||||
if queueID == "" {
|
||||
a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID string, action MailRuleAction) error {
|
||||
var userID, mailboxAddress string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&userID, &mailboxAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self := normalizeEmail(mailboxAddress)
|
||||
filteredTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == self {
|
||||
continue
|
||||
}
|
||||
filteredTargets = append(filteredTargets, target)
|
||||
}
|
||||
targets = dedupeEmails(filteredTargets)
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := a.forwardingRawMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasForwardingHeader(raw) {
|
||||
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||
return nil
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
rfcMessageID = messageID
|
||||
}
|
||||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mailboxID,
|
||||
SentMessageID: messageID,
|
||||
MessageID: ruleForwardQueueMessageID(rfcMessageID, targets),
|
||||
Source: sendSourceRuleForwarding,
|
||||
MailFrom: mailboxAddress,
|
||||
HeaderFrom: mailboxAddress,
|
||||
Recipients: targets,
|
||||
MIMEBytes: forwarded,
|
||||
Now: a.now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queueID == "" {
|
||||
a.log.Warn("rule forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
|
||||
var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
|
||||
FROM mailboxes mb
|
||||
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id
|
||||
LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id
|
||||
WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &mailboxTargetsJSON, &accountTarget, &accountTargetsJSON)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||||
if len(targets) == 0 {
|
||||
return nil, userID, mailboxAddress, nil
|
||||
}
|
||||
verifiedTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
verified, err := a.forwardingEmailVerified(ctx, userID, target)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if verified {
|
||||
verifiedTargets = append(verifiedTargets, target)
|
||||
}
|
||||
}
|
||||
return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
|
||||
}
|
||||
|
||||
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(msg.RawPath) != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(msg.RawPath); err == nil && ok {
|
||||
if raw, err := os.ReadFile(msg.RawPath); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
func splitRuleForwardTargets(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == ',' || r == ',' || r == ';' || r == ';'
|
||||
})
|
||||
}
|
||||
|
||||
func ruleForwardQueueMessageID(messageID string, targets []string) string {
|
||||
base := strings.TrimSpace(messageID)
|
||||
if base == "" {
|
||||
base = newID("ruleforward")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(dedupeEmails(targets), ",")))
|
||||
return base + "#rule-forward-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func hasForwardingHeader(raw []byte) bool {
|
||||
header := raw
|
||||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||||
header = raw[:idx]
|
||||
} else if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||||
header = raw[:idx]
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(header)), strings.ToLower(forwardingHeaderName)+":")
|
||||
}
|
||||
|
||||
func addForwardingHeaders(raw []byte, mailboxAddress, hostname string) []byte {
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
if hostname == "" {
|
||||
hostname = "lanqin.local"
|
||||
}
|
||||
header := fmt.Sprintf("%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n", forwardingHeaderName, hostname, normalizeEmail(mailboxAddress))
|
||||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||||
out := make([]byte, 0, len(raw)+len(header))
|
||||
out = append(out, raw[:idx]...)
|
||||
out = append(out, []byte("\r\n"+header)...)
|
||||
out = append(out, raw[idx+2:]...)
|
||||
return out
|
||||
}
|
||||
if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||||
lfHeader := strings.ReplaceAll(header, "\r\n", "\n")
|
||||
out := make([]byte, 0, len(raw)+len(lfHeader))
|
||||
out = append(out, raw[:idx]...)
|
||||
out = append(out, []byte("\n"+lfHeader)...)
|
||||
out = append(out, raw[idx+1:]...)
|
||||
return out
|
||||
}
|
||||
return append([]byte(header+"\r\n"), raw...)
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const forwardingVerificationTTL = 24 * time.Hour
|
||||
|
||||
type ForwardingVerifiedEmail struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
|
||||
VerificationSentAt *time.Time `json:"verificationSentAt,omitempty"`
|
||||
VerificationExpiresAt *time.Time `json:"verificationExpiresAt,omitempty"`
|
||||
DeliveryStatus string `json:"deliveryStatus,omitempty"`
|
||||
DeliveryError string `json:"deliveryError,omitempty"`
|
||||
}
|
||||
|
||||
type MailboxForwardingRule struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
|
||||
type ForwardingSettings struct {
|
||||
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
|
||||
AccountTargetEmail string `json:"accountTargetEmail"`
|
||||
AccountTargetEmails []string `json:"accountTargetEmails"`
|
||||
MailboxRules []MailboxForwardingRule `json:"mailboxRules"`
|
||||
}
|
||||
|
||||
func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
email, ok := a.cleanForwardingVerificationEmail(w, r, user.ID, req.Email)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, verified, err := a.forwardingVerifiedEmailState(r.Context(), user.ID, email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load verified email")
|
||||
return
|
||||
}
|
||||
if verified {
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
id = newID("fwd")
|
||||
}
|
||||
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, errors.Is(err, sql.ErrNoRows)); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save verified email")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleResendForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
var email string
|
||||
var verified int
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT email,verified FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email, &verified)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load verified email")
|
||||
return
|
||||
}
|
||||
if intBool(verified) {
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, false); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to resend verification email")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效")
|
||||
return
|
||||
}
|
||||
var id, email string
|
||||
var verified int
|
||||
var expiresRaw sql.NullString
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT id,email,verified,verification_expires_at FROM forwarding_verified_emails WHERE verification_token_hash=?`, hashToken(token)).Scan(&id, &email, &verified, &expiresRaw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效或已使用")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, "", "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if intBool(verified) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已经验证完成")
|
||||
return
|
||||
}
|
||||
if expiresRaw.Valid && expiresRaw.String != "" && parseTime(expiresRaw.String).Before(a.now().UTC()) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, email, "验证链接已过期,请回到设置页重新发送")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE forwarding_verified_emails
|
||||
SET verified=1,verified_at=?,delivery_status='verified',delivery_error='',updated_at=?
|
||||
WHERE id=?`, now, now, id)
|
||||
if err != nil {
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
var email string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT email FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email); err != nil {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete verified email")
|
||||
return
|
||||
}
|
||||
if err := a.removeForwardingTargetFromSettings(r.Context(), tx, user.ID, email, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save forwarding settings")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := firstForwardingTarget(targets)
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,target_emails,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
|
||||
user.ID, target, jsonEncode(targets), now)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save account forwarding")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if mailboxID == "" {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
if ok, err := a.userOwnsMailboxID(r.Context(), user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
} else if !ok {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := firstForwardingTarget(targets)
|
||||
if len(targets) == 0 {
|
||||
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,target_emails,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
|
||||
mailboxID, target, jsonEncode(targets), now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
|
||||
return
|
||||
}
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) forwardingSettings(ctx context.Context, userID string) (ForwardingSettings, error) {
|
||||
settings := ForwardingSettings{
|
||||
VerifiedEmails: []ForwardingVerifiedEmail{},
|
||||
MailboxRules: []MailboxForwardingRule{},
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT fve.id,fve.email,fve.verified,fve.created_at,
|
||||
fve.verified_at,fve.verification_sent_at,fve.verification_expires_at,
|
||||
COALESCE(NULLIF(sq.status,''), fve.delivery_status),
|
||||
COALESCE(NULLIF(sq.last_error,''), fve.delivery_error)
|
||||
FROM forwarding_verified_emails fve
|
||||
LEFT JOIN send_queue sq ON sq.id=fve.delivery_queue_id
|
||||
WHERE fve.user_id=?
|
||||
ORDER BY fve.created_at DESC,fve.email`, userID)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item ForwardingVerifiedEmail
|
||||
var verified int
|
||||
var created string
|
||||
var verifiedAt, sentAt, expiresAt sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.Email, &verified, &created, &verifiedAt, &sentAt, &expiresAt, &item.DeliveryStatus, &item.DeliveryError); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
item.Verified = intBool(verified)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.VerifiedAt = nullableTime(verifiedAt)
|
||||
item.VerificationSentAt = nullableTime(sentAt)
|
||||
item.VerificationExpiresAt = nullableTime(expiresAt)
|
||||
settings.VerifiedEmails = append(settings.VerifiedEmails, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
var accountTarget, accountTargetsJSON string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return settings, err
|
||||
}
|
||||
settings.AccountTargetEmails = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
||||
settings.AccountTargetEmail = firstForwardingTarget(settings.AccountTargetEmails)
|
||||
rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
|
||||
FROM mailbox_forwarding_settings mfs
|
||||
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
|
||||
WHERE mb.user_id=? AND (mfs.target_email<>'' OR mfs.target_emails<>'[]')
|
||||
ORDER BY mb.address`, userID)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item MailboxForwardingRule
|
||||
var target, targetsJSON string
|
||||
if err := rows.Scan(&item.MailboxID, &target, &targetsJSON); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
item.TargetEmails = forwardingTargetsFromStored(target, targetsJSON)
|
||||
item.TargetEmail = firstForwardingTarget(item.TargetEmails)
|
||||
if len(item.TargetEmails) > 0 {
|
||||
settings.MailboxRules = append(settings.MailboxRules, item)
|
||||
}
|
||||
}
|
||||
return settings, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email string, insert bool) error {
|
||||
now := a.now().UTC()
|
||||
expires := now.Add(forwardingVerificationTTL)
|
||||
token := randomToken()
|
||||
nowRaw := now.Format(time.RFC3339Nano)
|
||||
expiresRaw := expires.Format(time.RFC3339Nano)
|
||||
if insert {
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,verified_at,verification_token_hash,verification_sent_at,verification_expires_at,delivery_queue_id,delivery_status,delivery_error,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, userID, email, 0, nil, hashToken(token), nowRaw, expiresRaw, "", sendQueueStatusQueued, "", nowRaw, nowRaw); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails
|
||||
SET verified=0,verified_at=NULL,verification_token_hash=?,verification_sent_at=?,verification_expires_at=?,delivery_queue_id='',delivery_status=?,delivery_error='',updated_at=?
|
||||
WHERE id=? AND user_id=?`,
|
||||
hashToken(token), nowRaw, expiresRaw, sendQueueStatusQueued, nowRaw, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
queueID, err := a.sendForwardingVerificationEmail(ctx, userID, email, token, now)
|
||||
if err != nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_status=?,delivery_error=?,updated_at=? WHERE id=? AND user_id=?`, sendQueueStatusFailed, err.Error(), nowRaw, id, userID)
|
||||
return nil
|
||||
}
|
||||
if queueID != "" {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_queue_id=?,delivery_status=?,delivery_error='',updated_at=? WHERE id=? AND user_id=?`, queueID, sendQueueStatusQueued, nowRaw, id, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return "", errors.New("SMTP 未配置,无法发送验证邮件")
|
||||
}
|
||||
mb, err := a.primaryMailboxForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fromDomain := domainPart(mb.Address)
|
||||
from := "noreply@" + fromDomain
|
||||
link := a.forwardingVerificationURL(token)
|
||||
text := "邮箱转发验证\n\n您正在将此邮箱添加为邮件转发目标地址。请打开以下链接完成验证:\n" + link + "\n\n此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。"
|
||||
html := `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;line-height:1.6;padding:32px 24px">
|
||||
<div style="max-width:640px;margin:0 auto">
|
||||
<h1 style="font-size:28px;line-height:1.25;margin:0 0 28px;font-weight:700">邮箱转发验证</h1>
|
||||
<p style="font-size:17px;margin:0 0 28px">您正在将此邮箱添加为邮件转发目标地址。请点击下方按钮完成验证:</p>
|
||||
<p style="text-align:center;margin:0 0 34px"><a href="` + htmlEscape(link) + `" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:8px;padding:14px 38px;font-size:18px;font-weight:700">确认验证</a></p>
|
||||
<p style="font-size:15px;color:#6b7280;margin:0 0 12px">如果按钮无法点击,请复制以下链接到浏览器:</p>
|
||||
<p style="font-size:15px;color:#6b7280;word-break:break-all;margin:0 0 28px">` + htmlEscape(link) + `</p>
|
||||
<p style="font-size:15px;color:#9ca3af;margin:0">此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。</p>
|
||||
</div></div>`
|
||||
messageID := fmt.Sprintf("<%s@%s>", newID("fwdverify"), fromDomain)
|
||||
mimeBytes, err := BuildMIME(MIMEMessage{From: from, FromName: "noreply", To: []string{targetEmail}, Subject: "邮箱转发验证", Text: text, HTML: html, MessageID: messageID, Date: now})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mb.ID,
|
||||
MessageID: messageID,
|
||||
Source: sendSourceForwardingVerification,
|
||||
MailFrom: from,
|
||||
HeaderFrom: from,
|
||||
Recipients: []string{targetEmail},
|
||||
MIMEBytes: mimeBytes,
|
||||
Now: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) forwardingVerificationURL(token string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
|
||||
if base == "" {
|
||||
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
|
||||
}
|
||||
return base + "/api/verify-email?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int, ok bool, email, message string) {
|
||||
title := "邮箱转发验证"
|
||||
heading := "验证失败"
|
||||
color := "#dc2626"
|
||||
statusMark := "!"
|
||||
closingMessage := "请联系验证发起人重新发送链接"
|
||||
if ok {
|
||||
heading = "验证完成"
|
||||
color = "#16a34a"
|
||||
statusMark = "✓"
|
||||
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: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) {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return "", false
|
||||
}
|
||||
if owns, err := a.userOwnsMailboxAddress(r.Context(), userID, email); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return "", false
|
||||
} else if owns {
|
||||
badRequest(w, errors.New("不能把当前账号邮箱作为转发验证邮箱"))
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func (a *App) forwardingVerifiedEmailState(ctx context.Context, userID, email string) (id string, verified bool, err error) {
|
||||
var verifiedInt int
|
||||
err = a.db.QueryRowContext(ctx, `SELECT id,verified FROM forwarding_verified_emails WHERE user_id=? AND email=?`, userID, normalizeEmail(email)).Scan(&id, &verifiedInt)
|
||||
return id, intBool(verifiedInt), err
|
||||
}
|
||||
|
||||
func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox, error) {
|
||||
var mb Mailbox
|
||||
var created string
|
||||
err := 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,id LIMIT 1`, userID).
|
||||
Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &mb.QuotaMB, &mb.Status, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return mb, errors.New("当前账号没有可用于发送验证邮件的邮箱")
|
||||
}
|
||||
mb.CreatedAt = parseTime(created)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM forwarding_verified_emails WHERE user_id=? AND email=? AND verified=1`, userID, normalizeEmail(email)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func forwardingTargetsFromRequest(targetEmail string, targetEmails []string) []string {
|
||||
if len(targetEmails) > 0 {
|
||||
return targetEmails
|
||||
}
|
||||
if strings.TrimSpace(targetEmail) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{targetEmail}
|
||||
}
|
||||
|
||||
func (a *App) cleanForwardingTargets(ctx context.Context, userID string, values []string) ([]string, error) {
|
||||
targets := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.EqualFold(value, "none") {
|
||||
continue
|
||||
}
|
||||
target := normalizeEmail(value)
|
||||
if target == "" || !strings.Contains(target, "@") {
|
||||
return nil, errors.New("转发邮箱无效")
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
ok, err := a.forwardingEmailVerified(ctx, userID, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("请先完成邮箱验证")
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func forwardingTargetsFromStored(targetEmail, targetsJSON string) []string {
|
||||
targets := dedupeEmails(jsonDecodeSlice(targetsJSON))
|
||||
if len(targets) > 0 {
|
||||
return targets
|
||||
}
|
||||
target := normalizeEmail(targetEmail)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{target}
|
||||
}
|
||||
|
||||
func firstForwardingTarget(targets []string) string {
|
||||
if len(targets) == 0 {
|
||||
return ""
|
||||
}
|
||||
return targets[0]
|
||||
}
|
||||
|
||||
func removeForwardingTarget(targets []string, email string) []string {
|
||||
email = normalizeEmail(email)
|
||||
next := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == email {
|
||||
continue
|
||||
}
|
||||
next = append(next, normalizeEmail(target))
|
||||
}
|
||||
return dedupeEmails(next)
|
||||
}
|
||||
|
||||
func (a *App) removeForwardingTargetFromSettings(ctx context.Context, tx *sql.Tx, userID, email, now string) error {
|
||||
var accountTarget, accountTargetsJSON string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
} else if err == nil {
|
||||
targets := removeForwardingTarget(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), email)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE account_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE user_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
|
||||
FROM mailbox_forwarding_settings mfs
|
||||
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
|
||||
WHERE mb.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type mailboxRow struct {
|
||||
id string
|
||||
target string
|
||||
targetsJSON string
|
||||
}
|
||||
var items []mailboxRow
|
||||
for rows.Next() {
|
||||
var item mailboxRow
|
||||
if err := rows.Scan(&item.id, &item.target, &item.targetsJSON); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
targets := removeForwardingTarget(forwardingTargetsFromStored(item.target, item.targetsJSON), email)
|
||||
if len(targets) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE mailbox_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE mailbox_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) userOwnsMailboxID(ctx context.Context, userID, mailboxID string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) userOwnsMailboxAddress(ctx context.Context, userID, address string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE user_id=? AND address=? AND status='active'`, userID, normalizeEmail(address)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type imapMetadata struct {
|
||||
UID int64
|
||||
ModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.backfillIMAPUIDs(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "folders", "sort_order", `ALTER TABLE folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders WHERE lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash') ORDER BY mailbox_id, created_at, name, id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
folderIDs = append(folderIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
order := customFolderDefaultSortOrderBase + 1
|
||||
for _, id := range folderIDs {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET sort_order=? WHERE id=? AND sort_order=0`, order, id); err != nil {
|
||||
return err
|
||||
}
|
||||
order++
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
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, ¬Null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, alterSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderIDs []string
|
||||
for rows.Next() {
|
||||
var folderID string
|
||||
if err := rows.Scan(&folderID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
folderIDs = append(folderIDs, folderID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, folderID := range folderIDs {
|
||||
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var messageIDs []string
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var maxUID, maxModSeq int64
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) newUIDValidity() int64 {
|
||||
value := a.now().UTC().Unix()
|
||||
if value <= 0 {
|
||||
return time.Now().UTC().Unix()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
|
||||
if folderID == "" {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
var nextUID, highestModSeq int64
|
||||
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
|
||||
if err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
if nextUID < 1 {
|
||||
nextUID = 1
|
||||
}
|
||||
nextModSeq := highestModSeq + 1
|
||||
if nextModSeq < 1 {
|
||||
nextModSeq = 1
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
|
||||
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
var current int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(¤t); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
next := current + 1
|
||||
if next < 1 || next == math.MaxInt64 {
|
||||
next = current
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
var dbFolderID sql.NullString
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !dbFolderID.Valid || dbFolderID.String == "" {
|
||||
return 0, nil
|
||||
}
|
||||
folderID = dbFolderID.String
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if modSeq == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
||||
return modSeq, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxMailImportBytes int64 = 256 << 20
|
||||
|
||||
const maxSelectedMailExport = 200
|
||||
|
||||
type exportedMessage struct {
|
||||
raw []byte
|
||||
subject string
|
||||
date time.Time
|
||||
}
|
||||
|
||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := a.exportMessageIDs(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "mailbox or label not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errSystemAdminRequired) {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := make(map[string]int, len(ids))
|
||||
for _, id := range ids {
|
||||
message, err := a.rawMessageForExport(r.Context(), id)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
|
||||
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
|
||||
header.SetModTime(message.date)
|
||||
entry, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(message.raw); err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = zw.Close()
|
||||
}
|
||||
|
||||
var errSystemAdminRequired = errors.New("system admin required")
|
||||
|
||||
func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
user := currentUser(r)
|
||||
if user == nil {
|
||||
return nil, errors.New("no user")
|
||||
}
|
||||
view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
selectedIDs, err := selectedExportMessageIDs(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if view == "unknown" {
|
||||
if user.Role != "admin" {
|
||||
return nil, errSystemAdminRequired
|
||||
}
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')")
|
||||
args = append(args, user.ID)
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "m.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
switch view {
|
||||
case "", "folder":
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
normalized, err := normalizeFolderNameForUser(folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "f.name=?")
|
||||
args = append(args, normalized)
|
||||
case "starred":
|
||||
where = append(where, "m.is_starred=1")
|
||||
case "label":
|
||||
labelID := strings.TrimSpace(r.URL.Query().Get("labelId"))
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
if len(selectedIDs) > 0 {
|
||||
placeholders := make([]string, 0, len(selectedIDs))
|
||||
for _, id := range selectedIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func selectedExportMessageIDs(r *http.Request) ([]string, error) {
|
||||
values := r.URL.Query()["messageId"]
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) > maxSelectedMailExport {
|
||||
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
|
||||
msg, err := a.storedMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
exportDate := msg.ReceivedAt
|
||||
if exportDate.IsZero() {
|
||||
exportDate = messageDate(msg)
|
||||
}
|
||||
if msg.RawPath != "" {
|
||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
||||
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||
Date: messageDate(msg), Attachments: attachments,
|
||||
})
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
|
||||
}
|
||||
|
||||
func exportMessageFilename(subject string, date time.Time) string {
|
||||
name := strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(subject))
|
||||
name = strings.Trim(name, " .-_")
|
||||
if name == "" {
|
||||
name = "无主题"
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > 80 {
|
||||
name = string(runes[:80])
|
||||
}
|
||||
if date.IsZero() {
|
||||
return name + ".eml"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
|
||||
}
|
||||
|
||||
func uniqueExportFilename(name string, used map[string]int) string {
|
||||
used[name]++
|
||||
if used[name] == 1 {
|
||||
return name
|
||||
}
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name))
|
||||
}
|
||||
|
||||
func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes)
|
||||
if err := r.ParseMultipartForm(maxMailImportBytes); err != nil {
|
||||
respondError(w, http.StatusRequestEntityTooLarge, "import is too large")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderName := strings.TrimSpace(r.FormValue("folder"))
|
||||
if folderName == "" {
|
||||
folderName = "Inbox"
|
||||
}
|
||||
folderName, err = normalizeFolderNameForUser(folderName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
files := r.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
files = r.MultipartForm.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
badRequest(w, errors.New("at least one EML or MBOX file is required"))
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped := 0, 0
|
||||
problems := []string{}
|
||||
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||
if maxMessageBytes <= 0 {
|
||||
maxMessageBytes = 35 * 1024 * 1024
|
||||
}
|
||||
for _, header := range files {
|
||||
messages, fileErr := readImportFile(header, maxMessageBytes)
|
||||
if fileErr != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr))
|
||||
continue
|
||||
}
|
||||
for _, raw := range messages {
|
||||
if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err))
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
}
|
||||
if imported == 0 && len(problems) > 0 {
|
||||
badRequest(w, errors.New(problems[0]))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems})
|
||||
}
|
||||
|
||||
func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) {
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".eml" && ext != ".mbox" {
|
||||
return nil, errors.New("only .eml and .mbox files are supported")
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
if ext == ".eml" {
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil, errors.New("message is empty")
|
||||
}
|
||||
return [][]byte{raw}, nil
|
||||
}
|
||||
return parseMBOX(file, maxMessageBytes)
|
||||
}
|
||||
|
||||
func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
bufferSize := int(maxMessageBytes + 1024)
|
||||
if bufferSize < 64*1024 {
|
||||
bufferSize = 64 * 1024
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), bufferSize)
|
||||
var current bytes.Buffer
|
||||
messages := [][]byte{}
|
||||
seenSeparator := false
|
||||
flush := func() error {
|
||||
raw := bytes.TrimSpace(current.Bytes())
|
||||
current.Reset()
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
messages = append(messages, append([]byte(nil), raw...))
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if bytes.HasPrefix(line, []byte("From ")) {
|
||||
if seenSeparator {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
seenSeparator = true
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(line, []byte(">From ")) {
|
||||
line = line[1:]
|
||||
}
|
||||
current.Write(line)
|
||||
current.WriteString("\r\n")
|
||||
if int64(current.Len()) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("MBOX contains no messages")
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error {
|
||||
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid message: %w", err)
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folderID
|
||||
msg.RecipientAddr = mb.Address
|
||||
msg.RawPath = ""
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil {
|
||||
a.deleteMessage(ctx, id)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendImportProblem(items []string, problem string) []string {
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
return append(items, problem)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
stdmail "net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseMBOXMultipleMessages(t *testing.T) {
|
||||
raw := strings.Join([]string{
|
||||
"From sender@example.com Mon Jan 1 00:00:00 2024",
|
||||
"From: sender@example.com",
|
||||
"To: first@example.com",
|
||||
"Subject: first",
|
||||
"",
|
||||
"first body",
|
||||
">From escaped body line",
|
||||
"From sender@example.com Tue Jan 2 00:00:00 2024",
|
||||
"From: sender@example.com",
|
||||
"To: second@example.com",
|
||||
"Subject: second",
|
||||
"",
|
||||
"second body",
|
||||
}, "\n")
|
||||
messages, err := parseMBOX(strings.NewReader(raw), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages=%d", len(messages))
|
||||
}
|
||||
if !bytes.Contains(messages[0], []byte("Subject: first")) || !bytes.Contains(messages[0], []byte("From escaped body line")) {
|
||||
t.Fatalf("first message=%q", messages[0])
|
||||
}
|
||||
if !bytes.Contains(messages[1], []byte("Subject: second")) {
|
||||
t.Fatalf("second message=%q", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||
}
|
||||
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-owner", "Transfer Owner", "Password123!", nil)
|
||||
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-other", "Transfer Other", "Password123!", nil)
|
||||
owner := &testClient{t: t, server: ts}
|
||||
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
|
||||
var imported struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
|
||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "中文标题" || list.Items[1].Subject != "older imported message" {
|
||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC)
|
||||
if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("export status=%d body=%q", status, archive)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 2 {
|
||||
t.Fatalf("zip entries=%d", len(zr.File))
|
||||
}
|
||||
if zr.File[0].Name != "中文标题 (20240103).eml" {
|
||||
t.Fatalf("first filename=%q", zr.File[0].Name)
|
||||
}
|
||||
wantModified := receivedAt
|
||||
if !zr.File[0].Modified.Equal(wantModified) {
|
||||
t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified)
|
||||
}
|
||||
entry, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported, err := io.ReadAll(entry)
|
||||
entry.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("exported message err=%v raw=%q", err, exported)
|
||||
}
|
||||
parsed, err := stdmail.ReadMessage(bytes.NewReader(exported))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
|
||||
if err != nil || decodedSubject != "中文标题" {
|
||||
t.Fatalf("decoded subject=%q err=%v", decodedSubject, err)
|
||||
}
|
||||
messageDate, err := parsed.Header.Date()
|
||||
if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("message date=%s err=%v", messageDate, err)
|
||||
}
|
||||
|
||||
selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID)
|
||||
status, selectedArchive := getMailExport(t, owner, selectedPath)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("selected export status=%d body=%q", status, selectedArchive)
|
||||
}
|
||||
selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" {
|
||||
t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File))
|
||||
}
|
||||
|
||||
var denied map[string]any
|
||||
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-mailbox import code=%d", code)
|
||||
}
|
||||
status, _ = getMailExport(t, owner, "/api/mail/export?view=unknown")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("unknown export status=%d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||
}
|
||||
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil)
|
||||
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
|
||||
owner := &testClient{t: t, server: ts}
|
||||
other := &testClient{t: t, server: ts}
|
||||
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login=%d", code)
|
||||
}
|
||||
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
|
||||
var imported map[string]any
|
||||
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
|
||||
t.Fatalf("other import=%d response=%v", code, imported)
|
||||
}
|
||||
var otherList struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
|
||||
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
|
||||
}
|
||||
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
|
||||
status, archive := getMailExport(t, owner, path)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cross-owner export status=%d body=%q", status, archive)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 0 {
|
||||
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
|
||||
}
|
||||
}
|
||||
|
||||
func exportEntryNames(files []*zip.File) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
_ = writer.WriteField("mailboxId", mailboxID)
|
||||
_ = writer.WriteField("folder", folder)
|
||||
for name, content := range files {
|
||||
part, err := writer.CreateFormFile("files", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, client.server.URL+"/api/mail/import", &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
if client.cookie != nil {
|
||||
req.AddCookie(client.cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
t.Fatalf("decode import response: %v", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getMailExport(t *testing.T, client *testClient, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, client.server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.cookie != nil {
|
||||
req.AddCookie(client.cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
|
||||
|
||||
type translateMailMessageRequest struct {
|
||||
TargetLanguage string `json:"targetLanguage"`
|
||||
}
|
||||
|
||||
type translateMailMessageResponse struct {
|
||||
TranslatedText string `json:"translatedText"`
|
||||
TranslatedHTML string `json:"translatedHtml,omitempty"`
|
||||
SourceLanguage string `json:"sourceLanguage,omitempty"`
|
||||
TargetLanguage string `json:"targetLanguage"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.config().MailTranslateEnabled {
|
||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||
return
|
||||
}
|
||||
var req translateMailMessageRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := normalizeTranslateTarget(req.TargetLanguage)
|
||||
if target == "" {
|
||||
respondError(w, http.StatusBadRequest, "unsupported target language")
|
||||
return
|
||||
}
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(msg.BodyText)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(msg.Snippet)
|
||||
}
|
||||
if text == "" {
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translatedHTMLResult := make(chan string, 1)
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
go func() {
|
||||
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||
translatedHTMLResult <- translatedHTML
|
||||
}()
|
||||
} else {
|
||||
translatedHTMLResult <- ""
|
||||
}
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
translatedHTML := <-translatedHTMLResult
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.config().MailTranslateEnabled {
|
||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||
return
|
||||
}
|
||||
var req translateMailMessageRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := normalizeTranslateTarget(req.TargetLanguage)
|
||||
if target == "" {
|
||||
respondError(w, http.StatusBadRequest, "unsupported target language")
|
||||
return
|
||||
}
|
||||
account, ok := a.externalIMAPAccountForMailRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to load remote message")
|
||||
return
|
||||
}
|
||||
stored, _, err := a.parseMaildirMessage(raw, account.Username)
|
||||
text := ""
|
||||
if err == nil {
|
||||
text = strings.TrimSpace(stored.BodyText)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(stored.Snippet)
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(remote.Snippet)
|
||||
}
|
||||
if text == "" {
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translatedHTMLResult := make(chan string, 1)
|
||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||
go func() {
|
||||
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||
translatedHTMLResult <- translatedHTML
|
||||
}()
|
||||
} else {
|
||||
translatedHTMLResult <- ""
|
||||
}
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
translatedHTML := <-translatedHTMLResult
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
|
||||
}
|
||||
|
||||
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
|
||||
|
||||
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
|
||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type translationJob struct {
|
||||
node *html.Node
|
||||
original string
|
||||
text string
|
||||
}
|
||||
remaining := maxChars
|
||||
jobs := make([]translationJob, 0)
|
||||
var collect func(*html.Node)
|
||||
collect = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||
limited, _ := truncateRunes(text, remaining)
|
||||
remaining -= utf8.RuneCountInString(limited)
|
||||
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
collect(c)
|
||||
}
|
||||
}
|
||||
for _, n := range nodes {
|
||||
collect(n)
|
||||
}
|
||||
results := make([]string, len(jobs))
|
||||
jobIndexes := make(chan int)
|
||||
errCh := make(chan error, 1)
|
||||
workers := min(4, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for index := range jobIndexes {
|
||||
translated, _, translateErr := translator(ctx, jobs[index].text, target)
|
||||
if translateErr != nil {
|
||||
select {
|
||||
case errCh <- translateErr:
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
results[index] = translated
|
||||
}
|
||||
}()
|
||||
}
|
||||
for index := range jobs {
|
||||
jobIndexes <- index
|
||||
}
|
||||
close(jobIndexes)
|
||||
wg.Wait()
|
||||
select {
|
||||
case translateErr := <-errCh:
|
||||
return "", translateErr
|
||||
default:
|
||||
}
|
||||
for index, job := range jobs {
|
||||
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, n := range nodes {
|
||||
if err := html.Render(&b, n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if policy != nil {
|
||||
return policy.Sanitize(b.String()), nil
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func shouldSkipHTMLTranslationElement(tag string) bool {
|
||||
switch strings.ToLower(tag) {
|
||||
case "script", "style", "code", "pre", "textarea":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsTranslatableLetter(value string) bool {
|
||||
for _, r := range value {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '\u4e00' && r <= '\u9fff') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTranslateTarget(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "zh", "zh-cn", "zh-hans", "zh_cn":
|
||||
return "zh-CN"
|
||||
case "zh-tw", "zh-hant", "zh_hk", "zh-hk", "zh-mo":
|
||||
return "zh-TW"
|
||||
case "en", "en-us", "en-gb":
|
||||
return "en"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(value string, max int) (string, bool) {
|
||||
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
||||
return value, false
|
||||
}
|
||||
out := make([]rune, 0, max)
|
||||
for i, r := range value {
|
||||
if len(out) >= max {
|
||||
return string(out), i < len(value)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(out), false
|
||||
}
|
||||
|
||||
func googleFreeTranslate(ctx context.Context, text, target string) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
params := url.Values{}
|
||||
params.Set("client", "gtx")
|
||||
params.Set("sl", "auto")
|
||||
params.Set("tl", target)
|
||||
params.Set("dt", "t")
|
||||
params.Set("q", text)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleTranslateEndpoint+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1024))
|
||||
return "", "", fmt.Errorf("google translate status %d", res.StatusCode)
|
||||
}
|
||||
var raw any
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024)).Decode(&raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
translated, source := parseGoogleTranslateResponse(raw)
|
||||
translated = strings.TrimSpace(translated)
|
||||
if translated == "" {
|
||||
return "", source, errors.New("empty translation")
|
||||
}
|
||||
return translated, source, nil
|
||||
}
|
||||
|
||||
func parseGoogleTranslateResponse(raw any) (string, string) {
|
||||
root, _ := raw.([]any)
|
||||
var b strings.Builder
|
||||
if len(root) > 0 {
|
||||
if sentences, ok := root[0].([]any); ok {
|
||||
for _, item := range sentences {
|
||||
parts, ok := item.([]any)
|
||||
if !ok || len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
if s, ok := parts[0].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
source := ""
|
||||
if len(root) > 2 {
|
||||
if s, ok := root[2].(string); ok {
|
||||
source = s
|
||||
}
|
||||
}
|
||||
return b.String(), source
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||
raw := []any{
|
||||
[]any{
|
||||
[]any{"你好", "Hello", nil, nil, float64(3)},
|
||||
[]any{",世界", ", world", nil, nil, float64(3)},
|
||||
},
|
||||
nil,
|
||||
"en",
|
||||
}
|
||||
translated, source := parseGoogleTranslateResponse(raw)
|
||||
if translated != "你好,世界" {
|
||||
t.Fatalf("translated = %q", translated)
|
||||
}
|
||||
if source != "en" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
|
||||
translator := func(_ context.Context, text, target string) (string, string, error) {
|
||||
return strings.ToUpper(text) + "-" + target, "en", nil
|
||||
}
|
||||
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
|
||||
t.Fatalf("translated HTML = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<pre>keep me</pre>`) {
|
||||
t.Fatalf("code block was translated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
got, truncated := truncateRunes("你好world", 4)
|
||||
if got != "你好wo" || !truncated {
|
||||
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
|
||||
}
|
||||
got, truncated = truncateRunes("你好", 4)
|
||||
if got != "你好" || truncated {
|
||||
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var mailboxes struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
if !mailboxes.Items[0].Primary {
|
||||
t.Fatal("administrator mailbox should be marked as primary")
|
||||
}
|
||||
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
|
||||
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("account was not preserved code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMaildirRecentErrors = 10
|
||||
|
||||
type maildirSyncCounts struct {
|
||||
FilesScanned int `json:"filesScanned"`
|
||||
Imported int `json:"imported"`
|
||||
Backfilled int `json:"backfilled"`
|
||||
Cleaned int `json:"cleaned"`
|
||||
FileErrors int `json:"fileErrors"`
|
||||
fileErrorDetails []string `json:"-"`
|
||||
}
|
||||
|
||||
func (c maildirSyncCounts) total() int {
|
||||
return c.Imported + c.Backfilled + c.Cleaned
|
||||
}
|
||||
|
||||
type maildirSyncRun struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts maildirSyncCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root string `json:"root"`
|
||||
ScanSeconds int `json:"scanSeconds"`
|
||||
WorkerStarted bool `json:"workerStarted"`
|
||||
Running bool `json:"running"`
|
||||
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
RecentErrors []string `json:"recentErrors"`
|
||||
Summary maildirSyncCounts `json:"summary"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
workerStarted bool
|
||||
running bool
|
||||
current *maildirSyncRun
|
||||
lastRun *maildirSyncRun
|
||||
lastError string
|
||||
nextRunAt *time.Time
|
||||
recentErrors []string
|
||||
summary maildirSyncCounts
|
||||
}
|
||||
|
||||
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
|
||||
return &maildirSyncHealthTracker{}
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = true
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStopped() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = false
|
||||
h.nextRunAt = nil
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
|
||||
h.running = true
|
||||
h.current = run
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := h.current
|
||||
if run == nil {
|
||||
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
|
||||
}
|
||||
finished := finishedAt.UTC()
|
||||
run.FinishedAt = &finished
|
||||
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
|
||||
run.Counts = counts
|
||||
run.Status = "success"
|
||||
run.Error = ""
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
h.lastError = run.Error
|
||||
h.pushRecentError(run.Error)
|
||||
} else if counts.FileErrors > 0 {
|
||||
run.Status = "partial"
|
||||
if len(counts.fileErrorDetails) > 0 {
|
||||
run.Error = counts.fileErrorDetails[0]
|
||||
h.lastError = run.Error
|
||||
}
|
||||
for _, detail := range counts.fileErrorDetails {
|
||||
h.pushRecentError(detail)
|
||||
}
|
||||
} else {
|
||||
h.lastError = ""
|
||||
}
|
||||
h.summary.FilesScanned += counts.FilesScanned
|
||||
h.summary.Imported += counts.Imported
|
||||
h.summary.Backfilled += counts.Backfilled
|
||||
h.summary.Cleaned += counts.Cleaned
|
||||
h.summary.FileErrors += counts.FileErrors
|
||||
h.running = false
|
||||
h.current = nil
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
|
||||
root := strings.TrimSpace(cfg.MaildirRoot)
|
||||
scanSeconds := cfg.MaildirScanSeconds
|
||||
if scanSeconds <= 0 {
|
||||
scanSeconds = 30
|
||||
}
|
||||
out := maildirSyncHealthResponse{
|
||||
Configured: root != "",
|
||||
Enabled: root != "",
|
||||
Root: root,
|
||||
ScanSeconds: scanSeconds,
|
||||
}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out.WorkerStarted = h.workerStarted
|
||||
out.Running = h.running
|
||||
out.LastRun = cloneMaildirSyncRun(h.lastRun)
|
||||
out.LastError = h.lastError
|
||||
out.NextRunAt = cloneTimePtr(h.nextRunAt)
|
||||
out.RecentErrors = append([]string(nil), h.recentErrors...)
|
||||
out.Summary = h.summary
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
h.recentErrors = append([]string{value}, h.recentErrors...)
|
||||
if len(h.recentErrors) > maxMaildirRecentErrors {
|
||||
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.FinishedAt = cloneTimePtr(in.FinishedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTimePtr(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := in.UTC()
|
||||
return &out
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !unix
|
||||
|
||||
package app
|
||||
|
||||
func applyMaildirOwnership(path string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build unix
|
||||
|
||||
package app
|
||||
|
||||
import "os"
|
||||
|
||||
const (
|
||||
maildirOwnerUID = 5000
|
||||
maildirOwnerGID = 5000
|
||||
)
|
||||
|
||||
func applyMaildirOwnership(path string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return nil
|
||||
}
|
||||
return os.Chown(path, maildirOwnerUID, maildirOwnerGID)
|
||||
}
|
||||
@@ -45,58 +45,83 @@ type parsedMail struct {
|
||||
}
|
||||
|
||||
func (a *App) maildirWorker(ctx context.Context) {
|
||||
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
|
||||
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||
nextRunAt := a.now().UTC()
|
||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n > 0 {
|
||||
a.log.Info("initial maildir sync imported messages", "count", n)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.maildirHealth.markWorkerStopped()
|
||||
a.log.Info("maildir sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := a.syncMaildirOnce(ctx)
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||
if err != nil {
|
||||
a.log.Warn("maildir sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
a.log.Info("maildir sync imported messages", "count", n)
|
||||
if n := counts.total(); n > 0 {
|
||||
a.log.Info("maildir sync processed messages", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
|
||||
startedAt := a.now().UTC()
|
||||
a.maildirHealth.markRunStarted(startedAt)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
finishedAt := a.now().UTC()
|
||||
var nextRunAt *time.Time
|
||||
if interval > 0 && err == nil {
|
||||
next := finishedAt.Add(interval)
|
||||
nextRunAt = &next
|
||||
}
|
||||
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
return counts.total(), err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||
if root == "" {
|
||||
return 0, nil
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return maildirSyncCounts{}, err
|
||||
}
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
counts.FilesScanned += mbCounts.FilesScanned
|
||||
counts.Imported += mbCounts.Imported
|
||||
counts.FileErrors += mbCounts.FileErrors
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||
for _, folder := range folders {
|
||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(folderBase, sub)
|
||||
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Backfilled += backfilled
|
||||
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Cleaned += cleaned
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
@@ -152,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.CatchAllEnabled {
|
||||
if a.config().CatchAllEnabled {
|
||||
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -178,13 +216,13 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
imported := 0
|
||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
@@ -193,24 +231,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
@@ -248,9 +289,13 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -291,7 +336,10 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
||||
if strings.TrimSpace(msg.RecipientAddr) == "" {
|
||||
msg.RecipientAddr = mb.Address
|
||||
}
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
@@ -311,11 +359,23 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
} else if handled {
|
||||
return false, nil
|
||||
}
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
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
|
||||
}
|
||||
@@ -338,8 +398,205 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var samePathID, oldFolderID string
|
||||
var oldRead, oldStarred int
|
||||
var oldModSeq int64
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
|
||||
if err == nil {
|
||||
if oldFolderID != folderID {
|
||||
if oldFolderID != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
|
||||
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
modSeq := oldModSeq
|
||||
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
|
||||
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
|
||||
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
type candidate struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var chosen candidate
|
||||
for rows.Next() {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if c.RawPath == "" || c.RawPath == rawPath {
|
||||
chosen = c
|
||||
break
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
chosen = c
|
||||
break
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if chosen.ID == "" {
|
||||
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
|
||||
return false, nil
|
||||
}
|
||||
var previousFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previousFolderID != "" && previousFolderID != folderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
|
||||
var existing string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
if err != nil || existing == "" || existing == rawPath {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type item struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
var missing []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
missing = append(missing, it)
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, it := range missing {
|
||||
a.deleteMessageFiles(ctx, it.ID)
|
||||
var folderID sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if folderID.Valid && folderID.String != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(missing), nil
|
||||
}
|
||||
|
||||
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
|
||||
base := filepath.Base(path)
|
||||
flags := ""
|
||||
hasFlags := false
|
||||
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
flags = base[idx+len(sep):]
|
||||
hasFlags = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFlags {
|
||||
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
|
||||
}
|
||||
return !strings.EqualFold(folderName, "Inbox"), false
|
||||
}
|
||||
|
||||
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
|
||||
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -364,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) == "" {
|
||||
@@ -382,22 +646,39 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
receivedAt = sentAt
|
||||
}
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
RecipientAddr: recipientAddr,
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||
}, 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)
|
||||
@@ -436,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 == "" {
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
|
||||
}
|
||||
|
||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID != "" {
|
||||
oldFolderID := state.FolderID
|
||||
state.FolderID = folderID
|
||||
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.MailboxID == "" || state.FolderID == "" {
|
||||
return nil
|
||||
}
|
||||
if !replace && state.RawPath != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := os.Stat(state.RawPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
filename := maildirFilename(messageID, state.MessageID)
|
||||
tmpPath := filepath.Join(folderBase, "tmp", filename)
|
||||
finalPath := filepath.Join(folderBase, subdir, filename)
|
||||
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
|
||||
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(tmpPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if replace || state.RawPath != "" {
|
||||
a.removeMaildirPath(ctx, state.RawPath)
|
||||
}
|
||||
if updateFolder {
|
||||
if state.IMAPUID > 0 && folderID == "" {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
} else {
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
} else {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.MailboxID == "" {
|
||||
return nil
|
||||
}
|
||||
state.FolderID = targetFolderID
|
||||
if state.RawPath == "" {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
|
||||
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.FolderID = folderID
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
||||
}
|
||||
|
||||
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
var rawPath string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.RawPath == "" {
|
||||
return nil
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
currentRead := state.IsRead
|
||||
currentStarred := state.IsStarred
|
||||
if read != nil {
|
||||
currentRead = *read
|
||||
}
|
||||
if starred != nil {
|
||||
currentStarred = *starred
|
||||
}
|
||||
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
||||
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
return
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(rawPath)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
rows.Close()
|
||||
return 0, ctx.Err()
|
||||
default:
|
||||
}
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, id := range ids {
|
||||
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type maildirMessageState struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
MessageID string
|
||||
RawPath string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
IMAPUID int64
|
||||
IMAPModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
||||
var state maildirMessageState
|
||||
var mailboxID, folderID sql.NullString
|
||||
var read, starred int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.MailboxID = mailboxID.String
|
||||
state.FolderID = folderID.String
|
||||
state.IsRead = intBool(read)
|
||||
state.IsStarred = intBool(starred)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
|
||||
var msg storedMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred int
|
||||
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To = jsonDecodeSlice(toJSON)
|
||||
msg.CC = jsonDecodeSlice(ccJSON)
|
||||
msg.BCC = jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt = parseTime(sent)
|
||||
msg.ReceivedAt = parseTime(received)
|
||||
msg.IsRead = intBool(read)
|
||||
msg.IsStarred = intBool(starred)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AttachmentInput
|
||||
for rows.Next() {
|
||||
var filename, contentType, storagePath string
|
||||
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
|
||||
var mb maildirMailbox
|
||||
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
|
||||
var name string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||
if root == "" || strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, pathAbs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||
}
|
||||
|
||||
func ensureMaildirFolderDirs(base, folderBase string) error {
|
||||
for _, sub := range []string{"tmp", "new", "cur"} {
|
||||
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, dir := range maildirOwnershipDirs(base, folderBase) {
|
||||
if err := applyMaildirOwnership(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maildirOwnershipDirs(base, folderBase string) []string {
|
||||
dirs := []string{
|
||||
filepath.Dir(filepath.Dir(base)),
|
||||
filepath.Dir(base),
|
||||
base,
|
||||
}
|
||||
if filepath.Clean(folderBase) != filepath.Clean(base) {
|
||||
dirs = append(dirs, folderBase)
|
||||
}
|
||||
dirs = append(dirs, filepath.Join(folderBase, "tmp"), filepath.Join(folderBase, "new"), filepath.Join(folderBase, "cur"))
|
||||
return dirs
|
||||
}
|
||||
|
||||
func maildirFilename(messageID, headerMessageID string) string {
|
||||
base := strings.TrimSpace(headerMessageID)
|
||||
if base == "" {
|
||||
base = messageID
|
||||
}
|
||||
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
|
||||
}
|
||||
|
||||
func safeMaildirName(value string) string {
|
||||
value = strings.Trim(value, "<>")
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-', r == '@':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "._-")
|
||||
if out == "" {
|
||||
out = "message"
|
||||
}
|
||||
if len(out) > 120 {
|
||||
out = out[:120]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageDate(msg storedMessage) time.Time {
|
||||
if !msg.SentAt.IsZero() {
|
||||
return msg.SentAt
|
||||
}
|
||||
if !msg.ReceivedAt.IsZero() {
|
||||
return msg.ReceivedAt
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func maildirPathWithFlags(path string, read, starred bool) string {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if read || starred {
|
||||
dir = filepath.Join(filepath.Dir(dir), "cur")
|
||||
} else if filepath.Base(dir) == "cur" {
|
||||
dir = filepath.Join(filepath.Dir(dir), "new")
|
||||
}
|
||||
base := name
|
||||
sep := maildirFlagSeparator()
|
||||
existingFlags := ""
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
existingFlags = base[idx+len(sep):]
|
||||
base = base[:idx]
|
||||
}
|
||||
flags := preserveMaildirFlags(existingFlags, "SF")
|
||||
if read {
|
||||
flags = appendMaildirFlag(flags, 'S')
|
||||
}
|
||||
if starred {
|
||||
flags = appendMaildirFlag(flags, 'F')
|
||||
}
|
||||
if flags != "" {
|
||||
base += sep + flags
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
|
||||
func preserveMaildirFlags(flags, managed string) string {
|
||||
var b strings.Builder
|
||||
for _, flag := range flags {
|
||||
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(flag)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendMaildirFlag(flags string, flag rune) string {
|
||||
if strings.ContainsRune(flags, flag) {
|
||||
return flags
|
||||
}
|
||||
return flags + string(flag)
|
||||
}
|
||||
|
||||
func maildirFlagSeparator() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "!2,"
|
||||
}
|
||||
return ":2,"
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
|
||||
}
|
||||
|
||||
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
|
||||
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
|
||||
return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
|
||||
}
|
||||
|
||||
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const deliveryWebhookMaxAge = 5 * time.Minute
|
||||
|
||||
type deliveryWebhookEvent struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
QueueID string `json:"queueId"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
|
||||
if secret == "" {
|
||||
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
|
||||
return
|
||||
}
|
||||
timestamp := strings.TrimSpace(r.Header.Get("X-LanQin-Timestamp"))
|
||||
signature := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("X-LanQin-Signature")), "sha256=")
|
||||
unix, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil || signature == "" {
|
||||
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
|
||||
return
|
||||
}
|
||||
signedAt := time.Unix(unix, 0)
|
||||
if delta := a.now().UTC().Sub(signedAt); delta < -deliveryWebhookMaxAge || delta > deliveryWebhookMaxAge {
|
||||
respondError(w, http.StatusUnauthorized, "webhook timestamp is outside the allowed window")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("invalid webhook body"))
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
expected, err := hex.DecodeString(signature)
|
||||
if err != nil || !hmac.Equal(mac.Sum(nil), expected) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Events []deliveryWebhookEvent `json:"events"`
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&payload); err != nil || len(payload.Events) == 0 || len(payload.Events) > 100 {
|
||||
badRequest(w, errors.New("events must contain between 1 and 100 items"))
|
||||
return
|
||||
}
|
||||
accepted := 0
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start delivery event transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, event := range payload.Events {
|
||||
inserted, err := a.storeDeliveryEvent(r, tx, event)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if inserted {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to store delivery events")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "accepted": accepted, "duplicates": len(payload.Events) - accepted})
|
||||
}
|
||||
|
||||
func (a *App) storeDeliveryEvent(r *http.Request, tx *sql.Tx, event deliveryWebhookEvent) (bool, error) {
|
||||
event.ID = strings.TrimSpace(event.ID)
|
||||
event.Provider = strings.ToLower(strings.TrimSpace(event.Provider))
|
||||
event.QueueID = strings.TrimSpace(event.QueueID)
|
||||
event.MessageID = strings.TrimSpace(event.MessageID)
|
||||
event.RFCMessageID = strings.TrimSpace(event.RFCMessageID)
|
||||
event.Recipient = normalizeEmail(event.Recipient)
|
||||
event.Status = strings.ToLower(strings.TrimSpace(event.Status))
|
||||
if event.ID == "" || len(event.ID) > 200 || event.Provider == "" || len(event.Provider) > 80 || event.Recipient == "" || len(event.Recipient) > 320 || len(event.Reason) > 2000 || !validDeliveryEventStatus(event.Status) {
|
||||
return false, errors.New("invalid delivery event")
|
||||
}
|
||||
if event.QueueID == "" && event.MessageID == "" && event.RFCMessageID == "" {
|
||||
return false, errors.New("queueId, messageId, or rfcMessageId is required")
|
||||
}
|
||||
occurredAt, err := time.Parse(time.RFC3339Nano, event.OccurredAt)
|
||||
if err != nil {
|
||||
return false, errors.New("occurredAt must be an RFC3339 timestamp")
|
||||
}
|
||||
var queueID, sentMessageID, rfcMessageID string
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id,sent_message_id,message_id FROM send_queue
|
||||
WHERE (?<>'' AND id=?) OR (?<>'' AND sent_message_id=?) OR (?<>'' AND message_id=?)
|
||||
ORDER BY created_at DESC LIMIT 1`, event.QueueID, event.QueueID, event.MessageID, event.MessageID, event.RFCMessageID, event.RFCMessageID).Scan(&queueID, &sentMessageID, &rfcMessageID)
|
||||
if err != nil {
|
||||
return false, errors.New("send item not found")
|
||||
}
|
||||
if (event.QueueID != "" && event.QueueID != queueID) || (event.MessageID != "" && event.MessageID != sentMessageID) || (event.RFCMessageID != "" && event.RFCMessageID != rfcMessageID) {
|
||||
return false, errors.New("delivery event identifiers do not refer to the same send item")
|
||||
}
|
||||
var recipientsJSON string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT recipients_json FROM send_queue WHERE id=?`, queueID).Scan(&recipientsJSON); err != nil {
|
||||
return false, errors.New("send item not found")
|
||||
}
|
||||
foundRecipient := false
|
||||
for _, recipient := range jsonDecodeSlice(recipientsJSON) {
|
||||
if normalizeEmail(recipient) == event.Recipient {
|
||||
foundRecipient = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRecipient {
|
||||
return false, errors.New("delivery event recipient does not belong to the send item")
|
||||
}
|
||||
id := newID("dev")
|
||||
createdAt := a.now().UTC()
|
||||
reason := strings.TrimSpace(event.Reason)
|
||||
res, err := tx.ExecContext(r.Context(), `INSERT OR IGNORE INTO delivery_events(id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, event.ID, event.Provider, queueID, sentMessageID, rfcMessageID, event.Recipient, event.Status, reason, occurredAt.UTC().Format(time.RFC3339Nano), createdAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
item := DeliveryEvent{ID: id, ExternalID: event.ID, Provider: event.Provider, QueueID: queueID, MessageID: sentMessageID, RFCMessageID: rfcMessageID, Recipient: event.Recipient, Status: event.Status, Reason: reason, OccurredAt: occurredAt.UTC(), CreatedAt: createdAt}
|
||||
var mailboxID string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT mailbox_id FROM send_queue WHERE id=?`, queueID).Scan(&mailboxID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := a.enqueueStatusWebhook(r.Context(), tx, "delivery:"+event.Provider+":"+event.ID, "delivery."+event.Status, mailboxID, item); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func validDeliveryEventStatus(status string) bool {
|
||||
switch status {
|
||||
case "delivered", "bounced", "complained", "rejected", "deferred":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListSends(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
limit := parseOpenAPILimit(r, 30, 100)
|
||||
where := "mb.user_id=?"
|
||||
args := []any{user.ID}
|
||||
if mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")); mailboxID != "" {
|
||||
where += " AND sq.mailbox_id=?"
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
||||
if !validSendQueueStatus(status) {
|
||||
badRequest(w, errors.New("invalid send queue status"))
|
||||
return
|
||||
}
|
||||
where += " AND sq.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
cursorCreatedAt, cursorID, _, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if cursorCreatedAt != "" {
|
||||
where += " AND (sq.created_at<? OR (sq.created_at=? AND sq.id<?))"
|
||||
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE `+where+` ORDER BY sq.created_at DESC,sq.id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list sends")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []openAPISendStatus{}
|
||||
for rows.Next() {
|
||||
item, err := scanSendQueueEntry(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan sends")
|
||||
return
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, item.MailFrom)
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
items = append(items, status)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list sends")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeSendQueueCursor(last.CreatedAt, last.QueueID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendEvents(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
audit, err := a.sendAuditEvents(r, item.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send events")
|
||||
return
|
||||
}
|
||||
delivery, err := a.deliveryEvents(r, item.SentMessageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load delivery events")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"auditEvents": audit, "deliveryEvents": delivery})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIRetrySend(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send item is not failed"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`, sendQueueStatusQueued, now, now, item.ID, sendQueueStatusFailed)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to retry send item")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusConflict, "send item status changed")
|
||||
return
|
||||
}
|
||||
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
|
||||
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICancelSend(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send item cannot be canceled"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?)`, sendQueueStatusCanceled, now, item.ID, sendQueueStatusQueued, sendQueueStatusFailed)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to cancel send item")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusConflict, "send item status changed")
|
||||
return
|
||||
}
|
||||
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
|
||||
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
|
||||
}
|
||||
|
||||
func sendAuditInputFromEntry(item SendQueueEntry, userID, errorText string) sendAuditInput {
|
||||
return sendAuditInput{QueueID: item.ID, UserID: userID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, Error: errorText}
|
||||
}
|
||||
|
||||
func (a *App) resolveOpenAPISendQueue(r *http.Request, id string) (SendQueueEntry, error) {
|
||||
user := currentUser(r)
|
||||
if item, err := a.loadSendQueueEntryForUser(r.Context(), strings.TrimSpace(id), user.ID); err == nil {
|
||||
return item, nil
|
||||
}
|
||||
return a.loadLatestSendQueueForMessage(r.Context(), strings.TrimSpace(id), user.ID)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIMessage(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListAliases(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases
|
||||
WHERE (?='' OR source>? OR (source=? AND id>?)) ORDER BY source,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list aliases")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Alias{}
|
||||
for rows.Next() {
|
||||
item, err := scanOpenAPIAlias(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan aliases")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list aliases")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Source, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetAlias(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := scanOpenAPIAlias(a.db.QueryRowContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases WHERE id=?`, chi.URLParam(r, "id")))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "alias not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
type aliasScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanOpenAPIAlias(row aliasScanner) (Alias, error) {
|
||||
var item Alias
|
||||
var enabled int
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.DomainID, &item.Source, &item.Destination, &enabled, &created)
|
||||
item.Enabled = intBool(enabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (a *App) sendAuditEvents(r *http.Request, queueID string) ([]SendAuditEvent, error) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at FROM send_audit_events WHERE queue_id=? ORDER BY created_at,id`, queueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) deliveryEvents(r *http.Request, sentMessageID string) ([]DeliveryEvent, error) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at FROM delivery_events WHERE sent_message_id=? ORDER BY occurred_at,id`, sentMessageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []DeliveryEvent{}
|
||||
for rows.Next() {
|
||||
var item DeliveryEvent
|
||||
var occurredAt, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.ExternalID, &item.Provider, &item.QueueID, &item.MessageID, &item.RFCMessageID, &item.Recipient, &item.Status, &item.Reason, &occurredAt, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.OccurredAt = parseTime(occurredAt)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains
|
||||
WHERE (?='' OR name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Domain{}
|
||||
for rows.Next() {
|
||||
item, err := scanDomain(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan domains")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Name, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
id, err := a.createDomainTx(r.Context(), nil, req.Name)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetDomain(w http.ResponseWriter, r *http.Request) {
|
||||
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIUpdateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update domain")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check domain")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
badRequest(w, errors.New("domain still has mailboxes"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete domain")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
FROM mailboxes mb JOIN users u ON u.id=mb.user_id
|
||||
WHERE (?='' OR mb.address>? OR (mb.address=? AND mb.id>?)) ORDER BY mb.address,mb.id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Mailbox{}
|
||||
for rows.Next() {
|
||||
item, err := scanMailbox(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Address, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := requireString("domainId", req.DomainID); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := requireString("localPart", req.LocalPart); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
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")
|
||||
return
|
||||
}
|
||||
localPart := normalizeLocalPart(req.LocalPart)
|
||||
if localPart == "" {
|
||||
badRequest(w, errors.New("localPart is required"))
|
||||
return
|
||||
}
|
||||
address := localPart + "@" + domain.Name
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = address
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
userID, err := a.resolveMailboxOwnerTx(r.Context(), tx, req.UserID, req.OwnerEmail, address, displayName, string(passwordHash))
|
||||
if err != nil {
|
||||
respondMailboxOwnerError(w, err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||
return
|
||||
}
|
||||
mailbox, err := a.mailboxByID(r.Context(), mailboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, mailbox)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, mailbox)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Status string `json:"status"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = current.DisplayName
|
||||
}
|
||||
quotaMB := req.QuotaMB
|
||||
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)
|
||||
if status == "" {
|
||||
status = current.Status
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
mailbox, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, mailbox)
|
||||
}
|
||||
|
||||
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, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if rows.Scan(&messageID) == nil {
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, chi.URLParam(r, "id")).Scan(&userID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?,updated_at=? WHERE id=?`, string(hash), now, userID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reset password")
|
||||
return
|
||||
}
|
||||
res, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?,updated_at=? WHERE user_id=?`, string(hash), now, userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reset mailbox passwords")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save password")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affectedMailboxes": affected})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
|
||||
var req mailComposeInput
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
requestJSON, _ := json.Marshal(req)
|
||||
requestSum := sha256.Sum256(requestJSON)
|
||||
requestHash := hex.EncodeToString(requestSum[:])
|
||||
if idempotencyKey != "" {
|
||||
if len(idempotencyKey) > 128 || strings.ContainsAny(idempotencyKey, "\r\n") {
|
||||
badRequest(w, errors.New("invalid Idempotency-Key"))
|
||||
return
|
||||
}
|
||||
status, replayed, err := a.reserveOpenAPISendIdempotency(r.Context(), currentUser(r).ID, idempotencyKey, requestHash)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if replayed {
|
||||
w.Header().Set("Idempotency-Replayed", "true")
|
||||
respondJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
}
|
||||
msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI)
|
||||
if err != nil {
|
||||
if idempotencyKey != "" {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=? AND sent_message_id=''`, currentUser(r).ID, idempotencyKey)
|
||||
}
|
||||
respondSendError(w, err)
|
||||
return
|
||||
}
|
||||
status := openAPISendStatusFromMessage(msg, mb.Address)
|
||||
if msg.SendQueueID != "" {
|
||||
if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil {
|
||||
status = openAPISendStatusFromQueue(item, mb.Address)
|
||||
}
|
||||
} else {
|
||||
item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID)
|
||||
if err == nil {
|
||||
status = openAPISendStatusFromQueue(item, mb.Address)
|
||||
}
|
||||
}
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
if idempotencyKey != "" {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE send_idempotency_keys SET sent_message_id=?,queue_id=? WHERE user_id=? AND idempotency_key=?`, status.MessageID, status.QueueID, currentUser(r).ID, idempotencyKey)
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, status)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendStatus(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
item, err = a.loadSendQueueEntryForSentMessage(r.Context(), id, user.ID)
|
||||
}
|
||||
if err == nil {
|
||||
mailboxAddress := ""
|
||||
if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil {
|
||||
mailboxAddress = mb.Address
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, mailboxAddress)
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
respondJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
mailboxAddress := ""
|
||||
if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil {
|
||||
mailboxAddress = mb.Address
|
||||
}
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromMessage(msg, mailboxAddress))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
limit := parseOpenAPILimit(r, 30, 100)
|
||||
cursorReceivedAt, cursorID, offset, err := parseOpenAPIMessageCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
where := "m.mailbox_id=?"
|
||||
args := []any{mailboxID}
|
||||
if folder != "" && !strings.EqualFold(folder, "all") {
|
||||
where += " AND lower(f.name)=lower(?)"
|
||||
args = append(args, folder)
|
||||
}
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
||||
where += " AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)"
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
if cursorReceivedAt != "" {
|
||||
where += " AND (m.received_at<? OR (m.received_at=? AND m.id<?))"
|
||||
args = append(args, cursorReceivedAt, cursorReceivedAt, cursorID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
query := `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m JOIN folders f ON f.id=m.folder_id
|
||||
WHERE ` + where + `
|
||||
ORDER BY m.received_at DESC,m.id DESC LIMIT ?`
|
||||
if offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, offset)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailMessage{}
|
||||
for rows.Next() {
|
||||
item, err := scanMessageSummary(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan messages")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
}
|
||||
nextCursor := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
nextCursor = encodeOpenAPIMessageCursor(last.ReceivedAt, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor})
|
||||
}
|
||||
|
||||
type domainScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanDomain(row domainScanner) (Domain, error) {
|
||||
var item Domain
|
||||
var checked sql.NullString
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.Name, &item.Status, &item.DKIMSelector, &item.DKIMPublicKey, &item.DNSStatus, &checked, &created)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.DNSCheckedAt = nullableTime(checked)
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
type mailboxScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanMailbox(row mailboxScanner) (Mailbox, error) {
|
||||
var item Mailbox
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.UserID, &item.UserEmail, &item.DomainID, &item.LocalPart, &item.Address, &item.DisplayName, &item.QuotaMB, &item.Status, &created)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
type openAPISendStatus struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
QueueStatus string `json:"queueStatus,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Recipients []string `json:"recipients,omitempty"`
|
||||
AttemptCount int `json:"attemptCount,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
RecipientStatuses []openAPIRecipientStatus `json:"recipientStatuses,omitempty"`
|
||||
}
|
||||
|
||||
type openAPIRecipientStatus struct {
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus {
|
||||
status := item.Status
|
||||
if status == sendQueueStatusDelivered {
|
||||
status = "relayed"
|
||||
}
|
||||
return openAPISendStatus{
|
||||
ID: firstNonEmpty(item.SentMessageID, item.ID),
|
||||
QueueID: item.ID,
|
||||
Status: status,
|
||||
QueueStatus: item.Status,
|
||||
MessageID: item.SentMessageID,
|
||||
RFCMessageID: item.MessageID,
|
||||
MailboxID: item.MailboxID,
|
||||
MailboxAddress: mailboxAddress,
|
||||
Subject: item.Subject,
|
||||
Recipients: item.Recipients,
|
||||
AttemptCount: item.AttemptCount,
|
||||
MaxAttempts: item.MaxAttempts,
|
||||
NextAttemptAt: timePtr(item.NextAttemptAt),
|
||||
LastError: item.LastError,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: timePtr(item.UpdatedAt),
|
||||
DeliveredAt: item.DeliveredAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) reserveOpenAPISendIdempotency(ctx context.Context, userID, key, requestHash string) (openAPISendStatus, bool, error) {
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM send_idempotency_keys WHERE created_at<?`, a.now().UTC().Add(-24*time.Hour).Format(time.RFC3339Nano))
|
||||
res, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_idempotency_keys(user_id,idempotency_key,request_hash,created_at) VALUES(?,?,?,?)`, userID, key, requestHash, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
return openAPISendStatus{}, false, nil
|
||||
}
|
||||
var storedHash, sentMessageID, queueID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT request_hash,sent_message_id,queue_id FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=?`, userID, key).Scan(&storedHash, &sentMessageID, &queueID); err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
if storedHash != requestHash {
|
||||
return openAPISendStatus{}, false, errors.New("Idempotency-Key was already used with a different request")
|
||||
}
|
||||
if sentMessageID == "" {
|
||||
return openAPISendStatus{}, false, errors.New("a request with this Idempotency-Key is still processing")
|
||||
}
|
||||
item, err := a.loadSendQueueEntryForUser(ctx, queueID, userID)
|
||||
if err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, item.MailFrom)
|
||||
a.applyDeliveryStatus(ctx, &status)
|
||||
return status, true, nil
|
||||
}
|
||||
|
||||
func (a *App) applyDeliveryStatus(ctx context.Context, status *openAPISendStatus) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT recipient,status,reason,provider,occurred_at FROM delivery_events
|
||||
WHERE sent_message_id=? ORDER BY occurred_at DESC,id DESC`, status.MessageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
counts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var item openAPIRecipientStatus
|
||||
var occurredAt string
|
||||
if rows.Scan(&item.Recipient, &item.Status, &item.Reason, &item.Provider, &occurredAt) != nil || seen[item.Recipient] {
|
||||
continue
|
||||
}
|
||||
seen[item.Recipient] = true
|
||||
item.OccurredAt = parseTime(occurredAt)
|
||||
status.RecipientStatuses = append(status.RecipientStatuses, item)
|
||||
counts[item.Status]++
|
||||
}
|
||||
if len(status.RecipientStatuses) == 0 {
|
||||
return
|
||||
}
|
||||
if len(status.RecipientStatuses) < len(status.Recipients) || len(counts) > 1 {
|
||||
status.Status = "partial"
|
||||
return
|
||||
}
|
||||
for _, value := range []string{"complained", "bounced", "rejected", "deferred", "delivered"} {
|
||||
if counts[value] > 0 {
|
||||
status.Status = value
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type openAPIMessageCursor struct {
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type openAPIListCursor struct {
|
||||
Sort string `json:"sort"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func encodeOpenAPIMessageCursor(receivedAt time.Time, id string) string {
|
||||
payload, _ := json.Marshal(openAPIMessageCursor{ReceivedAt: receivedAt.UTC().Format(time.RFC3339Nano), ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func parseOpenAPIMessageCursor(raw string) (receivedAt, id string, offset int, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", 0, nil
|
||||
}
|
||||
if n, convErr := strconv.Atoi(raw); convErr == nil {
|
||||
if n < 0 {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
return "", "", n, nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
var cursor openAPIMessageCursor
|
||||
if err := json.Unmarshal(data, &cursor); err != nil || cursor.ReceivedAt == "" || cursor.ID == "" {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, cursor.ReceivedAt); err != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
return cursor.ReceivedAt, cursor.ID, 0, nil
|
||||
}
|
||||
|
||||
func encodeOpenAPIListCursor(sortValue, id string) string {
|
||||
payload, _ := json.Marshal(openAPIListCursor{Sort: sortValue, ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func parseOpenAPIListCursor(raw string) (sortValue, id string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", errors.New("invalid cursor")
|
||||
}
|
||||
var cursor openAPIListCursor
|
||||
if err := json.Unmarshal(data, &cursor); err != nil || cursor.Sort == "" || cursor.ID == "" {
|
||||
return "", "", errors.New("invalid cursor")
|
||||
}
|
||||
return cursor.Sort, cursor.ID, nil
|
||||
}
|
||||
|
||||
func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus {
|
||||
recipients := append(append([]string{}, msg.To...), msg.CC...)
|
||||
recipients = append(recipients, msg.BCC...)
|
||||
return openAPISendStatus{
|
||||
ID: msg.ID,
|
||||
Status: sendAuditAccepted,
|
||||
MessageID: msg.ID,
|
||||
RFCMessageID: msg.MessageID,
|
||||
MailboxID: msg.MailboxID,
|
||||
MailboxAddress: mailboxAddress,
|
||||
Subject: msg.Subject,
|
||||
Recipients: dedupeEmails(recipients),
|
||||
CreatedAt: msg.ReceivedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, ownerEmail, address, displayName, passwordHash string) (string, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" {
|
||||
var disabled int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", errNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if intBool(disabled) {
|
||||
return "", errors.New("owner user is disabled")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
email := normalizeEmail(ownerEmail)
|
||||
if email == "" {
|
||||
email = address
|
||||
}
|
||||
if !strings.Contains(email, "@") {
|
||||
return "", errors.New("invalid owner email")
|
||||
}
|
||||
var existing string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
userID = newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
|
||||
func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error {
|
||||
var disabled int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if intBool(disabled) {
|
||||
return errors.New("owner user is disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func respondMailboxOwnerError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, errNotFound) {
|
||||
respondError(w, http.StatusNotFound, "owner user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func respondSendError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, errNoRecipients), errors.Is(err, errInvalidMIME), errors.Is(err, errAttachmentTooLarge):
|
||||
badRequest(w, err)
|
||||
case errors.Is(err, errSMTPRateLimited):
|
||||
respondError(w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, errSenderNotAuthorized):
|
||||
respondError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, errMailboxQuotaExceeded):
|
||||
respondError(w, http.StatusInsufficientStorage, err.Error())
|
||||
default:
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadLatestSendQueueForMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE sq.sent_message_id=? AND mb.user_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, userID)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
func (a *App) loadLatestSendQueueForMailboxMessage(ctx context.Context, sentMessageID, mailboxID string) (SendQueueEntry, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE sq.sent_message_id=? AND sq.mailbox_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, mailboxID)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
func (a *App) loadSendQueueEntryForSentMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
|
||||
return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID)
|
||||
}
|
||||
|
||||
func (a *App) loadOpenAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) {
|
||||
var messageID string
|
||||
err := a.db.QueryRowContext(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 (m.id=? OR m.message_id=?) AND mb.user_id=? AND lower(f.name)='sent'
|
||||
ORDER BY m.received_at DESC LIMIT 1`, id, id, userID).Scan(&messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.messageByID(ctx, messageID, false)
|
||||
}
|
||||
|
||||
func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasMinimumPasswordLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
want bool
|
||||
}{
|
||||
{name: "five ASCII characters", password: "abc12", want: false},
|
||||
{name: "six ASCII characters", password: "abc123", want: true},
|
||||
{name: "six Unicode characters", password: "密码测试六位", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
|
||||
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -142,12 +142,13 @@ 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 id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
if intBool(existingSystem) && id != PermissionGroupRegular {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -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
|
||||
|
||||
@@ -137,16 +137,18 @@ type PermissionGroup struct {
|
||||
}
|
||||
|
||||
type PermissionLimits struct {
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
MaxMailboxCount int `json:"maxMailboxCount"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
}
|
||||
|
||||
func defaultPermissionLimits() PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 25,
|
||||
MaxMailboxCount: 9,
|
||||
SMTPDailyLimit: 200,
|
||||
SMTPMinuteLimit: 20,
|
||||
IMAPMinuteLimit: 200,
|
||||
@@ -158,6 +160,9 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
|
||||
if limits.MaxAttachmentMB < 0 {
|
||||
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
|
||||
}
|
||||
if limits.MaxMailboxCount < 0 {
|
||||
return PermissionLimits{}, errors.New("maxMailboxCount cannot be negative")
|
||||
}
|
||||
if limits.SMTPDailyLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
|
||||
}
|
||||
@@ -173,6 +178,17 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func normalizeMailboxLimitOverride(value *int) (*int, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *value < 0 {
|
||||
return nil, errors.New("mailboxLimitOverride cannot be negative")
|
||||
}
|
||||
normalized := *value
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredLimits(value string) PermissionLimits {
|
||||
limits := defaultPermissionLimits()
|
||||
if strings.TrimSpace(value) == "" {
|
||||
@@ -197,11 +213,12 @@ func encodePermissionLimits(limits PermissionLimits) string {
|
||||
|
||||
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +233,12 @@ func mergeLimitValue(left, right int) int {
|
||||
}
|
||||
|
||||
func minimalLimits() PermissionLimits {
|
||||
// minimalLimits sets every field to 1 so that mergePermissionLimits
|
||||
// (which takes the max of each field) produces correct aggregation
|
||||
// minimalLimits sets every field to 1 so that mergePermissionLimits
|
||||
// (which takes the max of each field) produces correct aggregation
|
||||
// when no group has a limit set for a given field.
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 1,
|
||||
MaxMailboxCount: 1,
|
||||
SMTPDailyLimit: 1,
|
||||
SMTPMinuteLimit: 1,
|
||||
IMAPMinuteLimit: 1,
|
||||
@@ -236,6 +254,7 @@ func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
|
||||
return true
|
||||
}
|
||||
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
|
||||
canGrantLimitValue(actor.Limits.MaxMailboxCount, limits.MaxMailboxCount) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
|
||||
@@ -292,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
|
||||
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
|
||||
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
|
||||
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"},
|
||||
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
|
||||
|
||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||
|
||||
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
||||
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
|
||||
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"},
|
||||
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
||||
|
||||
{Key: PermissionGroupsView, Label: "查看权限组", Description: "查看权限组、权限目录和使用人数。", Category: "权限组"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限组", Description: "创建自定义权限组。", Category: "权限组"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限组", Description: "修改自定义权限组名称、说明和权限。", Category: "权限组"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限组", Description: "删除未被用户使用的自定义权限组。", Category: "权限组"},
|
||||
{Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"},
|
||||
|
||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||
@@ -315,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
|
||||
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
|
||||
|
||||
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
|
||||
|
||||
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
|
||||
|
||||
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
|
||||
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
|
||||
@@ -435,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
return []PermissionGroup{
|
||||
{
|
||||
ID: PermissionGroupSuperAdmin,
|
||||
Name: "超级管理员",
|
||||
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||
Name: "管理员",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
@@ -471,21 +490,6 @@ func regularUserDefaultPermissions() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func fixedPermissionGroupIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assignablePermissionGroupIDs() map[string]bool {
|
||||
out := fixedPermissionGroupIDs()
|
||||
delete(out, PermissionGroupSuperAdmin)
|
||||
delete(out, PermissionGroupRegular)
|
||||
return out
|
||||
}
|
||||
|
||||
func isAssignablePermissionGroupID(groupID string) bool {
|
||||
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||
}
|
||||
@@ -498,14 +502,6 @@ func permissionGroupOrder() map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func permissionGroupNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = group.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range defaultPermissionGroups() {
|
||||
@@ -597,6 +593,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
if u.LoginName == "" {
|
||||
u.LoginName = u.Email
|
||||
}
|
||||
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -611,6 +610,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
}
|
||||
u.Permissions = permissions
|
||||
u.Limits = limits
|
||||
if u.Role != "admin" && u.MailboxLimitOverride != nil {
|
||||
u.Limits.MaxMailboxCount = *u.MailboxLimitOverride
|
||||
}
|
||||
u.PermissionGroupIDs = groupIDs
|
||||
u.PermissionGroups = groups
|
||||
u.Protected = a.isDefaultAdminUser(u)
|
||||
@@ -769,7 +771,7 @@ func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, grou
|
||||
|
||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||
if role == "admin" {
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "管理员"}
|
||||
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||
}
|
||||
ids := []string{PermissionGroupRegular}
|
||||
@@ -1029,11 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
return u != nil && u.Role == "admin"
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
const apiTokenScopesContextKey contextKey = "api_token_scopes"
|
||||
|
||||
func (a *App) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -31,14 +32,25 @@ func (a *App) Router() http.Handler {
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/public/settings", a.handlePublicSettings)
|
||||
r.Get("/verify-email", a.handleVerifyForwardingEmail)
|
||||
r.Post("/auth/register", a.handleRegister)
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
r.Post("/auth/logout", a.handleLogout)
|
||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
||||
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
||||
r.With(a.requireAuth).Get("/me/api-tokens", a.handleListAPITokens)
|
||||
r.With(a.requireAuth).Post("/me/api-tokens", a.handleCreateAPIToken)
|
||||
r.With(a.requireAuth).Post("/me/api-tokens/{id}", a.handleUpdateAPIToken)
|
||||
r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/forwarding", a.handleForwardingSettings)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails", a.handleAddForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails/{id}/resend", a.handleResendForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/forwarding/verified-emails/{id}", a.handleDeleteForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/account", a.handleUpdateAccountForwarding)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/mailboxes/{id}/forwarding", a.handleUpdateMailboxForwarding)
|
||||
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
|
||||
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
||||
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
||||
@@ -53,25 +65,59 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}", a.handleUpdateRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/move", a.handleMoveRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/apply", a.handleApplyRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
|
||||
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Post("/open/v1/delivery-events", a.handleOpenAPIDeliveryWebhook)
|
||||
r.Route("/open", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
r.Route("/open/v1", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders/reorder", a.handleReorderMailFolders)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/folders/{id}", a.handleDeleteMailFolder)
|
||||
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/export", a.handleExportMail)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/import", a.handleImportMail)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/translate", a.handleTranslateExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
|
||||
r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
@@ -82,6 +128,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
@@ -90,6 +137,22 @@ func (a *App) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdminAccess)
|
||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||
r.Get("/admin/backups", a.handleListBackups)
|
||||
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
|
||||
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
|
||||
r.Post("/admin/backups", a.handleCreateBackup)
|
||||
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
|
||||
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
|
||||
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
|
||||
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
|
||||
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
|
||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||
@@ -115,11 +178,16 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
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)
|
||||
@@ -131,14 +199,45 @@ func (a *App) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *App) registerOpenAPIRoutes(r chi.Router) {
|
||||
r.Use(a.requireAPIToken)
|
||||
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains", a.handleOpenAPIListDomains)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/domains", a.handleOpenAPICreateDomain)
|
||||
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains/{id}", a.handleOpenAPIGetDomain)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/domains/{id}", a.handleOpenAPIUpdateDomain)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/domains/{id}", a.handleOpenAPIDeleteDomain)
|
||||
r.With(a.requireAPITokenScope("dns:read"), a.requireAdminAccess, a.requirePermission(PermissionDNSView)).Get("/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.With(a.requireAPITokenScope("dns:check"), a.requireAdminAccess, a.requirePermission(PermissionDNSCheck)).Post("/domains/{id}/dns-check", a.handleDNSCheck)
|
||||
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes", a.handleOpenAPIListMailboxes)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/mailboxes", a.handleOpenAPICreateMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes/{id}", a.handleOpenAPIGetMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionUsersResetPassword)).Post("/mailboxes/{id}/password", a.handleOpenAPIResetMailboxPassword)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
|
||||
r.With(a.requireAPITokenScope("messages:send"), a.requirePermission(PermissionMailSend)).Post("/send", a.handleOpenAPISendMail)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send", a.handleOpenAPIListSends)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}", a.handleOpenAPISendStatus)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}/events", a.handleOpenAPISendEvents)
|
||||
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/retry", a.handleOpenAPIRetrySend)
|
||||
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/cancel", a.handleOpenAPICancelSend)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/messages/{id}", a.handleOpenAPIMessage)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailAttachments)).Get("/attachments/{id}", a.handleAttachment)
|
||||
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases", a.handleOpenAPIListAliases)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesCreate)).Post("/aliases", a.handleCreateAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases/{id}", a.handleOpenAPIGetAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesUpdate)).Post("/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesDelete)).Delete("/aliases/{id}", a.handleDeleteAlias)
|
||||
}
|
||||
|
||||
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Idempotency-Key")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -160,27 +259,55 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAPIToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, scopes, err := a.authenticateAPIToken(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "api token required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
ctx = context.WithValue(ctx, apiTokenScopesContextKey, scopes)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAPITokenScope(scope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scopes, _ := r.Context().Value(apiTokenScopesContextKey).(map[string]bool)
|
||||
if !scopes["*"] && !scopes[scope] {
|
||||
respondError(w, http.StatusForbidden, "api token scope required: "+scope)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
}
|
||||
|
||||
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
cookie, err := r.Cookie(a.cfg.CookieName)
|
||||
cookie, err := r.Cookie(a.config().CookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, errors.New("no session")
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
row := a.db.QueryRowContext(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
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
@@ -191,13 +318,59 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, error) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
return nil, nil, errors.New("no api token")
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,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
|
||||
FROM api_tokens at JOIN users u ON u.id=at.user_id
|
||||
WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
|
||||
var tokenID, scopesJSON string
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, nil, errors.New("disabled")
|
||||
}
|
||||
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID)
|
||||
scopes := map[string]bool{}
|
||||
for _, scope := range jsonDecodeSlice(scopesJSON) {
|
||||
scopes[scope] = true
|
||||
}
|
||||
return &u, scopes, nil
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
fields := strings.Fields(strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return fields[1]
|
||||
}
|
||||
|
||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
|
||||
email = normalizeEmail(email)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
||||
FROM users WHERE email=? LIMIT 1`, email)
|
||||
var u User
|
||||
var passwordHash string
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errNotFound
|
||||
}
|
||||
@@ -205,6 +378,7 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
@@ -213,11 +387,12 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
}
|
||||
|
||||
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
@@ -225,6 +400,7 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -16,15 +16,21 @@ const (
|
||||
sendQueueStatusSending = "sending"
|
||||
sendQueueStatusDelivered = "delivered"
|
||||
sendQueueStatusFailed = "failed"
|
||||
sendQueueStatusCanceled = "canceled"
|
||||
|
||||
sendAuditAccepted = "accepted"
|
||||
sendAuditQueued = "queued"
|
||||
sendAuditDelivered = "delivered"
|
||||
sendAuditFailed = "failed"
|
||||
sendAuditRetry = "retry"
|
||||
sendAuditCanceled = "canceled"
|
||||
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
sendSourceOpenAPI = "open_api"
|
||||
sendSourceForwarding = "forwarding"
|
||||
sendSourceRuleForwarding = "rule_forwarding"
|
||||
sendSourceForwardingVerification = "forwarding_verification"
|
||||
|
||||
sendQueueStaleAfter = 15 * time.Minute
|
||||
sendQueueConcurrency = 4
|
||||
@@ -61,7 +67,7 @@ type sendQueueItem struct {
|
||||
}
|
||||
|
||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return "", nil
|
||||
}
|
||||
now := in.Now.UTC()
|
||||
@@ -85,7 +91,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
|
||||
return "", err
|
||||
}
|
||||
if existingID != id {
|
||||
if status == sendQueueStatusDelivered || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
||||
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
||||
if err != nil {
|
||||
@@ -124,6 +130,12 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("send queue worker stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := a.processDueSendQueue(ctx); err != nil {
|
||||
a.log.Warn("send queue worker failed", "error", err)
|
||||
}
|
||||
@@ -137,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||
@@ -230,12 +242,12 @@ func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusFailed, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusQueued, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusFailed, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -356,10 +368,26 @@ func (a *App) recordSendAudit(ctx context.Context, event, status string, in send
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano))
|
||||
id := newID("audit")
|
||||
createdAt := a.now().UTC()
|
||||
item := SendAuditEvent{ID: id, QueueID: in.QueueID, MailboxID: in.MailboxID, SentMessageID: in.SentMessageID, Source: source, Event: event, Status: status, MailFrom: normalizeEmail(in.MailFrom), HeaderFrom: normalizeEmail(in.HeaderFrom), Recipients: dedupeEmails(in.Recipients), Error: in.Error, CreatedAt: createdAt}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to start send audit transaction", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, item.MailFrom, item.HeaderFrom, jsonEncode(item.Recipients), in.Error, createdAt.Format(time.RFC3339Nano)); err != nil {
|
||||
a.log.Warn("failed to record send audit", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
if err := a.enqueueStatusWebhook(ctx, tx, "audit:"+id, "send."+event, in.MailboxID, item); err != nil {
|
||||
a.log.Warn("failed to enqueue send status webhook", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
a.log.Warn("failed to commit send audit", "event", event, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
||||
if safeID == "" || safeID == "." {
|
||||
safeID = "unknown"
|
||||
}
|
||||
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
}
|
||||
|
||||
func (a *App) writeSendQueueDeliveredMarker(id string) error {
|
||||
|
||||
@@ -8,20 +8,20 @@ import (
|
||||
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Name: a.config().CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
Secure: !a.config().AllowInsecureHTTP,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,63 +10,92 @@ import (
|
||||
)
|
||||
|
||||
type SystemSettings struct {
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
ExternalIMAPSecretSet bool `json:"externalImapSecretSet"`
|
||||
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
|
||||
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
|
||||
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
|
||||
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 {
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
ExternalIMAPSecretKey string `json:"externalImapSecretKey"`
|
||||
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
|
||||
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
|
||||
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
|
||||
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 {
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshMs int `json:"mailRefreshMs"`
|
||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshMs int `json:"mailRefreshMs"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||
}
|
||||
|
||||
type PublicDomain struct {
|
||||
@@ -83,15 +112,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := a.cfg.MailRefreshSeconds
|
||||
cfg := a.config()
|
||||
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := cfg.MailRefreshSeconds
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = 30
|
||||
}
|
||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
|
||||
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
|
||||
|
||||
// Include available domains for mailbox creation during registration
|
||||
if a.cfg.OpenRegistration {
|
||||
if cfg.OpenRegistration {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
@@ -109,12 +139,14 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
var req systemSettingsUpdate
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next := a.cfg
|
||||
next := a.config()
|
||||
next.PublicHostname = normalizeHostname(req.PublicHostname)
|
||||
if next.PublicHostname == "" {
|
||||
badRequest(w, errors.New("publicHostname is required"))
|
||||
@@ -165,12 +197,57 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
|
||||
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
|
||||
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
|
||||
next.ExternalIMAPEnabled = req.ExternalIMAPEnabled
|
||||
if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" {
|
||||
next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey)
|
||||
}
|
||||
if req.ExternalIMAPSyncSeconds <= 0 {
|
||||
req.ExternalIMAPSyncSeconds = 300
|
||||
}
|
||||
next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds
|
||||
next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts
|
||||
next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID)
|
||||
if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" {
|
||||
next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret)
|
||||
}
|
||||
next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID)
|
||||
if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" {
|
||||
next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret)
|
||||
}
|
||||
if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" {
|
||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||
return
|
||||
}
|
||||
next.TelegramMailEnabled = req.TelegramMailEnabled
|
||||
if strings.TrimSpace(req.TelegramBotToken) != "" {
|
||||
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
|
||||
}
|
||||
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
|
||||
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
|
||||
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
|
||||
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
|
||||
if next.TelegramMailEnabled {
|
||||
if next.TelegramBotToken == "" {
|
||||
badRequest(w, errors.New("Telegram Bot Token 未设置"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
|
||||
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
|
||||
return
|
||||
}
|
||||
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
|
||||
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||
previous := a.config()
|
||||
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
|
||||
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||
return
|
||||
}
|
||||
a.cfg = next
|
||||
a.setConfig(next)
|
||||
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
|
||||
}
|
||||
|
||||
@@ -180,7 +257,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.cfg
|
||||
cfg := a.config()
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
badRequest(w, errors.New("SMTP 主机未设置"))
|
||||
return
|
||||
@@ -213,7 +290,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
domain = "lanqin.local"
|
||||
}
|
||||
now := a.now().UTC()
|
||||
subject := "LanQin Email SMTP 测试"
|
||||
subject := "NewSzxcn 邮箱 SMTP 测试"
|
||||
bodyText := "这是一封 SMTP 测试邮件。"
|
||||
bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>"
|
||||
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
|
||||
@@ -247,33 +324,49 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
cfg := a.config()
|
||||
return SystemSettings{
|
||||
PublicHostname: a.cfg.PublicHostname,
|
||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||
SMTPHost: a.cfg.SMTPHost,
|
||||
SMTPPort: a.cfg.SMTPPort,
|
||||
SMTPUsername: a.cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
||||
MaildirRoot: a.cfg.MaildirRoot,
|
||||
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: a.cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: a.cfg.OpenRegistration,
|
||||
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUsername: cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: cfg.SMTPRequireTLS,
|
||||
MaildirRoot: cfg.MaildirRoot,
|
||||
MaildirScanSeconds: cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: cfg.OpenRegistration,
|
||||
TwoFactorEnabled: cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
|
||||
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
|
||||
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
|
||||
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
|
||||
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
|
||||
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
TelegramMailEnabled: cfg.TelegramMailEnabled,
|
||||
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
|
||||
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
|
||||
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -286,84 +379,132 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
}
|
||||
switch key {
|
||||
case "publicHostname":
|
||||
a.cfg.PublicHostname = value
|
||||
cfg.PublicHostname = value
|
||||
case "publicBaseUrl":
|
||||
a.cfg.PublicBaseURL = value
|
||||
cfg.PublicBaseURL = value
|
||||
case "smtpHost":
|
||||
a.cfg.SMTPHost = value
|
||||
cfg.SMTPHost = value
|
||||
case "smtpPort":
|
||||
a.cfg.SMTPPort = value
|
||||
cfg.SMTPPort = value
|
||||
case "smtpUsername":
|
||||
a.cfg.SMTPUsername = value
|
||||
cfg.SMTPUsername = value
|
||||
case "smtpPassword":
|
||||
a.cfg.SMTPPassword = value
|
||||
cfg.SMTPPassword = value
|
||||
case "smtpRequireTls":
|
||||
a.cfg.SMTPRequireTLS = value == "true"
|
||||
cfg.SMTPRequireTLS = value == "true"
|
||||
case "maildirRoot":
|
||||
a.cfg.MaildirRoot = value
|
||||
cfg.MaildirRoot = value
|
||||
case "maildirScanSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MaildirScanSeconds = n
|
||||
cfg.MaildirScanSeconds = n
|
||||
}
|
||||
case "sessionTtlHours":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.SessionTTLHours = n
|
||||
cfg.SessionTTLHours = n
|
||||
}
|
||||
case "allowInsecureHttp":
|
||||
a.cfg.AllowInsecureHTTP = value == "true"
|
||||
cfg.AllowInsecureHTTP = value == "true"
|
||||
case "openRegistration":
|
||||
a.cfg.OpenRegistration = value == "true"
|
||||
cfg.OpenRegistration = value == "true"
|
||||
case "twoFactorEnabled":
|
||||
a.cfg.TwoFactorEnabled = value == "true"
|
||||
cfg.TwoFactorEnabled = value == "true"
|
||||
case "turnstileEnabled":
|
||||
a.cfg.TurnstileEnabled = value == "true"
|
||||
cfg.TurnstileEnabled = value == "true"
|
||||
case "turnstileSiteKey":
|
||||
a.cfg.TurnstileSiteKey = value
|
||||
cfg.TurnstileSiteKey = value
|
||||
case "turnstileSecretKey":
|
||||
a.cfg.TurnstileSecretKey = value
|
||||
cfg.TurnstileSecretKey = value
|
||||
case "catchAllEnabled":
|
||||
a.cfg.CatchAllEnabled = value == "true"
|
||||
cfg.CatchAllEnabled = value == "true"
|
||||
case "mailAutoRefresh":
|
||||
a.cfg.MailAutoRefresh = value == "true"
|
||||
cfg.MailAutoRefresh = value == "true"
|
||||
case "mailRefreshSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MailRefreshSeconds = n
|
||||
cfg.MailRefreshSeconds = n
|
||||
}
|
||||
case "userMailboxApplyEnabled":
|
||||
a.cfg.UserMailboxApplyEnabled = value == "true"
|
||||
cfg.UserMailboxApplyEnabled = value == "true"
|
||||
case "userMailboxDomainIds":
|
||||
a.cfg.UserMailboxDomainIDs = value
|
||||
cfg.UserMailboxDomainIDs = value
|
||||
case "reservedMailboxPrefixes":
|
||||
a.cfg.ReservedMailboxPrefixes = value
|
||||
cfg.ReservedMailboxPrefixes = value
|
||||
case "externalImapEnabled":
|
||||
cfg.ExternalIMAPEnabled = value == "true"
|
||||
case "externalImapSecretKey":
|
||||
cfg.ExternalIMAPSecretKey = value
|
||||
case "externalImapSyncSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
cfg.ExternalIMAPSyncSeconds = n
|
||||
}
|
||||
case "externalImapAllowPrivateHosts":
|
||||
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||
case "externalImapGmailClientId":
|
||||
cfg.ExternalIMAPGmailClientID = value
|
||||
case "externalImapGmailClientSecret":
|
||||
cfg.ExternalIMAPGmailClientSecret = value
|
||||
case "externalImapOutlookClientId":
|
||||
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"
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.setConfig(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
|
||||
values := map[string]string{
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
"smtpHost": cfg.SMTPHost,
|
||||
"smtpPort": cfg.SMTPPort,
|
||||
"smtpUsername": cfg.SMTPUsername,
|
||||
"smtpPassword": cfg.SMTPPassword,
|
||||
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
||||
"maildirRoot": cfg.MaildirRoot,
|
||||
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
||||
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
||||
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
||||
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
||||
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
||||
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
||||
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
||||
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
||||
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
||||
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
||||
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
||||
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
|
||||
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
|
||||
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
"smtpHost": cfg.SMTPHost,
|
||||
"smtpPort": cfg.SMTPPort,
|
||||
"smtpUsername": cfg.SMTPUsername,
|
||||
"smtpPassword": cfg.SMTPPassword,
|
||||
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
||||
"maildirRoot": cfg.MaildirRoot,
|
||||
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
||||
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
||||
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
||||
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
||||
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
||||
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
||||
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
||||
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
||||
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
||||
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
||||
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
||||
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
|
||||
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
|
||||
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
|
||||
"externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled),
|
||||
"externalImapSecretKey": cfg.ExternalIMAPSecretKey,
|
||||
"externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds),
|
||||
"externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts),
|
||||
"externalImapGmailClientId": cfg.ExternalIMAPGmailClientID,
|
||||
"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)
|
||||
@@ -377,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const statusWebhookMaxAttempts = 10
|
||||
|
||||
type statusWebhookEnvelope struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
|
||||
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC()
|
||||
id := newID("whk")
|
||||
payload := jsonEncode(statusWebhookEnvelope{ID: id, Type: eventType, CreatedAt: now.Format(time.RFC3339Nano), Data: data})
|
||||
_, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO status_webhook_outbox(id,event_key,event_type,mailbox_id,payload_json,next_attempt_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, eventKey, eventType, mailboxID, payload, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) statusWebhookWorker(ctx context.Context) {
|
||||
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||
return
|
||||
}
|
||||
a.log.Info("status webhook worker started")
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := a.processDueStatusWebhooks(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.log.Warn("status webhook worker failed", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("status webhook worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||
return nil
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
|
||||
WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), statusWebhookMaxAttempts)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM status_webhook_outbox
|
||||
WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? ORDER BY next_attempt_at,created_at LIMIT 20`, statusWebhookMaxAttempts, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type item struct {
|
||||
id, payload string
|
||||
attempt int
|
||||
}
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var value item
|
||||
if err := rows.Scan(&value.id, &value.payload, &value.attempt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range items {
|
||||
if err := a.deliverStatusWebhook(ctx, value.id, []byte(value.payload)); err != nil {
|
||||
now := a.now().UTC()
|
||||
next := now.Add(sendRetryDelay(value.attempt + 1))
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), value.id)
|
||||
continue
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, now, now, value.id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload []byte) error {
|
||||
target, err := a.validatedStatusWebhookURL(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email-Webhook/1.0")
|
||||
req.Header.Set("X-LanQin-Webhook-Id", eventID)
|
||||
req.Header.Set("X-LanQin-Timestamp", timestamp)
|
||||
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
Transport: &http.Transport{DialContext: a.statusWebhookDialContext, DisableKeepAlives: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("status webhook returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
|
||||
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
|
||||
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
|
||||
}
|
||||
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
|
||||
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
|
||||
return nil, errors.New("invalid status webhook URL")
|
||||
}
|
||||
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
|
||||
return nil, errors.New("status webhook URL must use HTTPS")
|
||||
}
|
||||
if !a.config().StatusWebhookAllowPrivateHosts {
|
||||
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (a *App) statusWebhookDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.config().StatusWebhookAllowPrivateHosts {
|
||||
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicStatusWebhookIP(ip) {
|
||||
return nil, errors.New("private or local status webhook hosts are not allowed")
|
||||
}
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("status webhook host resolved without usable addresses")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func validatePublicWebhookHost(ctx context.Context, host string) error {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return errors.New("localhost status webhook hosts are not allowed")
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve status webhook host: %w", err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicStatusWebhookIP(ip) {
|
||||
return errors.New("private or local status webhook hosts are not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPublicStatusWebhookIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsGlobalUnicast() && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func truncateWebhookError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 1000 {
|
||||
return value[:1000]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
|
||||
|
||||
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
||||
return &SubmissionServers{
|
||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
|
||||
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
|
||||
}
|
||||
s := smtpserver.NewServer(submissionBackend{app: a})
|
||||
s.Addr = addr
|
||||
s.Domain = a.cfg.PublicHostname
|
||||
s.Domain = a.config().PublicHostname
|
||||
s.TLSConfig = tlsConfig
|
||||
s.AllowInsecureAuth = false
|
||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.ReadTimeout = smtpSessionTimeout
|
||||
s.WriteTimeout = smtpSessionTimeout
|
||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||
@@ -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 {
|
||||
@@ -241,6 +279,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insertedSent {
|
||||
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
|
||||
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
|
||||
if sentID != "" {
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
BuildVersion = "dev"
|
||||
BuildCommit = ""
|
||||
BuildDate = ""
|
||||
)
|
||||
|
||||
type systemVersionInfo struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||
BuildDate string `json:"buildDate,omitempty"`
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
LatestName string `json:"latestName,omitempty"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
UpdateEnabled bool `json:"updateEnabled"`
|
||||
CheckError string `json:"checkError,omitempty"`
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
info.CheckError = "暂时无法连接版本服务"
|
||||
a.log.Warn("check system version", "error", err)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "system administrator required")
|
||||
return
|
||||
}
|
||||
if !a.updateEnabled() {
|
||||
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to check latest release")
|
||||
return
|
||||
}
|
||||
if !info.UpdateAvailable {
|
||||
respondError(w, http.StatusConflict, "already on the latest version")
|
||||
return
|
||||
}
|
||||
|
||||
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
|
||||
if err != nil {
|
||||
a.log.Error("backup database before update", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
"currentVersion": info.CurrentVersion,
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
current := strings.TrimSpace(a.config().AppVersion)
|
||||
if current == "" {
|
||||
current = BuildVersion
|
||||
}
|
||||
info := systemVersionInfo{
|
||||
CurrentVersion: current,
|
||||
CurrentCommit: strings.TrimSpace(BuildCommit),
|
||||
BuildDate: strings.TrimSpace(BuildDate),
|
||||
UpdateEnabled: a.updateEnabled(),
|
||||
}
|
||||
|
||||
release, err := a.fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.LatestVersion = strings.TrimSpace(release.TagName)
|
||||
info.LatestName = strings.TrimSpace(release.Name)
|
||||
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
|
||||
info.ReleaseNotes = strings.TrimSpace(release.Body)
|
||||
if !release.PublishedAt.IsZero() {
|
||||
info.PublishedAt = &release.PublishedAt
|
||||
}
|
||||
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return githubRelease{}, errors.New("invalid release API URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
|
||||
client := &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
|
||||
}
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return githubRelease{}, errors.New("release API returned an empty tag")
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (a *App) updateEnabled() bool {
|
||||
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
|
||||
}
|
||||
|
||||
func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return errors.New("invalid update service URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("update service returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||
go func() {
|
||||
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := a.triggerUpdateService(ctx); err != nil {
|
||||
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
|
||||
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
|
||||
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pruneUpdateBackups(backupDir, 5); err != nil {
|
||||
a.log.Warn("prune update backups", "error", err)
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func pruneUpdateBackups(dir string, keep int) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type backupFile struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
backups := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
|
||||
}
|
||||
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if len(backups) <= keep {
|
||||
return nil
|
||||
}
|
||||
for _, backup := range backups[keep:] {
|
||||
if err := os.Remove(backup.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
func versionIsNewer(latest, current string) bool {
|
||||
latestParts, latestPrerelease, latestOK := parseVersion(latest)
|
||||
currentParts, currentPrerelease, currentOK := parseVersion(current)
|
||||
if !latestOK {
|
||||
return false
|
||||
}
|
||||
if !currentOK {
|
||||
return true
|
||||
}
|
||||
for i := 0; i < len(latestParts); i++ {
|
||||
if latestParts[i] != currentParts[i] {
|
||||
return latestParts[i] > currentParts[i]
|
||||
}
|
||||
}
|
||||
return currentPrerelease != "" && latestPrerelease == ""
|
||||
}
|
||||
|
||||
func parseVersion(value string) ([3]int, string, bool) {
|
||||
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if match == nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
var parts [3]int
|
||||
for i := 0; i < 3; i++ {
|
||||
if match[i+1] == "" {
|
||||
continue
|
||||
}
|
||||
part, err := strconv.Atoi(match[i+1])
|
||||
if err != nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return parts, match[4], true
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"tag_name":"v0.2.0","name":"Version 0.2.0","html_url":"https://example.test/releases/v0.2.0","body":"Release notes","published_at":"2026-08-03T00:00:00Z"}`)
|
||||
}))
|
||||
defer releaseServer.Close()
|
||||
|
||||
var updateRequests atomic.Int32
|
||||
updateStarted := make(chan struct{}, 1)
|
||||
releaseUpdate := make(chan struct{})
|
||||
var releaseUpdateOnce sync.Once
|
||||
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||
defer releaseBlockedUpdate()
|
||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("update method = %s", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer update-secret" {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
updateRequests.Add(1)
|
||||
updateStarted <- struct{}{}
|
||||
<-releaseUpdate
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer updateServer.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
AppVersion: "v0.1.0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: dir,
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ReleaseAPIURL: releaseServer.URL,
|
||||
UpdateServiceURL: updateServer.URL,
|
||||
UpdateServiceToken: "update-secret",
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
|
||||
var version systemVersionInfo
|
||||
if code := admin.do("GET", "/api/admin/system/version", nil, &version); code != http.StatusOK {
|
||||
t.Fatalf("version code=%d", code)
|
||||
}
|
||||
if version.CurrentVersion != "v0.1.0" || version.LatestVersion != "v0.2.0" || !version.UpdateAvailable || !version.UpdateEnabled {
|
||||
t.Fatalf("unexpected version response: %+v", version)
|
||||
}
|
||||
|
||||
type updateResponse struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
response := make(chan updateResponse, 1)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
req.AddCookie(admin.cookie)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
response <- updateResponse{code: resp.StatusCode}
|
||||
}()
|
||||
select {
|
||||
case result := <-response:
|
||||
if result.err != nil || result.code != http.StatusAccepted {
|
||||
t.Fatalf("update response=%+v", result)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
releaseBlockedUpdate()
|
||||
t.Fatal("update response waited for container replacement")
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduled update request did not start")
|
||||
}
|
||||
releaseBlockedUpdate()
|
||||
if got := updateRequests.Load(); got != 1 {
|
||||
t.Fatalf("update requests=%d", got)
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||
if err != nil || len(backups) != 1 {
|
||||
t.Fatalf("backups=%v err=%v", backups, err)
|
||||
}
|
||||
if info, err := os.Stat(backups[0]); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("backup stat=%v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpdateRequiresSystemAdministrator(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/update", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userContextKey, &User{ID: "operator", Role: "user"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
a.handleSystemUpdate(recorder, req)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("code=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionHandlesReleaseFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, err := New(Config{
|
||||
Addr: ":0",
|
||||
AppVersion: "v0.1.0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: dir,
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
ReleaseAPIURL: "http://127.0.0.1:1/releases/latest",
|
||||
AllowInsecureHTTP: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/version", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
a.handleSystemVersion(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("code=%d", recorder.Code)
|
||||
}
|
||||
var info systemVersionInfo
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(info.CheckError, "版本服务") || info.CurrentVersion != "v0.1.0" {
|
||||
t.Fatalf("unexpected response: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
latest string
|
||||
current string
|
||||
want bool
|
||||
}{
|
||||
{"v0.2.0", "v0.1.9", true},
|
||||
{"v1.0.0", "v0.99.99", true},
|
||||
{"v1.0.0", "v1.0.0", false},
|
||||
{"v1.0.0-beta.1", "v1.0.0", false},
|
||||
{"v1.0.0", "v1.0.0-beta.1", true},
|
||||
{"v1.0.0+build.2", "v1.0.0+build.1", false},
|
||||
{"v1.0.0", "dev", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := versionIsNewer(tt.latest, tt.current); got != tt.want {
|
||||
t.Errorf("versionIsNewer(%q, %q)=%v want %v", tt.latest, tt.current, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUpdateBackupsWithFewerFilesThanLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pre-update-one.db")
|
||||
if err := os.WriteFile(path, []byte("backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pruneUpdateBackups(dir, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("backup should be retained: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "<VIP> & 续费信息"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
|
||||
}
|
||||
}
|
||||
if sent[0].ReplyMarkup == nil {
|
||||
t.Fatal("Telegram OTP copy button was not included")
|
||||
}
|
||||
var delivered, storedPayload string
|
||||
var telegramMessageID int64
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
|
||||
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
|
||||
}
|
||||
if storedPayload != "{}" || telegramMessageID != 8 {
|
||||
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
|
||||
}
|
||||
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
|
||||
disablePayload := systemSettingsPayload(settings)
|
||||
disablePayload["telegramMailEnabled"] = false
|
||||
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("disable Telegram settings code=%d", code)
|
||||
}
|
||||
var pending int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
|
||||
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
var body map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
|
||||
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = serverURL
|
||||
const token = "123456:secret-token-value"
|
||||
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected Telegram network request to fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
|
||||
t.Fatalf("Telegram error exposed Bot Token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
|
||||
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
|
||||
cleaned := stripTelegramQuotedContent(body)
|
||||
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
|
||||
t.Fatalf("unexpected OTP %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
|
||||
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: strings.Repeat("R&D <team@example.com> ", 30),
|
||||
Recipient: "admin@example.com",
|
||||
Subject: strings.Repeat("超长主题 & <test> ", 50),
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: strings.Repeat("正文内容 & <重要> ", 1000),
|
||||
BodyMode: "full",
|
||||
OTP: "846981",
|
||||
AttachmentNames: []string{
|
||||
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
|
||||
},
|
||||
AttachmentCount: 12,
|
||||
})
|
||||
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
|
||||
t.Fatalf("Telegram HTML exceeds budget: %d", got)
|
||||
}
|
||||
if !strings.Contains(message.HTML, "&") || !strings.Contains(message.HTML, "<") || !strings.Contains(message.HTML, "<code>846981</code>") {
|
||||
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup == nil {
|
||||
t.Fatal("copy_text markup missing")
|
||||
}
|
||||
}
|
||||
|
||||
func 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, "<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())
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func defaultMailTemplates() []MailTemplate {
|
||||
{
|
||||
Key: "welcome",
|
||||
Name: "欢迎邮件",
|
||||
Subject: "欢迎使用 LanQin Email",
|
||||
Subject: "欢迎使用 NewSzxcn 邮箱",
|
||||
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
|
||||
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
|
||||
UpdatedAt: now,
|
||||
@@ -51,7 +51,7 @@ func defaultMailTemplates() []MailTemplate {
|
||||
{
|
||||
Key: smtpTestTemplateKey,
|
||||
Name: "SMTP 测试",
|
||||
Subject: "LanQin Email SMTP 测试",
|
||||
Subject: "NewSzxcn 邮箱 SMTP 测试",
|
||||
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
|
||||
BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>",
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
|
||||
}
|
||||
|
||||
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
|
||||
if !a.cfg.TurnstileEnabled {
|
||||
if !a.config().TurnstileEnabled {
|
||||
return nil
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
|
||||
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
|
||||
if secret == "" || token == "" {
|
||||
return errors.New("turnstile verification required")
|
||||
}
|
||||
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
|
||||
if ip := normalizeRemoteIP(remoteIP); ip != "" {
|
||||
form.Set("remoteip", ip)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
|
||||
verifyURL := strings.TrimSpace(a.turnstileURL)
|
||||
if verifyURL == "" {
|
||||
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func newTwoFactorRecoveryCode() (string, error) {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
|
||||
if len(value) > 10 {
|
||||
value = value[:10]
|
||||
}
|
||||
return value[:5] + "-" + value[5:], nil
|
||||
}
|
||||
|
||||
func normalizeRecoveryCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
return code
|
||||
}
|
||||
|
||||
func totpProvisioningURI(issuer, account, secret string) string {
|
||||
issuer = strings.TrimSpace(issuer)
|
||||
account = strings.TrimSpace(account)
|
||||
@@ -121,12 +140,64 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
|
||||
}
|
||||
|
||||
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
codes := make([]string, 0, 8)
|
||||
for len(codes) < 8 {
|
||||
code, err := newTwoFactorRecoveryCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
|
||||
newID("rcv"), userID, hashToken(normalized), now)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
if len(normalized) < 8 {
|
||||
return false, nil
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,created_at FROM users WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var secret, created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errNotFound
|
||||
}
|
||||
@@ -134,6 +205,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
@@ -142,7 +214,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
return
|
||||
}
|
||||
@@ -172,12 +244,12 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"secret": secret,
|
||||
"otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret),
|
||||
"otpauthUrl": totpProvisioningURI("NewSzxcn 邮箱", current.Email, secret),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
return
|
||||
}
|
||||
@@ -210,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
@@ -219,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -248,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
|
||||
+214
-54
@@ -3,24 +3,52 @@ package app
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Scopes []string `json:"scopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeliveryEvent struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
Provider string `json:"provider"`
|
||||
QueueID string `json:"queueId"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
@@ -44,6 +72,8 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -57,11 +87,16 @@ type Alias struct {
|
||||
}
|
||||
|
||||
type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
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"`
|
||||
UIDValidity int64 `json:"uidValidity"`
|
||||
UIDNext int64 `json:"uidNext"`
|
||||
HighestModSeq int64 `json:"highestModseq"`
|
||||
}
|
||||
|
||||
type MailLabel struct {
|
||||
@@ -73,32 +108,46 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ExternalAccountID string `json:"externalAccountId,omitempty"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
AuthenticationResults string `json:"authenticationResults"`
|
||||
ReceivedSPF string `json:"receivedSpf"`
|
||||
SPF string `json:"spf"`
|
||||
DKIM string `json:"dkim"`
|
||||
DMARC string `json:"dmarc"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -168,9 +217,11 @@ type MailRule struct {
|
||||
}
|
||||
|
||||
type MailRuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
MatchMode string `json:"matchMode,omitempty"`
|
||||
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
type MailRuleAction struct {
|
||||
@@ -189,12 +240,24 @@ type BlockedSender struct {
|
||||
}
|
||||
|
||||
type MailStats struct {
|
||||
TotalMessages int64 `json:"totalMessages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
TotalMessages int64 `json:"totalMessages"`
|
||||
TotalIncoming int64 `json:"totalIncoming"`
|
||||
TotalOutgoing int64 `json:"totalOutgoing"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
TodayOutgoing int64 `json:"todayOutgoing"`
|
||||
DraftMessages int64 `json:"draftMessages"`
|
||||
FailedSends int64 `json:"failedSends"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
AverageMessageBytes int64 `json:"averageMessageBytes"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
Trend []MailStatsTrendPoint `json:"trend"`
|
||||
Distribution []MailStatsDistributionItem `json:"distribution"`
|
||||
TopContacts []MailStatsContact `json:"topContacts"`
|
||||
}
|
||||
|
||||
type MailStatsFolderCount struct {
|
||||
@@ -204,3 +267,100 @@ type MailStatsFolderCount struct {
|
||||
Unread int64 `json:"unread"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type MailStatsTrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
Incoming int64 `json:"incoming"`
|
||||
Outgoing int64 `json:"outgoing"`
|
||||
}
|
||||
|
||||
type MailStatsDistributionItem struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type MailStatsContact struct {
|
||||
Email string `json:"email"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ExternalIMAPAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLSMode string `json:"tlsMode"`
|
||||
Username string `json:"username"`
|
||||
AuthMode string `json:"authMode"`
|
||||
OAuthProvider string `json:"oauthProvider,omitempty"`
|
||||
OAuthEmail string `json:"oauthEmail,omitempty"`
|
||||
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ExternalIMAPFolder struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type ExternalIMAPSyncRun struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
Source string `json:"source"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -14,12 +14,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
const minimumPasswordLength = 6
|
||||
|
||||
func hasMinimumPasswordLength(password string) bool {
|
||||
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||
}
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
@@ -53,6 +60,7 @@ func (p *HTMLPolicy) Sanitize(s string) string {
|
||||
}
|
||||
|
||||
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
|
||||
var htmlNonContentTagRe = regexp.MustCompile(`(?is)<(style|script|head|title|noscript)\b[^>]*>.*?</\s*(style|script|head|title|noscript)\s*>`)
|
||||
|
||||
func extractSafeEmailStyles(value string) ([]string, string) {
|
||||
styles := []string{}
|
||||
@@ -148,6 +156,70 @@ func normalizeEmail(s string) string {
|
||||
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
|
||||
}
|
||||
|
||||
func normalizeLoginName(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(s, "@") {
|
||||
return normalizeEmail(s)
|
||||
}
|
||||
return normalizeLocalPart(s)
|
||||
}
|
||||
|
||||
func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
||||
loginName := normalizeLoginName(value)
|
||||
for _, fallback := range fallbacks {
|
||||
if loginName != "" {
|
||||
break
|
||||
}
|
||||
loginName = normalizeLoginName(fallback)
|
||||
}
|
||||
if loginName == "" {
|
||||
return "", errors.New("登录名不能为空")
|
||||
}
|
||||
if len([]rune(loginName)) > 80 {
|
||||
return "", errors.New("登录名不能超过 80 个字符")
|
||||
}
|
||||
if strings.Contains(loginName, "@") {
|
||||
parts := strings.SplitN(loginName, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", errors.New("登录名格式无效")
|
||||
}
|
||||
return loginName, nil
|
||||
}
|
||||
if len([]rune(loginName)) < 2 {
|
||||
return "", errors.New("登录名至少需要 2 个字符")
|
||||
}
|
||||
return loginName, nil
|
||||
}
|
||||
|
||||
func cleanUsername(value string) (string, error) {
|
||||
username, err := cleanLoginName(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return "", errors.New("登录名不能使用邮箱地址")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func cleanPrimaryEmail(value string) (string, error) {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
if len([]rune(email)) > 254 {
|
||||
return "", errors.New("邮箱地址不能超过 254 个字符")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
@@ -214,6 +286,21 @@ func nullableString(v string) any {
|
||||
return v
|
||||
}
|
||||
|
||||
func nullableInt(v *int) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func intPtrFromNull(v sql.NullInt64) *int {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
value := int(v.Int64)
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339Nano, v)
|
||||
return t
|
||||
@@ -241,6 +328,7 @@ func snippetFrom(text, html string) string {
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
s = htmlNonContentTagRe.ReplaceAllString(s, " ")
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LanQin Email</title>
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>NewSzxcn 邮箱</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "lanqin-email-web",
|
||||
"name": "newszxcn-email-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,27 +26,22 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@tiptap/core": "^3.27.0",
|
||||
"@tiptap/extension-color": "^3.27.0",
|
||||
"@tiptap/extension-font-family": "^3.27.0",
|
||||
"@tiptap/extension-highlight": "^3.27.0",
|
||||
"@tiptap/extension-image": "^3.27.0",
|
||||
"@tiptap/extension-link": "^3.27.0",
|
||||
"@tiptap/extension-placeholder": "^3.27.0",
|
||||
"@tiptap/extension-text-align": "^3.27.0",
|
||||
"@tiptap/extension-text-style": "^3.27.0",
|
||||
"@tiptap/extension-underline": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.4.10",
|
||||
"dompurify": "3.4.13",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "6.30.4",
|
||||
"react-router-dom": "7.18.2",
|
||||
"tailwind-merge": "2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,7 +50,7 @@
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.25",
|
||||
"tailwindcss": "3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.6.3",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
||||
<path d="M14 22.5A6.5 6.5 0 0 1 20.5 16h23A6.5 6.5 0 0 1 50 22.5v19A6.5 6.5 0 0 1 43.5 48h-23A6.5 6.5 0 0 1 14 41.5v-19Z" fill="#f8fafc"/>
|
||||
<path d="M18 23.5 32 34l14-10.5" fill="none" stroke="#0f172a" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 42V24h4.8l10.4 11.3V24H44v18h-4.8L28.8 30.7V42H24Z" fill="#2563eb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
@@ -27,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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react"
|
||||
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -27,13 +27,15 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "概览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限组", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] },
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||
]
|
||||
|
||||
@@ -55,7 +57,7 @@ function ProtectedContent() {
|
||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
|
||||
|
||||
if (isMailRoute || isProfileRoute) {
|
||||
return <Outlet />
|
||||
@@ -64,21 +66,24 @@ function ProtectedContent() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">LanQin Email</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarHeader className="border-b">
|
||||
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{isAdminRoute && <SystemVersionDialog className="ml-10" />}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||
@@ -105,9 +110,6 @@ function ProtectedContent() {
|
||||
<span className="truncate font-semibold">{user.displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
||||
{user.role === "admin" ? "超级管理员" : "普通用户"}
|
||||
</Badge>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -125,7 +127,7 @@ function ProtectedContent() {
|
||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||
<SidebarTrigger aria-label="打开导航" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as React from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { CheckCircle2, Download, ExternalLink, Loader2, RefreshCcw, TriangleAlert } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn, formatDate } from "@/lib/utils"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
const frontendVersion = import.meta.env.VITE_APP_VERSION || "dev"
|
||||
|
||||
export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "sidebar" | "inline"; className?: string }) {
|
||||
const me = useMe()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [updatePhase, setUpdatePhase] = React.useState<"idle" | "starting" | "restarting">("idle")
|
||||
const version = useQuery({
|
||||
queryKey: ["admin", "system-version"],
|
||||
queryFn: api.systemVersion,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
const currentVersion = version.data?.currentVersion || frontendVersion
|
||||
const isSystemAdmin = me.data?.user.role === "admin"
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const targetVersion = version.data?.latestVersion
|
||||
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||
try {
|
||||
result = await api.updateSystem()
|
||||
} catch (error) {
|
||||
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||
result = {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
message: "更新请求已发送,正在等待服务恢复",
|
||||
}
|
||||
}
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdatePhase("idle")
|
||||
toast({ title: "更新失败", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = mode === "inline" ? (
|
||||
<Button type="button" variant="outline" className={cn("h-11 justify-start gap-2 px-4 text-base font-normal", className)}>
|
||||
<RefreshCcw className="h-5 w-5 text-primary" />
|
||||
{currentVersion}
|
||||
{version.data?.updateAvailable && <Badge className="ml-1">可更新</Badge>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant={version.data?.updateAvailable ? "secondary" : "ghost"}
|
||||
className={cn("h-8 w-fit max-w-full justify-start gap-2 rounded-md px-2 text-xs font-medium group-data-[collapsible=icon]:hidden", className)}
|
||||
aria-label={`系统版本 ${currentVersion}`}
|
||||
>
|
||||
<span className="truncate">{currentVersion}</span>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", version.data?.updateAvailable ? "bg-amber-500" : "bg-emerald-500")} aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-h-[88svh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 pr-7">
|
||||
<DialogTitle>系统版本</DialogTitle>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => version.refetch()} disabled={version.isFetching || update.isPending} aria-label="重新检查更新" title="重新检查更新">
|
||||
<RefreshCcw className={cn("h-4 w-4", version.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="border-b pb-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">当前版本</div>
|
||||
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||
</div>
|
||||
|
||||
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
||||
{version.data?.checkError && <VersionState icon={<TriangleAlert />} title="暂时无法检查更新" description={version.data.checkError} tone="warning" />}
|
||||
{version.data && !version.data.checkError && !version.data.updateAvailable && <VersionState icon={<CheckCircle2 />} title="已是最新版本" description="当前无需更新。" tone="success" />}
|
||||
{version.data?.updateAvailable && (
|
||||
<VersionState
|
||||
icon={<Download />}
|
||||
title="发现新版本"
|
||||
description={`${version.data.latestVersion} 已发布${version.data.publishedAt ? ` · ${formatDate(version.data.publishedAt)}` : ""}`}
|
||||
tone="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{version.data?.releaseNotes && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">更新日志</div>
|
||||
<div className="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
|
||||
{version.data.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{update.isPending && (
|
||||
<div className="rounded-md border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{updatePhase === "starting" ? "正在准备更新" : "正在重启服务"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请保持页面打开,服务恢复后会自动刷新。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.data?.updateAvailable && !version.data.updateEnabled && (
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
当前部署未启用页面更新,请在服务器执行 <code className="rounded bg-muted px-1.5 py-0.5 text-foreground">sudo newszxcn-email update</code>。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<div>
|
||||
{version.data?.releaseUrl && (
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<a href={version.data.releaseUrl} target="_blank" rel="noreferrer">
|
||||
更新详情<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{version.data?.updateAvailable && version.data.updateEnabled && (
|
||||
<Button type="button" disabled={!isSystemAdmin || update.isPending} onClick={() => update.mutate()}>
|
||||
{update.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{isSystemAdmin ? "立即更新" : "仅超级管理员可更新"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionState({ icon, title, description, tone = "neutral" }: { icon: React.ReactNode; title: string; description: string; tone?: "neutral" | "success" | "warning" }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 rounded-md border p-4",
|
||||
tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100",
|
||||
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100",
|
||||
)}>
|
||||
<span className="mt-0.5 [&>svg]:h-5 [&>svg]:w-5">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-1 block text-sm opacity-75">{description}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForUpdatedService(targetVersion: string) {
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
await delay(3000)
|
||||
try {
|
||||
const health = await fetch(`/healthz?update=${Date.now()}`, { cache: "no-store" })
|
||||
if (!health.ok) {
|
||||
continue
|
||||
}
|
||||
const response = await fetch(`/api/admin/system/version?update=${Date.now()}`, { credentials: "include", cache: "no-store" })
|
||||
if (!response.ok) continue
|
||||
const body = await response.json() as { currentVersion?: string }
|
||||
if (body.currentVersion === targetVersion) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error("更新等待超时,请稍后手动刷新页面检查服务状态")
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isUpdateConnectionInterruption(error: unknown) {
|
||||
if (!(error instanceof Error)) return false
|
||||
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user