Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c468cc1a | |||
| 75c399e178 | |||
| d345ae7e08 | |||
| 7cbae154bf | |||
| bd6edf353d | |||
| 14ec1979b2 | |||
| 6fa98cec97 | |||
| 71746bdfba | |||
| f15a3d3222 | |||
| 7fc91f2ed9 | |||
| c482e5b896 | |||
| 07e62e9c2d | |||
| 5797b472d9 | |||
| 3caee11e38 | |||
| 2dd3647923 | |||
| ccb8ce01d9 | |||
| 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 |
@@ -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,11 @@
|
|||||||
|
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
|
||||||
|
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
|
||||||
|
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
|
||||||
|
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
|
||||||
|
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
|
||||||
|
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
|
||||||
|
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
|
||||||
|
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
|
||||||
|
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
|
||||||
|
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
|
||||||
|
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
|
||||||
|
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
|
||||||
|
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||||
|
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||||
|
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
|
||||||
|
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
|
||||||
|
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
|
||||||
|
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
|
||||||
|
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
|
||||||
|
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
|
||||||
|
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- 修复刷新后台仪表盘时,注册、自助申请邮箱等功能在设置读取完成前短暂显示为关闭的问题。
|
||||||
|
- 为后台统计、系统状态、域名与列表页面、备份设置和个人中心补充统一加载状态,避免将未加载数据误显示为 0、未配置或暂无数据。
|
||||||
|
- 优化受限权限账号的状态展示:无权读取的系统设置明确显示为不可查看,不再误判为关闭或未配置。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.43...v1.2.44](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.43...v1.2.44)
|
||||||
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -39,6 +40,15 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
+128
-59
@@ -1,6 +1,7 @@
|
|||||||
name: Docker Release
|
name: Docker Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
@@ -26,6 +27,15 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -135,8 +145,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Prepare image name
|
- name: Prepare image name
|
||||||
id: image
|
id: image
|
||||||
@@ -160,7 +170,7 @@ jobs:
|
|||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
labels: |
|
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 }}
|
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
@@ -196,77 +206,136 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
tag="${{ needs.release.outputs.tag }}"
|
tag="${{ needs.release.outputs.tag }}"
|
||||||
version="${{ needs.release.outputs.version }}"
|
|
||||||
repo="${GITHUB_REPOSITORY}"
|
repo="${GITHUB_REPOSITORY}"
|
||||||
repo_url="https://github.com/${repo}"
|
repo_url="https://github.com/${repo}"
|
||||||
image_base="${REGISTRY}/${repo}"
|
|
||||||
image_base="${image_base,,}"
|
|
||||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||||
generate_args=(-f "tag_name=${tag}")
|
version_notes=".github/release-notes/${tag}.md"
|
||||||
if [[ -n "${previous_tag}" ]]; then
|
|
||||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
if [[ -f "${version_notes}" ]]; then
|
||||||
|
cp "${version_notes}" generated-release-notes.md
|
||||||
|
else
|
||||||
|
generate_args=(-f "tag_name=${tag}")
|
||||||
|
if [[ -n "${previous_tag}" ]]; then
|
||||||
|
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||||
|
{
|
||||||
|
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||||
|
echo
|
||||||
|
if [[ -n "${previous_tag}" ]]; then
|
||||||
|
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||||
|
else
|
||||||
|
echo "- 首个公开版本。"
|
||||||
|
echo
|
||||||
|
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||||
|
fi
|
||||||
|
} > generated-release-notes.md
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
cp generated-release-notes.md release-notes.md
|
||||||
{
|
|
||||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
|
||||||
echo
|
|
||||||
if [[ -n "${previous_tag}" ]]; then
|
|
||||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
|
||||||
echo
|
|
||||||
echo
|
|
||||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
|
||||||
else
|
|
||||||
echo "- 首个公开版本。"
|
|
||||||
echo
|
|
||||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
|
||||||
fi
|
|
||||||
} > generated-release-notes.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > release-notes.md <<EOF
|
|
||||||
# LanQin Email ${tag}
|
|
||||||
|
|
||||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
|
||||||
|
|
||||||
## 注意
|
|
||||||
|
|
||||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
|
||||||
|
|
||||||
## 使用文档
|
|
||||||
|
|
||||||
- [项目文档](${repo_url}#readme)
|
|
||||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
|
||||||
|
|
||||||
## Docker 镜像
|
|
||||||
|
|
||||||
| 组件 | 镜像 |
|
|
||||||
|------|------|
|
|
||||||
| All-in-one | \`${image_base}:${tag}\` |
|
|
||||||
| API | \`${image_base}-api:${tag}\` |
|
|
||||||
| Web | \`${image_base}-web:${tag}\` |
|
|
||||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
|
||||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
|
||||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
|
||||||
|
|
||||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
|
||||||
EOF
|
|
||||||
|
|
||||||
{
|
|
||||||
echo
|
|
||||||
cat generated-release-notes.md
|
|
||||||
} >> release-notes.md
|
|
||||||
|
|
||||||
- name: Create or update GitHub release
|
- name: Create or update GitHub release
|
||||||
|
id: release_result
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
tag="${{ needs.release.outputs.tag }}"
|
tag="${{ needs.release.outputs.tag }}"
|
||||||
title="LanQin Email ${tag}"
|
title="NewSzxcn Email ${tag}"
|
||||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||||
|
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||||
|
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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."
|
||||||
|
|||||||
+8
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
|
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
|
||||||
|
|
||||||
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
[Releases](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -18,11 +18,13 @@ NewSzxcn Email is a self-hosted mail server with a complete Webmail client and a
|
|||||||
Debian and Ubuntu on `amd64` or `arm64` are supported.
|
Debian and Ubuntu on `amd64` or `arm64` are supported.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||||
|
|
||||||
|
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
|
## 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.
|
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
|
||||||
@@ -39,10 +41,12 @@ Useful commands:
|
|||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
|
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
|
||||||
|
|
||||||
## Required ports
|
## Required ports
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Publ
|
|||||||
## Manual source deployment
|
## Manual source deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||||
cd NewSzxcn-Email/deploy
|
cd NewSzxcn-Email/deploy
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||||
|
|
||||||
[](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) · [MIT License](LICENSE)
|
||||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
|
|
||||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -16,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||||
@@ -26,13 +21,79 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
已使用 `root` 登录时,也可以使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管理面板
|
||||||
|
|
||||||
|
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
|
||||||
|
|
||||||
|
```text
|
||||||
|
==================================================
|
||||||
|
NewSzxcn Email 管理面板
|
||||||
|
==================================================
|
||||||
|
状态:尚未安装
|
||||||
|
--------------------------------------------------
|
||||||
|
1. 一键安装 NewSzxcn Email
|
||||||
|
0. 退出
|
||||||
|
==================================================
|
||||||
|
请选择 [1]:
|
||||||
|
```
|
||||||
|
|
||||||
|
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
|
||||||
|
|
||||||
|
```text
|
||||||
|
==================================================
|
||||||
|
NewSzxcn Email 管理面板
|
||||||
|
==================================================
|
||||||
|
状态:运行中
|
||||||
|
版本:v1.2.19(示例,以实际安装版本为准)
|
||||||
|
地址:https://mail.example.com
|
||||||
|
--------------------------------------------------
|
||||||
|
安装与维护
|
||||||
|
1. 重新安装(完整备份,失败自动恢复)
|
||||||
|
2. 更新系统(自动备份,失败自动回滚)
|
||||||
|
3. 检查并修复现有安装
|
||||||
|
|
||||||
|
服务管理
|
||||||
|
4. 查看运行状态
|
||||||
|
5. 重启服务
|
||||||
|
6. 查看实时日志
|
||||||
|
|
||||||
|
证书与恢复
|
||||||
|
7. 管理 SSL 证书
|
||||||
|
8. 回滚到上次更新前版本
|
||||||
|
|
||||||
|
账号与帮助
|
||||||
|
9. 邮箱后台配置指南
|
||||||
|
10. 查看管理员登录信息
|
||||||
|
11. 重置管理员登录密码
|
||||||
|
|
||||||
|
危险操作
|
||||||
|
12. 卸载服务(保留数据)
|
||||||
|
|
||||||
|
0. 退出
|
||||||
|
==================================================
|
||||||
|
请选择 [2]:
|
||||||
|
```
|
||||||
|
|
||||||
|
容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将
|
||||||
|
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
|
||||||
|
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
|
||||||
|
|
||||||
脚本会自动完成:
|
脚本会自动完成:
|
||||||
|
|
||||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
- 安装或检查 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` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -48,6 +109,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
|||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|
||||||
|
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||||
|
|
||||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||||
|
|
||||||
### 命令行更新
|
### 命令行更新
|
||||||
@@ -56,21 +119,32 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
|||||||
sudo newszxcn-email update
|
sudo newszxcn-email update
|
||||||
```
|
```
|
||||||
|
|
||||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email rollback
|
sudo newszxcn-email rollback
|
||||||
```
|
```
|
||||||
|
|
||||||
|
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||||
|
|
||||||
常用运维命令:
|
常用运维命令:
|
||||||
|
|
||||||
```bash
|
```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 status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||||
|
|
||||||
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -104,17 +178,18 @@ sudo newszxcn-email uninstall
|
|||||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
|-- data/ # SQLite、附件和更新前备份
|
|-- data/ # SQLite、附件和更新前备份
|
||||||
|-- mail/ # Maildir 邮件原文
|
|-- mail/ # Maildir 邮件原文
|
||||||
`-- dkim/ # DKIM 私钥
|
|-- dkim/ # DKIM 私钥
|
||||||
|
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||||
```
|
```
|
||||||
|
|
||||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||||
|
|
||||||
## 手动部署
|
## 手动部署
|
||||||
|
|
||||||
需要自行控制 Compose 配置时:
|
需要自行控制 Compose 配置时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||||
cd NewSzxcn-Email/deploy
|
cd NewSzxcn-Email/deploy
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env
|
# 编辑 .env
|
||||||
@@ -136,7 +211,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
|||||||
- 后端:Go、Chi、SQLite
|
- 后端:Go、Chi、SQLite
|
||||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||||
- 邮件:Postfix、Dovecot、Rspamd
|
- 邮件:Postfix、Dovecot、Rspamd
|
||||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
- 部署:Docker、Docker Compose、GHCR
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -151,6 +226,8 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||||
|
|
||||||
提交前建议运行:
|
提交前建议运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+23
-9
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||||
|
|
||||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
[版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||||
@@ -21,13 +21,22 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
已使用 `root` 登录时,也可以使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会自动完成:
|
脚本会自动完成:
|
||||||
|
|
||||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
|
||||||
|
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||||
|
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||||
|
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -39,6 +48,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
|||||||
|
|
||||||
## 更新与回滚
|
## 更新与回滚
|
||||||
|
|
||||||
|
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
|
||||||
|
|
||||||
### 后台页面更新
|
### 后台页面更新
|
||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
@@ -62,10 +73,12 @@ sudo newszxcn-email rollback
|
|||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -99,17 +112,18 @@ sudo newszxcn-email uninstall
|
|||||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
├── data/ # SQLite、附件和更新前备份
|
├── data/ # SQLite、附件和更新前备份
|
||||||
├── mail/ # Maildir 邮件原文
|
├── mail/ # Maildir 邮件原文
|
||||||
└── dkim/ # DKIM 私钥
|
├── dkim/ # DKIM 私钥
|
||||||
|
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||||
```
|
```
|
||||||
|
|
||||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||||
|
|
||||||
## 手动部署
|
## 手动部署
|
||||||
|
|
||||||
需要自行控制 Compose 配置时:
|
需要自行控制 Compose 配置时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||||
cd NewSzxcn-Email/deploy
|
cd NewSzxcn-Email/deploy
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env
|
# 编辑 .env
|
||||||
@@ -131,7 +145,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
|||||||
- 后端:Go、Chi、SQLite
|
- 后端:Go、Chi、SQLite
|
||||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||||
- 邮件:Postfix、Dovecot、Rspamd
|
- 邮件:Postfix、Dovecot、Rspamd
|
||||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
- 部署:Docker、Docker Compose、GHCR
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -3,9 +3,14 @@ module lanqin-email-api
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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
|
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
|
golang.org/x/text v0.38.0
|
||||||
modernc.org/sqlite v1.31.1
|
modernc.org/sqlite v1.31.1
|
||||||
)
|
)
|
||||||
@@ -13,19 +18,14 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
|
||||||
github.com/emersion/go-smtp v0.24.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/net v0.26.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
|
||||||
golang.org/x/sys v0.23.0 // indirect
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
|||||||
+8
-8
@@ -10,8 +10,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
|
|||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-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 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
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.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
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 h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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-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.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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
|||||||
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
Messages int64 `json:"messages"`
|
Messages int64 `json:"messages"`
|
||||||
UnreadMessages int64 `json:"unreadMessages"`
|
UnreadMessages int64 `json:"unreadMessages"`
|
||||||
StorageBytes int64 `json:"storageBytes"`
|
StorageBytes int64 `json:"storageBytes"`
|
||||||
|
TodaySent int64 `json:"todaySent"`
|
||||||
|
TodayReceived int64 `json:"todayReceived"`
|
||||||
|
SendDelivered int64 `json:"sendDelivered"`
|
||||||
|
SendFailed int64 `json:"sendFailed"`
|
||||||
|
QueueMessages int64 `json:"queueMessages"`
|
||||||
}
|
}
|
||||||
|
now := a.now().UTC()
|
||||||
|
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||||
queries := []struct {
|
queries := []struct {
|
||||||
q string
|
q string
|
||||||
dest *int64
|
dest *int64
|
||||||
|
args []any
|
||||||
}{
|
}{
|
||||||
{`SELECT COUNT(*) FROM users`, &out.Users},
|
{q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
|
||||||
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
|
{q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
|
||||||
{`SELECT COUNT(*) FROM domains`, &out.Domains},
|
{q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
|
||||||
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
|
{q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
|
||||||
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
|
{q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
|
||||||
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
|
{q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
|
||||||
{`SELECT COUNT(*) FROM messages`, &out.Messages},
|
{q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
|
||||||
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
|
{q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
|
||||||
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
|
{q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
|
||||||
|
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
|
||||||
|
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
|
||||||
|
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
|
||||||
|
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
|
||||||
|
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
|
||||||
}
|
}
|
||||||
for _, item := range queries {
|
for _, item := range queries {
|
||||||
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
|
if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -49,10 +62,10 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
|
||||||
ORDER BY u.created_at DESC`)
|
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||||
return
|
return
|
||||||
@@ -64,7 +77,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
var created, mailboxCSV string
|
||||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,6 +114,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -108,14 +122,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor := currentUser(r)
|
actor := currentUser(r)
|
||||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
emailInput := req.Email
|
||||||
|
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||||
|
emailInput = req.LoginName
|
||||||
|
}
|
||||||
|
primaryEmail, err := cleanPrimaryEmail(emailInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
displayName := strings.TrimSpace(req.DisplayName)
|
displayName := strings.TrimSpace(req.DisplayName)
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = loginName
|
badRequest(w, errors.New("displayName is required"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
role := strings.TrimSpace(req.Role)
|
role := strings.TrimSpace(req.Role)
|
||||||
if role == "" {
|
if role == "" {
|
||||||
@@ -125,8 +144,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid role"))
|
badRequest(w, errors.New("invalid role"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
if role == "admin" {
|
||||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||||
@@ -137,8 +156,16 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
mailboxLimitOverride = nil
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
storageQuotaMB := req.StorageQuotaMB
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
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
|
return
|
||||||
}
|
}
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
@@ -154,11 +181,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
|
||||||
|
var primaryDomainID string
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
|
||||||
|
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load account domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
permissionGroupIDs := req.PermissionGroupIDs
|
permissionGroupIDs := req.PermissionGroupIDs
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
permissionGroupIDs = nil
|
permissionGroupIDs = nil
|
||||||
@@ -183,10 +221,13 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
current := currentUser(r)
|
current := currentUser(r)
|
||||||
var req struct {
|
var req struct {
|
||||||
|
LoginName string `json:"loginName"`
|
||||||
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled *bool `json:"disabled"`
|
Disabled *bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB *int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -211,7 +252,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "user not found")
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
return
|
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")
|
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,6 +286,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existing.Role == "admin" && disabled {
|
||||||
|
badRequest(w, errors.New("唯一管理员不能停用"))
|
||||||
|
return
|
||||||
|
}
|
||||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||||
if req.MailboxLimitOverride != nil {
|
if req.MailboxLimitOverride != nil {
|
||||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||||
@@ -234,6 +301,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
mailboxLimitOverride = nil
|
||||||
}
|
}
|
||||||
|
var storageQuotaMB int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.StorageQuotaMB != nil {
|
||||||
|
storageQuotaMB = *req.StorageQuotaMB
|
||||||
|
}
|
||||||
|
if storageQuotaMB < 100 {
|
||||||
|
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -278,8 +357,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
|
||||||
displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
|
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,6 +376,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existing.Role == "admin" {
|
||||||
|
a.updateConfig(func(cfg *Config) {
|
||||||
|
cfg.AdminEmail = primaryEmail
|
||||||
|
})
|
||||||
|
}
|
||||||
user, err := a.adminUserByID(r.Context(), id)
|
user, err := a.adminUserByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "user not found")
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
@@ -320,8 +408,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
@@ -367,11 +455,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "user not found")
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
return
|
return
|
||||||
} else if a.isDefaultAdminUser(target) {
|
} else if target.Role == "admin" {
|
||||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
badRequest(w, errors.New("administrator accounts cannot be deleted"))
|
||||||
return
|
|
||||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
|
||||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||||
@@ -511,6 +596,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +607,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Role string `json:"role"`
|
|
||||||
OwnerLoginName string `json:"ownerLoginName"`
|
OwnerLoginName string `json:"ownerLoginName"`
|
||||||
OwnerEmail string `json:"ownerEmail"`
|
OwnerEmail string `json:"ownerEmail"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@@ -538,25 +623,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
userID := strings.TrimSpace(req.UserID)
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
if req.QuotaMB < 0 {
|
||||||
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
return
|
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)
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -572,15 +643,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
|
||||||
userID := strings.TrimSpace(req.UserID)
|
|
||||||
displayName := req.DisplayName
|
displayName := req.DisplayName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = address
|
displayName = address
|
||||||
}
|
}
|
||||||
|
var disabled, ownerStorageQuotaMB int
|
||||||
|
var passwordHash, ownerRole string
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
var disabled int
|
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||||
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -588,31 +658,34 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(disabled) {
|
} else {
|
||||||
badRequest(w, errors.New("owner user is disabled"))
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
ownerEmailInput := req.OwnerEmail
|
||||||
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
|
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||||
|
ownerEmailInput = req.OwnerLoginName
|
||||||
|
}
|
||||||
|
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
|
err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if hashErr != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID = newID("usr")
|
userID = newID("usr")
|
||||||
ownerDisplayName := displayName
|
passwordHash = string(hash)
|
||||||
if !strings.EqualFold(ownerLoginName, address) {
|
ownerRole = "user"
|
||||||
ownerDisplayName = ownerLoginName
|
ownerStorageQuotaMB = defaultUserStorageQuotaMB
|
||||||
}
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
|
||||||
if err != nil {
|
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -621,16 +694,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if intBool(disabled) {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
quotaMB := req.QuotaMB
|
||||||
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
|
if quotaMB == 0 {
|
||||||
|
quotaMB = ownerStorageQuotaMB
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||||
|
return
|
||||||
|
}
|
||||||
m, err := a.mailboxByID(r.Context(), mailboxID)
|
m, err := a.mailboxByID(r.Context(), mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||||
@@ -656,8 +739,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("displayName is required"))
|
badRequest(w, errors.New("displayName is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.QuotaMB <= 0 {
|
if req.QuotaMB < 0 {
|
||||||
req.QuotaMB = 1024
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -667,13 +751,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid status"))
|
badRequest(w, errors.New("invalid status"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
existingMailbox, err := a.mailboxByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existingMailbox.Primary && status != existingMailbox.Status {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
userID := strings.TrimSpace(req.UserID)
|
userID := strings.TrimSpace(req.UserID)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
badRequest(w, errors.New("userId is required"))
|
badRequest(w, errors.New("userId is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existingMailbox.Primary && userID != existingMailbox.UserID {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
var disabled int
|
var disabled int
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
var ownerRole, ownerPasswordHash string
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -685,8 +783,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("owner user is disabled"))
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
if ownerRole == "admin" {
|
||||||
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
req.QuotaMB = 0
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||||
|
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
return
|
||||||
@@ -706,26 +807,17 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
current := currentUser(r)
|
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||||
var owner string
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
} else {
|
||||||
|
badRequest(w, err)
|
||||||
|
}
|
||||||
return
|
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)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
messageIDs := []string{}
|
messageIDs := []string{}
|
||||||
@@ -741,12 +833,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
affected, _ := res.RowsAffected()
|
affected, _ := res.RowsAffected()
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
@@ -1092,15 +1184,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||||
WHERE u.id=?
|
WHERE u.id=?
|
||||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
|
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
|
||||||
var item AdminUser
|
var item AdminUser
|
||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
var created, mailboxCSV string
|
||||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.Disabled = intBool(disabled)
|
item.Disabled = intBool(disabled)
|
||||||
@@ -1165,9 +1257,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
|
if err := a.markMailboxPrimary(ctx, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markPrimaryMailboxes(items []Mailbox) {
|
||||||
|
primaryByUser := make(map[string]int)
|
||||||
|
for i := range items {
|
||||||
|
candidate, ok := primaryByUser[items[i].UserID]
|
||||||
|
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
|
||||||
|
primaryByUser[items[i].UserID] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, index := range primaryByUser {
|
||||||
|
items[index].Primary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
|
||||||
|
var primaryID string
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
|
||||||
|
WHERE mb.user_id=?
|
||||||
|
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailbox.Primary = mailbox.ID == primaryID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
|
||||||
|
mailbox, err := a.mailboxByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mailbox.Primary {
|
||||||
|
return errors.New("用户默认邮箱不能删除")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
|
|||||||
+524
-109
@@ -23,15 +23,48 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
db *sql.DB
|
cfgMu sync.RWMutex
|
||||||
log *slog.Logger
|
db *sql.DB
|
||||||
now func() time.Time
|
log *slog.Logger
|
||||||
policy *HTMLPolicy
|
now func() time.Time
|
||||||
workerCancel context.CancelFunc
|
policy *HTMLPolicy
|
||||||
workerWG sync.WaitGroup
|
workerCancel context.CancelFunc
|
||||||
maildirHealth *maildirSyncHealthTracker
|
workerWG sync.WaitGroup
|
||||||
externalIMAP externalIMAPClientFactory
|
maildirHealth *maildirSyncHealthTracker
|
||||||
|
externalIMAP externalIMAPClientFactory
|
||||||
|
turnstileURL string
|
||||||
|
telegramURL string
|
||||||
|
telegramPairMu sync.Mutex
|
||||||
|
telegramPairs map[string]telegramPairing
|
||||||
|
telegramDeliveryMu sync.Mutex
|
||||||
|
backupMu sync.Mutex
|
||||||
|
backupJob *backupJob
|
||||||
|
backupTransfers map[string]*backupTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultUserStorageQuotaMB = 100
|
||||||
|
defaultAdminStorageQuotaMB = 1024
|
||||||
|
minimumStorageQuotaMB = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) config() Config {
|
||||||
|
a.cfgMu.RLock()
|
||||||
|
defer a.cfgMu.RUnlock()
|
||||||
|
return a.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setConfig(cfg Config) {
|
||||||
|
a.cfgMu.Lock()
|
||||||
|
a.cfg = cfg
|
||||||
|
a.cfgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateConfig(update func(*Config)) {
|
||||||
|
a.cfgMu.Lock()
|
||||||
|
defer a.cfgMu.Unlock()
|
||||||
|
update(&a.cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||||
@@ -51,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
|
||||||
a.externalIMAP = a
|
a.externalIMAP = a
|
||||||
if err := a.configureSQLite(context.Background()); err != nil {
|
if err := a.configureSQLite(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
@@ -73,16 +106,34 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
workerCtx, cancel := context.WithCancel(context.Background())
|
workerCtx, cancel := context.WithCancel(context.Background())
|
||||||
a.workerCancel = cancel
|
a.workerCancel = cancel
|
||||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
if strings.TrimSpace(a.config().MaildirRoot) != "" {
|
||||||
a.startWorker(func() { a.maildirWorker(workerCtx) })
|
a.startWorker(func() { a.maildirWorker(workerCtx) })
|
||||||
}
|
}
|
||||||
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||||
|
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||||
|
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +182,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
mailbox_limit_override INTEGER,
|
mailbox_limit_override INTEGER,
|
||||||
|
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
@@ -166,6 +218,14 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, code_hash)
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS api_tokens (
|
`CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -272,6 +332,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL DEFAULT 'folder',
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -401,6 +462,20 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS telegram_mail_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
message_id TEXT NOT NULL UNIQUE,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TEXT NOT NULL,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
delivered_at TEXT,
|
||||||
|
lease_until TEXT NOT NULL DEFAULT '',
|
||||||
|
telegram_message_id INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||||
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
||||||
AFTER DELETE ON mailboxes BEGIN
|
AFTER DELETE ON mailboxes BEGIN
|
||||||
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
||||||
@@ -613,6 +688,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateUserStorageQuota(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -631,6 +709,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -643,12 +724,117 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateTelegramNotifications(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.migrateDefaultMailLabels(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateTelegramNotifications(ctx context.Context) error {
|
||||||
|
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "lease_until", `ALTER TABLE telegram_mail_outbox ADD COLUMN lease_until TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "telegram_message_id", `ALTER TABLE telegram_mail_outbox ADD COLUMN telegram_message_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateDefaultMailLabels(ctx context.Context) error {
|
||||||
|
const marker = "defaultMailLabelsInitialized"
|
||||||
|
var initialized int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if initialized > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var mailboxIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var mailboxID string
|
||||||
|
if err := rows.Scan(&mailboxID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailboxIDs = append(mailboxIDs, mailboxID)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for _, mailboxID := range mailboxIDs {
|
||||||
|
if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
var mailboxSettingExists int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramMailboxIds'`).Scan(&mailboxSettingExists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mailboxSettingExists == 0 {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM mailboxes m JOIN users u ON u.id=m.user_id WHERE u.role='admin' AND m.status='active' ORDER BY m.address`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var mailboxIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailboxIDs = append(mailboxIDs, id)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramMailboxIds',?,?)`, strings.Join(mailboxIDs, ","), now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var includeSettingExists int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramIncludeUnregistered'`).Scan(&includeSettingExists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if includeSettingExists == 0 {
|
||||||
|
var enabled string
|
||||||
|
_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='telegramMailEnabled'`).Scan(&enabled)
|
||||||
|
includeUnregistered := "false"
|
||||||
|
if strings.EqualFold(enabled, "true") {
|
||||||
|
includeUnregistered = "true"
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramIncludeUnregistered',?,?)`, includeUnregistered, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) migrateForwardingVerification(ctx context.Context) error {
|
func (a *App) migrateForwardingVerification(ctx context.Context) error {
|
||||||
columns := []struct {
|
columns := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -920,81 +1106,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
|
||||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
// Administrators now use a real mailbox as their primary login address, so old
|
||||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
// bootstrap mailboxes must be preserved and normalized by the admin identity
|
||||||
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
|
// migration instead of deleted.
|
||||||
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
|
||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rows, err := a.db.QueryContext(ctx, `
|
|
||||||
SELECT mb.id, mb.domain_id
|
|
||||||
FROM mailboxes mb
|
|
||||||
JOIN users u ON u.id=mb.user_id
|
|
||||||
WHERE mb.address=?
|
|
||||||
AND mb.display_name='LanQin Admin'
|
|
||||||
AND u.email=?
|
|
||||||
AND u.role='admin'`, adminEmail, adminEmail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
type legacyMailbox struct {
|
|
||||||
id string
|
|
||||||
domainID string
|
|
||||||
}
|
|
||||||
items := []legacyMailbox{}
|
|
||||||
for rows.Next() {
|
|
||||||
var item legacyMailbox
|
|
||||||
if err := rows.Scan(&item.id, &item.domainID); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, item := range items {
|
|
||||||
messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
messageIDs := []string{}
|
|
||||||
for messageRows.Next() {
|
|
||||||
var messageID string
|
|
||||||
if err := messageRows.Scan(&messageID); err != nil {
|
|
||||||
messageRows.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
messageIDs = append(messageIDs, messageID)
|
|
||||||
}
|
|
||||||
if err := messageRows.Err(); err != nil {
|
|
||||||
messageRows.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := messageRows.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, messageID := range messageIDs {
|
|
||||||
a.deleteMessage(ctx, messageID)
|
|
||||||
}
|
|
||||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := a.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM domains
|
|
||||||
WHERE id=?
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1353,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasColumn := false
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notnull int
|
||||||
|
var dflt any
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name == "storage_quota_mb" {
|
||||||
|
hasColumn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasColumn {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1387,15 +1536,20 @@ func messageIndexes() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) seed(ctx context.Context) error {
|
func (a *App) seed(ctx context.Context) error {
|
||||||
|
cfg := a.config()
|
||||||
var count int
|
var count int
|
||||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||||
|
}
|
||||||
|
adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation")
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPassword := a.cfg.AdminPassword
|
adminPassword := cfg.AdminPassword
|
||||||
if adminPassword == "" {
|
if adminPassword == "" {
|
||||||
buf := make([]byte, 16)
|
buf := make([]byte, 16)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
@@ -1410,13 +1564,8 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
userID := newID("usr")
|
userID := newID("usr")
|
||||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
|
||||||
return errors.New("invalid admin email")
|
|
||||||
}
|
|
||||||
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
|
|
||||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||||
@@ -1437,7 +1586,7 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create mailbox for admin
|
// Create mailbox for admin
|
||||||
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
|
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1451,12 +1600,221 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
}
|
||||||
return nil
|
|
||||||
|
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
|
||||||
|
cfg := a.config()
|
||||||
|
type adminUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LoginName string `json:"loginName,omitempty"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`)
|
||||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
admins := []adminUser{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item adminUser
|
||||||
|
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
admins = append(admins, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(admins) == 0 {
|
||||||
|
if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" {
|
||||||
|
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail)
|
||||||
|
var item adminUser
|
||||||
|
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||||
|
admins = append(admins, item)
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||||
|
adminUsername := normalizeLoginName(cfg.AdminUsername)
|
||||||
|
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername)
|
||||||
|
var item adminUser
|
||||||
|
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||||
|
admins = append(admins, item)
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(admins) == 0 {
|
||||||
|
return errors.New("no administrator user found for identity migration")
|
||||||
|
}
|
||||||
|
keeper := admins[0]
|
||||||
|
adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var conflictID string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil {
|
||||||
|
return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID)
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
demoted := []adminUser{}
|
||||||
|
for _, admin := range admins[1:] {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
admin.PasswordHash = ""
|
||||||
|
demoted = append(demoted, admin)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`,
|
||||||
|
adminEmail, adminEmail, now, keeper.ID); err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
|
return fmt.Errorf("admin identity migration conflict: %w", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(adminEmail, "@", 2)
|
||||||
|
localPart := parts[0]
|
||||||
|
domainName := normalizeDomain(parts[1])
|
||||||
|
var domainID string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
domainID, err = a.createDomainTx(ctx, tx, domainName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mailboxCreated := false
|
||||||
|
var mailboxID, mailboxUserID string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil {
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailboxCreated = true
|
||||||
|
} else if mailboxUserID != keeper.ID {
|
||||||
|
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
|
||||||
|
}
|
||||||
|
result := map[string]any{
|
||||||
|
"adminUserId": keeper.ID,
|
||||||
|
"adminEmail": adminEmail,
|
||||||
|
"emailSource": emailSource,
|
||||||
|
"previousEmail": keeper.Email,
|
||||||
|
"demotedAdmins": demoted,
|
||||||
|
"mailboxId": mailboxID,
|
||||||
|
"mailboxCreated": mailboxCreated,
|
||||||
|
"migratedAt": now,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(result)
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.updateConfig(func(current *Config) {
|
||||||
|
current.AdminEmail = adminEmail
|
||||||
|
if current.MailDomain == "" {
|
||||||
|
current.MailDomain = domainName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
|
||||||
|
// Once initialized, the database identity is authoritative. This keeps an
|
||||||
|
// administrator email changed in the UI from reverting to the installer value.
|
||||||
|
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
|
||||||
|
return email, "existing_admin_email", nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.AdminEmail) != "" {
|
||||||
|
email, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
|
||||||
|
}
|
||||||
|
return email, "configured_admin_email", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
|
||||||
|
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||||
|
preferredLocalPart = normalizeLocalPart(loginName)
|
||||||
|
}
|
||||||
|
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||||
|
preferredLocalPart = "admin"
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var address string
|
||||||
|
if err := rows.Scan(&address); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if email, err := cleanPrimaryEmail(address); err == nil {
|
||||||
|
rows.Close()
|
||||||
|
return email, "existing_admin_mailbox", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
|
||||||
|
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
|
||||||
|
}
|
||||||
|
var onlyDomain string
|
||||||
|
var domainCount int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if domainCount == 1 && validMailDomain(onlyDomain) {
|
||||||
|
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
|
||||||
|
}
|
||||||
|
publicDomain := normalizeDomain(cfg.PublicHostname)
|
||||||
|
if strings.HasPrefix(publicDomain, "mail.") {
|
||||||
|
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
|
||||||
|
}
|
||||||
|
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
|
||||||
|
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
|
||||||
|
}
|
||||||
|
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMailDomain(domain string) bool {
|
||||||
|
domain = normalizeDomain(domain)
|
||||||
|
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
|
||||||
|
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,6 +1869,30 @@ func defaultFolderDefs() []struct{ name, role string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type defaultMailLabel struct {
|
||||||
|
name string
|
||||||
|
color string
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMailLabelDefs() []defaultMailLabel {
|
||||||
|
return []defaultMailLabel{
|
||||||
|
{name: "个人", color: "#10b981"},
|
||||||
|
{name: "家人", color: "#ec4899"},
|
||||||
|
{name: "朋友", color: "#06b6d4"},
|
||||||
|
{name: "工作", color: "#3b82f6"},
|
||||||
|
{name: "重要", color: "#f59e0b"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
|
||||||
|
for _, label := range defaultMailLabelDefs() {
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
|
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1540,8 +1922,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
if localPart == "" {
|
if localPart == "" {
|
||||||
return "", errors.New("invalid local part")
|
return "", errors.New("invalid local part")
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB < 0 {
|
||||||
quotaMB = 1024
|
return "", errors.New("quotaMb must be zero or greater")
|
||||||
|
}
|
||||||
|
var ownerRole string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
}
|
}
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = "active"
|
status = "active"
|
||||||
@@ -1568,24 +1957,50 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
|
||||||
|
SET quota_mb=0, updated_at=?
|
||||||
|
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||||
|
cfg := a.config()
|
||||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := a.now().UTC()
|
now := a.now().UTC()
|
||||||
|
systemDomain := normalizeDomain(cfg.MailDomain)
|
||||||
|
if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") {
|
||||||
|
systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1])
|
||||||
|
}
|
||||||
|
if systemDomain == "" {
|
||||||
|
systemDomain = normalizeDomain(cfg.PublicHostname)
|
||||||
|
}
|
||||||
|
if systemDomain == "" {
|
||||||
|
systemDomain = "lanqin.local"
|
||||||
|
}
|
||||||
|
systemAddress := "system@" + systemDomain
|
||||||
subject := "欢迎使用 NewSzxcn 邮箱"
|
subject := "欢迎使用 NewSzxcn 邮箱"
|
||||||
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
||||||
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
|
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
|
||||||
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
|
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
|
||||||
rendered := renderMailTemplate(tpl, templateRenderData{
|
rendered := renderMailTemplate(tpl, templateRenderData{
|
||||||
To: a.cfg.AdminEmail,
|
To: cfg.AdminEmail,
|
||||||
From: "system@lanqin.local",
|
From: systemAddress,
|
||||||
PublicHostname: a.cfg.PublicHostname,
|
PublicHostname: cfg.PublicHostname,
|
||||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
Time: now,
|
Time: now,
|
||||||
})
|
})
|
||||||
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
|
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
|
||||||
@@ -1594,11 +2009,11 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
|||||||
MailboxID: mailboxID,
|
MailboxID: mailboxID,
|
||||||
FolderID: folderID,
|
FolderID: folderID,
|
||||||
MessageUID: newID("uid"),
|
MessageUID: newID("uid"),
|
||||||
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
|
MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
From: "system@lanqin.local",
|
From: systemAddress,
|
||||||
FromName: "NewSzxcn 邮箱",
|
FromName: "NewSzxcn 邮箱",
|
||||||
To: []string{a.cfg.AdminEmail},
|
To: []string{cfg.AdminEmail},
|
||||||
SentAt: now,
|
SentAt: now,
|
||||||
ReceivedAt: now,
|
ReceivedAt: now,
|
||||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||||
|
|||||||
+1329
-112
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
||||||
respondError(w, http.StatusUnauthorized, "验证码错误")
|
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
|
||||||
return
|
if consumeErr != nil || !ok {
|
||||||
|
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||||
@@ -50,12 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
emailInput := req.Email
|
||||||
|
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||||
|
emailInput = req.LoginName
|
||||||
|
}
|
||||||
|
email, err := cleanPrimaryEmail(emailInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
|
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||||
if err != nil || user.Disabled {
|
if err != nil || user.Disabled {
|
||||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||||
return
|
return
|
||||||
@@ -64,7 +72,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
|
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
|
||||||
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
|
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
|
||||||
@@ -81,7 +89,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.OpenRegistration {
|
if !a.config().OpenRegistration {
|
||||||
respondError(w, http.StatusForbidden, "当前未开放注册")
|
respondError(w, http.StatusForbidden, "当前未开放注册")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,23 +109,54 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
email := normalizeEmail(req.Email)
|
email, err := cleanPrimaryEmail(req.Email)
|
||||||
if email == "" || !strings.Contains(email, "@") {
|
if err != nil {
|
||||||
badRequest(w, errors.New("邮箱地址无效"))
|
badRequest(w, errors.New("邮箱地址无效"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
displayName := strings.TrimSpace(req.DisplayName)
|
displayName := strings.TrimSpace(req.DisplayName)
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = strings.Split(email, "@")[0]
|
badRequest(w, errors.New("请输入显示名称"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len([]rune(displayName)) > 80 {
|
if len([]rune(displayName)) > 80 {
|
||||||
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
|
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
|
||||||
return
|
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 {
|
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
return
|
return
|
||||||
@@ -132,8 +171,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
userID := newID("usr")
|
userID := newID("usr")
|
||||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != 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") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
return
|
return
|
||||||
@@ -141,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 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)
|
user, err := a.userByID(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||||
@@ -150,46 +207,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
|
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||||
return
|
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})
|
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
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))
|
_, _ = 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})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
|
if user == nil || user.Role != "admin" {
|
||||||
|
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
}
|
}
|
||||||
@@ -239,8 +268,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < 8 {
|
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
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,461 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
||||||
|
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
|
||||||
|
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||||
|
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
|
||||||
|
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||||
|
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
|
||||||
|
}))
|
||||||
|
defer telegramServer.Close()
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
a.telegramURL = telegramServer.URL
|
||||||
|
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
|
||||||
|
t.Fatalf("unexpected groups: %+v", groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||||
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if size != int64(len("encrypted backup")) {
|
||||||
|
t.Fatalf("upload size = %d", size)
|
||||||
|
}
|
||||||
|
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||||
|
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||||
|
}
|
||||||
|
var metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Parents []string `json:"parents"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||||
|
t.Fatalf("metadata = %+v", metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||||
|
var updates []int64
|
||||||
|
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||||
|
updates = append(updates, uploaded)
|
||||||
|
}}
|
||||||
|
raw, err := io.ReadAll(reader)
|
||||||
|
if err != nil || string(raw) != "encrypted backup" {
|
||||||
|
t.Fatalf("read = %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||||
|
t.Fatalf("progress updates = %v", updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||||
|
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||||
|
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||||
|
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||||
|
if !strings.Contains(message, test.want) {
|
||||||
|
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||||
|
size := int64(googleDriveUploadChunkSize + 3)
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Truncate(size); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
var ranges []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
if len(ranges) == 1 {
|
||||||
|
w.WriteHeader(http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.startBackupTransfer("googleDrive", path) {
|
||||||
|
t.Fatal("failed to start transfer")
|
||||||
|
}
|
||||||
|
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantRanges := []string{
|
||||||
|
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||||
|
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||||
|
}
|
||||||
|
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||||
|
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||||
|
}
|
||||||
|
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||||
|
if transfer == nil || transfer.Uploaded != size {
|
||||||
|
t.Fatalf("transfer = %+v", transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
|
||||||
|
})
|
||||||
|
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
|
||||||
|
t.Fatal("backup password encryption succeeded without a deployment secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordValidation(t *testing.T) {
|
||||||
|
for _, valid := range []string{"12345678", "Restore Password 123!"} {
|
||||||
|
if !validBackupPassword(valid) {
|
||||||
|
t.Errorf("valid password rejected: %q", valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
|
||||||
|
if validBackupPassword(invalid) {
|
||||||
|
t.Errorf("invalid password accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordHint(t *testing.T) {
|
||||||
|
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||||
|
t.Fatalf("password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint("ab"); got != "ab" {
|
||||||
|
t.Fatalf("two-character password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint(""); got != "" {
|
||||||
|
t.Fatalf("empty password hint = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "A23456789Z" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
schedule, err := a.loadBackupSchedule(context.Background())
|
||||||
|
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||||
|
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("password update code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
if response["passwordHint"] != "N••••••••••9" {
|
||||||
|
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "NewSharedPassword9" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
var got string
|
||||||
|
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||||
|
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
deployDir := filepath.Join(dir, "deploy")
|
||||||
|
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||||
|
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||||
|
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "SharedBackupPassword9" {
|
||||||
|
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
status := a.backupJob.Status
|
||||||
|
a.backupMu.Unlock()
|
||||||
|
if status != "running" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("manual backup did not finish before timeout")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicServerIPValidation(t *testing.T) {
|
||||||
|
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||||
|
if !isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("public IP rejected: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
|
||||||
|
if isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("non-public IP accepted: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("literal public IP = %q", got)
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
|
||||||
|
t.Fatalf("literal private IP = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRuntimeBackupEnv(t *testing.T) {
|
||||||
|
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
|
||||||
|
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
|
||||||
|
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
|
||||||
|
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
|
||||||
|
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeRuntimeBackupEnv(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents := string(raw)
|
||||||
|
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
|
||||||
|
if !strings.Contains(contents, expected) {
|
||||||
|
t.Errorf("backup environment missing %q: %s", expected, contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
|
||||||
|
if strings.Contains(contents, excluded) {
|
||||||
|
t.Fatalf("backup environment included excluded value %q", excluded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("bundled compose did not enable complete backups")
|
||||||
|
}
|
||||||
|
if err := os.Remove(compose); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("missing bundled compose incorrectly enabled complete backups")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
|
||||||
|
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
|
||||||
|
if err != nil || ciphertext == "BackupPassword123!" {
|
||||||
|
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
|
||||||
|
}
|
||||||
|
plain, err := a.decryptBackupPassword(ciphertext)
|
||||||
|
if err != nil || plain != "BackupPassword123!" {
|
||||||
|
t.Fatalf("password decryption = %q, %v", plain, err)
|
||||||
|
}
|
||||||
|
if !validTelegramPrivateChatID("-1001234567890") {
|
||||||
|
t.Fatal("private Telegram group chat ID was rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
|
||||||
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := a.backupTelegramReport(context.Background(), path, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
|
||||||
|
if !strings.Contains(report, expected) {
|
||||||
|
t.Errorf("report missing %q: %s", expected, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "newszxcn.com(管理员)") {
|
||||||
|
t.Fatal("domain list incorrectly contains account role")
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
|
||||||
|
t.Fatal("report leaked a password")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ type Config struct {
|
|||||||
DataDir string
|
DataDir string
|
||||||
CookieName string
|
CookieName string
|
||||||
SessionTTLHours int
|
SessionTTLHours int
|
||||||
|
AdminUsername string
|
||||||
AdminEmail string
|
AdminEmail string
|
||||||
|
MailDomain string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
PublicHostname string
|
PublicHostname string
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
@@ -50,6 +52,12 @@ type Config struct {
|
|||||||
ExternalIMAPGmailClientSecret string
|
ExternalIMAPGmailClientSecret string
|
||||||
ExternalIMAPOutlookClientID string
|
ExternalIMAPOutlookClientID string
|
||||||
ExternalIMAPOutlookClientSecret string
|
ExternalIMAPOutlookClientSecret string
|
||||||
|
TelegramMailEnabled bool
|
||||||
|
TelegramBotToken string
|
||||||
|
TelegramPrivateChatID string
|
||||||
|
TelegramBodyMode string
|
||||||
|
TelegramMailboxIDs string
|
||||||
|
TelegramIncludeUnregistered bool
|
||||||
MailTranslateEnabled bool
|
MailTranslateEnabled bool
|
||||||
MailTranslateMaxChars int
|
MailTranslateMaxChars int
|
||||||
DeliveryWebhookSecret string
|
DeliveryWebhookSecret string
|
||||||
@@ -59,6 +67,8 @@ type Config struct {
|
|||||||
ReleaseAPIURL string
|
ReleaseAPIURL string
|
||||||
UpdateServiceURL string
|
UpdateServiceURL string
|
||||||
UpdateServiceToken string
|
UpdateServiceToken string
|
||||||
|
BackupSourceDir string
|
||||||
|
BackupDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
@@ -70,7 +80,9 @@ func LoadConfig() Config {
|
|||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||||
|
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
|
||||||
|
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
|
||||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||||
@@ -106,6 +118,12 @@ func LoadConfig() Config {
|
|||||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
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),
|
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
|
||||||
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
|
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
|
||||||
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
|
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
|
||||||
@@ -115,6 +133,8 @@ func LoadConfig() Config {
|
|||||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||||
|
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
|
||||||
|
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
|
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
|
||||||
name := strings.TrimSuffix(d.Name, ".")
|
name := strings.TrimSuffix(d.Name, ".")
|
||||||
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
|
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
|
||||||
return []DNSRecord{
|
return []DNSRecord{
|
||||||
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
|
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
|
||||||
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", 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 {
|
for _, item := range mx {
|
||||||
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
|
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
|
||||||
found = append(found, entry)
|
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
|
ok = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
|||||||
|
|
||||||
dkimName := d.DKIMSelector + "._domainkey." + d.Name
|
dkimName := d.DKIMSelector + "._domainkey." + d.Name
|
||||||
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
|
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)
|
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
|
||||||
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
|
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}
|
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 {
|
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
|
||||||
found := append([]string{}, records...)
|
found := append([]string{}, records...)
|
||||||
for _, item := range records {
|
for _, item := range records {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) externalIMAPWorker(ctx context.Context) {
|
func (a *App) externalIMAPWorker(ctx context.Context) {
|
||||||
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
|
interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = 5 * time.Minute
|
interval = 5 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
|
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
|
||||||
if !a.cfg.ExternalIMAPEnabled {
|
if !a.config().ExternalIMAPEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
|
||||||
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
|
|||||||
|
|
||||||
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
|
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.ExternalIMAPEnabled {
|
if !a.config().ExternalIMAPEnabled {
|
||||||
respondError(w, http.StatusForbidden, "external imap is disabled")
|
respondError(w, http.StatusForbidden, "external imap is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
|
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
|
http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
|
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
|
||||||
if a.cfg.ExternalIMAPAllowPrivateHosts {
|
if a.config().ExternalIMAPAllowPrivateHosts {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.EqualFold(host, "localhost") {
|
if strings.EqualFold(host, "localhost") {
|
||||||
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) externalIMAPKey() ([]byte, error) {
|
func (a *App) externalIMAPKey() ([]byte, error) {
|
||||||
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
|
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
|
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
|
||||||
}
|
}
|
||||||
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
|
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
|
||||||
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
|
callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
|
||||||
switch provider {
|
switch provider {
|
||||||
case externalIMAPOAuthGmail:
|
case externalIMAPOAuthGmail:
|
||||||
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
|
if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
|
||||||
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
|
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
|
||||||
}
|
}
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: a.cfg.ExternalIMAPGmailClientID,
|
ClientID: a.config().ExternalIMAPGmailClientID,
|
||||||
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
|
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
|
||||||
RedirectURL: callback,
|
RedirectURL: callback,
|
||||||
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
|
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
|
|||||||
},
|
},
|
||||||
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
|
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
|
||||||
case externalIMAPOAuthOutlook:
|
case externalIMAPOAuthOutlook:
|
||||||
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
|
if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
|
||||||
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
|
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
|
||||||
}
|
}
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: a.cfg.ExternalIMAPOutlookClientID,
|
ClientID: a.config().ExternalIMAPOutlookClientID,
|
||||||
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
|
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
|
||||||
RedirectURL: callback,
|
RedirectURL: callback,
|
||||||
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
|
|||||||
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
|
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
|
||||||
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
|
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
|
||||||
}
|
}
|
||||||
|
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
|
||||||
|
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
|
||||||
|
}
|
||||||
imported++
|
imported++
|
||||||
} else {
|
} else {
|
||||||
skipped++
|
skipped++
|
||||||
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
|
|||||||
}
|
}
|
||||||
|
|
||||||
type externalIMAPFolderState struct {
|
type externalIMAPFolderState struct {
|
||||||
LastUID uint32
|
LastUID uint32
|
||||||
|
Initialized bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
|
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
|
||||||
var state externalIMAPFolderState
|
var state externalIMAPFolderState
|
||||||
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
|
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
|
||||||
|
state.Initialized = true
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1376,15 +1382,6 @@ func safeExternalEMLFilename(subject string) string {
|
|||||||
return name + ".eml"
|
return name + ".eml"
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
|
|
||||||
parts := externalIMAPAttachmentPartsFromBodyStructure(body)
|
|
||||||
items := make([]Attachment, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
items = append(items, part.Attachment)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
|
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
items := []externalIMAPAttachmentPart{}
|
items := []externalIMAPAttachmentPart{}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
|
|||||||
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||||
var rfcMessageID string
|
var rfcMessageID string
|
||||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||||
if strings.TrimSpace(rfcMessageID) == "" {
|
if strings.TrimSpace(rfcMessageID) == "" {
|
||||||
@@ -101,7 +101,7 @@ func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID st
|
|||||||
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||||
var rfcMessageID string
|
var rfcMessageID string
|
||||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||||
if strings.TrimSpace(rfcMessageID) == "" {
|
if strings.TrimSpace(rfcMessageID) == "" {
|
||||||
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
|
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||||||
if len(targets) == 0 {
|
|
||||||
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
|
||||||
}
|
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
return nil, userID, mailboxAddress, nil
|
return nil, userID, mailboxAddress, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
|
|||||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
|
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -389,7 +389,7 @@ func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
|
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
|
||||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||||
return "", errors.New("SMTP 未配置,无法发送验证邮件")
|
return "", errors.New("SMTP 未配置,无法发送验证邮件")
|
||||||
}
|
}
|
||||||
mb, err := a.primaryMailboxForUser(ctx, userID)
|
mb, err := a.primaryMailboxForUser(ctx, userID)
|
||||||
@@ -428,9 +428,9 @@ func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) forwardingVerificationURL(token string) string {
|
func (a *App) forwardingVerificationURL(token string) string {
|
||||||
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
|
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
|
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
|
||||||
}
|
}
|
||||||
return base + "/api/verify-email?token=" + url.QueryEscape(token)
|
return base + "/api/verify-email?token=" + url.QueryEscape(token)
|
||||||
}
|
}
|
||||||
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
|
|||||||
title := "邮箱转发验证"
|
title := "邮箱转发验证"
|
||||||
heading := "验证失败"
|
heading := "验证失败"
|
||||||
color := "#dc2626"
|
color := "#dc2626"
|
||||||
|
statusMark := "!"
|
||||||
|
closingMessage := "请联系验证发起人重新发送链接"
|
||||||
if ok {
|
if ok {
|
||||||
heading = "验证完成"
|
heading = "验证完成"
|
||||||
color = "#2563eb"
|
color = "#16a34a"
|
||||||
|
statusMark = "✓"
|
||||||
|
closingMessage = "验证结果已记录,可以关闭此页面"
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
|
_, _ = 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, heading, htmlEscape(message), htmlEscape(email), color)
|
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) {
|
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,25 +236,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
|
|||||||
return next, nil
|
return next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
|
|
||||||
var folderID sql.NullString
|
|
||||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !folderID.Valid || folderID.String == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if modSeq == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
|
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
|
||||||
if folderID == "" {
|
if folderID == "" {
|
||||||
var dbFolderID sql.NullString
|
var dbFolderID sql.NullString
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
ORDER BY CASE
|
ORDER BY CASE
|
||||||
WHEN lower(f.name)='inbox' THEN 1000
|
WHEN lower(f.name)='inbox' THEN 1000
|
||||||
WHEN lower(f.name)='sent' THEN 5000
|
WHEN lower(f.name)='sent' THEN 5000
|
||||||
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||||
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -264,13 +267,9 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
@@ -285,7 +284,49 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("system folder already exists"))
|
badRequest(w, errors.New("system folder already exists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
icon := folderIconForName(name, req.Icon)
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
user := currentUser(r)
|
||||||
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mailboxIDs := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var mailboxID string
|
||||||
|
if err := rows.Scan(&mailboxID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mailboxIDs = append(mailboxIDs, mailboxID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(mailboxIDs) == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, mailboxID := range mailboxIDs {
|
||||||
|
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
@@ -299,16 +340,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
|
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||||
if folderID == "" {
|
if folderID == "" {
|
||||||
badRequest(w, errors.New("folder id is required"))
|
badRequest(w, errors.New("folder id is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
a.handleDeleteAllMailFolders(w, r, folderID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
var folderName string
|
var folderName string
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
|
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -378,8 +423,197 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
|
||||||
return a.ensureFolder(ctx, mailboxID, name)
|
folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
|
||||||
|
if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
|
||||||
|
folderName = strings.TrimSpace(folderID[4:])
|
||||||
|
}
|
||||||
|
name, err := normalizeCustomFolderName(folderName)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSystemFolderName(name) {
|
||||||
|
badRequest(w, errors.New("system folders cannot be deleted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := currentUser(r)
|
||||||
|
type folderTarget struct {
|
||||||
|
folderID string
|
||||||
|
mailboxID string
|
||||||
|
inboxID string
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targets := []folderTarget{}
|
||||||
|
for rows.Next() {
|
||||||
|
var target folderTarget
|
||||||
|
if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(targets) == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "folder not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range targets {
|
||||||
|
targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load inbox")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
moved := 0
|
||||||
|
for _, target := range targets {
|
||||||
|
messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageIDs := []string{}
|
||||||
|
for messageRows.Next() {
|
||||||
|
var messageID string
|
||||||
|
if err := messageRows.Scan(&messageID); err != nil {
|
||||||
|
messageRows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageIDs = append(messageIDs, messageID)
|
||||||
|
}
|
||||||
|
if err := messageRows.Err(); err != nil {
|
||||||
|
messageRows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageRows.Close()
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, target := range targets {
|
||||||
|
_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
|
||||||
|
var existingID string
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
|
||||||
|
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
|
||||||
|
return existingID, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id, err := a.ensureFolder(ctx, mailboxID, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderIconForName(name, requested string) string {
|
||||||
|
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
|
||||||
|
return normalizeFolderIcon(icon)
|
||||||
|
}
|
||||||
|
value := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
for _, match := range []struct {
|
||||||
|
icon string
|
||||||
|
terms []string
|
||||||
|
}{
|
||||||
|
{"netflix", []string{"netflix", "奈飞", "网飞"}},
|
||||||
|
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
|
||||||
|
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
|
||||||
|
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
|
||||||
|
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
|
||||||
|
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
|
||||||
|
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
|
||||||
|
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
|
||||||
|
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
|
||||||
|
{"star", []string{"重要", "紧急", "important", "urgent"}},
|
||||||
|
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
|
||||||
|
{"bell", []string{"提醒", "通知", "remind", "notification"}},
|
||||||
|
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
|
||||||
|
} {
|
||||||
|
for _, term := range match.terms {
|
||||||
|
if folderNameContainsTerm(value, term) {
|
||||||
|
return match.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderNameContainsTerm(value, term string) bool {
|
||||||
|
if term != "gpt" {
|
||||||
|
return strings.Contains(value, term)
|
||||||
|
}
|
||||||
|
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == term {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFolderIcon(raw string) string {
|
||||||
|
icon := strings.TrimSpace(raw)
|
||||||
|
const customPrefix = "data:image/png;base64,"
|
||||||
|
if strings.HasPrefix(icon, customPrefix) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
|
||||||
|
config, configErr := png.DecodeConfig(bytes.NewReader(data))
|
||||||
|
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
|
||||||
|
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
icon = strings.ToLower(icon)
|
||||||
|
switch icon {
|
||||||
|
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
|
||||||
|
return icon
|
||||||
|
default:
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
||||||
@@ -397,11 +631,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||||
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||||
|
if !ok {
|
||||||
respondError(w, http.StatusNotFound, "label not found")
|
respondError(w, http.StatusNotFound, "label not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
|
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folder := r.URL.Query().Get("folder")
|
folder := r.URL.Query().Get("folder")
|
||||||
@@ -971,7 +1206,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
|||||||
for _, rcpt := range localRecipients {
|
for _, rcpt := range localRecipients {
|
||||||
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
|
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copyMsg := base
|
copyMsg := base
|
||||||
@@ -982,11 +1217,12 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
|||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||||
|
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rcptMailbox.Status != "active" {
|
if rcptMailbox.Status != "active" {
|
||||||
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||||
copyMsg := base
|
copyMsg := base
|
||||||
copyMsg.MailboxID = ""
|
copyMsg.MailboxID = ""
|
||||||
copyMsg.FolderID = ""
|
copyMsg.FolderID = ""
|
||||||
@@ -995,6 +1231,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
|||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||||
|
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -1006,12 +1243,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
|||||||
copyMsg := base
|
copyMsg := base
|
||||||
copyMsg.MailboxID = rcptMailbox.ID
|
copyMsg.MailboxID = rcptMailbox.ID
|
||||||
copyMsg.FolderID = inboxID
|
copyMsg.FolderID = inboxID
|
||||||
|
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||||
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||||
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
||||||
|
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
|
||||||
|
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2007,15 +2248,83 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folder, err := normalizeFolderNameForUser(req.Folder)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(req.IDs))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
badRequest(w, errors.New("请选择要移动的邮件"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type itemResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MailboxID string `json:"mailboxId,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
results := make([]itemResult, 0, len(ids))
|
||||||
|
folderByMailbox := map[string]string{}
|
||||||
|
moved := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
msg, err := a.loadMessageForRequest(r, id, false)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folderID := folderByMailbox[msg.MailboxID]
|
||||||
|
if folderID == "" {
|
||||||
|
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folderByMailbox[msg.MailboxID] = folderID
|
||||||
|
}
|
||||||
|
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||||
|
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
|
||||||
|
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
|
||||||
|
}
|
||||||
|
failed := len(results) - moved
|
||||||
|
message := fmt.Sprintf("已移动 %d 封邮件", moved)
|
||||||
|
if failed > 0 {
|
||||||
|
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &f, nil
|
return &f, nil
|
||||||
@@ -2311,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var quotaMB int64
|
var quotaMB int64
|
||||||
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
|
var userID string
|
||||||
|
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan("aMB, &userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var used int64
|
var used int64
|
||||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
|
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
quotaBytes := quotaMB * 1024 * 1024
|
quotaBytes := quotaMB * 1024 * 1024
|
||||||
@@ -2345,7 +2655,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
|
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2382,7 +2692,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
|||||||
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
|
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||||
WHERE l.mailbox_id=?
|
WHERE l.mailbox_id=?
|
||||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||||
ORDER BY lower(l.name)`, mailboxID)
|
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2399,13 +2709,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
|
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
|
||||||
FROM mail_labels l
|
FROM mail_labels l
|
||||||
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||||
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||||
WHERE mb.user_id=? AND mb.status='active'
|
WHERE mb.user_id=? AND mb.status='active'
|
||||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
GROUP BY lower(l.name)
|
||||||
ORDER BY lower(l.name)`, userID)
|
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2425,7 +2735,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
|
|||||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
||||||
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
||||||
WHERE ml.message_id=?
|
WHERE ml.message_id=?
|
||||||
ORDER BY lower(l.name)`, messageID)
|
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2456,7 +2766,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
|||||||
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
|
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
|
||||||
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
|
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
|
||||||
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
|
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
|
||||||
ORDER BY lower(l.name)`, args...)
|
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2474,6 +2784,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mailLabelOrderSQL(alias string) string {
|
||||||
|
return mailLabelNameOrderSQL(alias + `.name`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailLabelNameOrderSQL(expression string) string {
|
||||||
|
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
|
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
|
||||||
name = normalizeLabelName(name)
|
name = normalizeLabelName(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -2517,6 +2835,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
|
||||||
|
var name string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLabelName(name string) string {
|
func normalizeLabelName(name string) string {
|
||||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||||
if len([]rune(name)) > 32 {
|
if len([]rune(name)) > 32 {
|
||||||
@@ -2550,7 +2876,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
|||||||
_ = os.Remove(p)
|
_ = os.Remove(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
|
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) deleteMessage(ctx context.Context, messageID string) {
|
func (a *App) deleteMessage(ctx context.Context, messageID string) {
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxMailImportBytes int64 = 256 << 20
|
const maxMailImportBytes int64 = 256 << 20
|
||||||
|
|
||||||
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
const maxSelectedMailExport = 200
|
||||||
|
|
||||||
|
type exportedMessage struct {
|
||||||
|
raw []byte
|
||||||
|
subject string
|
||||||
|
date time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||||
ids, err := a.exportMessageIDs(r)
|
ids, err := a.exportMessageIDs(r)
|
||||||
@@ -43,19 +50,21 @@ func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
zw := zip.NewWriter(w)
|
zw := zip.NewWriter(w)
|
||||||
usedNames := make(map[string]int, len(ids))
|
usedNames := make(map[string]int, len(ids))
|
||||||
for index, id := range ids {
|
for _, id := range ids {
|
||||||
raw, subject, err := a.rawMessageForExport(r.Context(), id)
|
message, err := a.rawMessageForExport(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = zw.Close()
|
_ = zw.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
|
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
|
||||||
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
|
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
|
||||||
|
header.SetModTime(message.date)
|
||||||
|
entry, err := zw.CreateHeader(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = zw.Close()
|
_ = zw.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := entry.Write(raw); err != nil {
|
if _, err := entry.Write(message.raw); err != nil {
|
||||||
_ = zw.Close()
|
_ = zw.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,10 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
|||||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||||
where := []string{}
|
where := []string{}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
selectedIDs, err := selectedExportMessageIDs(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if view == "unknown" {
|
if view == "unknown" {
|
||||||
if user.Role != "admin" {
|
if user.Role != "admin" {
|
||||||
@@ -109,12 +122,29 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
|||||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||||
return nil, sql.ErrNoRows
|
return nil, sql.ErrNoRows
|
||||||
}
|
}
|
||||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
if isAllMailboxID(mailboxID) {
|
||||||
args = append(args, labelID)
|
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:
|
default:
|
||||||
return nil, errors.New("unsupported mail view")
|
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...)
|
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 {
|
if err != nil {
|
||||||
@@ -132,40 +162,77 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
|||||||
return ids, rows.Err()
|
return ids, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
|
func selectedExportMessageIDs(r *http.Request) ([]string, error) {
|
||||||
|
values := r.URL.Query()["messageId"]
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
ids := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
id := strings.TrimSpace(value)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
if len(ids) > maxSelectedMailExport {
|
||||||
|
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
|
||||||
msg, err := a.storedMessageByID(ctx, id)
|
msg, err := a.storedMessageByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return exportedMessage{}, err
|
||||||
|
}
|
||||||
|
exportDate := msg.ReceivedAt
|
||||||
|
if exportDate.IsZero() {
|
||||||
|
exportDate = messageDate(msg)
|
||||||
}
|
}
|
||||||
if msg.RawPath != "" {
|
if msg.RawPath != "" {
|
||||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||||
return raw, msg.Subject, nil
|
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return exportedMessage{}, err
|
||||||
}
|
}
|
||||||
raw, err := BuildMIME(MIMEMessage{
|
raw, err := BuildMIME(MIMEMessage{
|
||||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
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,
|
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||||
Date: messageDate(msg), Attachments: attachments,
|
Date: messageDate(msg), Attachments: attachments,
|
||||||
})
|
})
|
||||||
return raw, msg.Subject, err
|
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportMessageFilename(subject, id string, index int) string {
|
func exportMessageFilename(subject string, date time.Time) string {
|
||||||
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
|
name := strings.Map(func(r rune) rune {
|
||||||
name = strings.Trim(name, ".-_")
|
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, strings.TrimSpace(subject))
|
||||||
|
name = strings.Trim(name, " .-_")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "message"
|
name = "无主题"
|
||||||
}
|
}
|
||||||
if len(name) > 80 {
|
runes := []rune(name)
|
||||||
name = name[:80]
|
if len(runes) > 80 {
|
||||||
|
name = string(runes[:80])
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
|
if date.IsZero() {
|
||||||
|
return name + ".eml"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func uniqueExportFilename(name string, used map[string]int) string {
|
func uniqueExportFilename(name string, used map[string]int) string {
|
||||||
@@ -216,7 +283,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
imported, skipped := 0, 0
|
imported, skipped := 0, 0
|
||||||
problems := []string{}
|
problems := []string{}
|
||||||
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||||
if maxMessageBytes <= 0 {
|
if maxMessageBytes <= 0 {
|
||||||
maxMessageBytes = 35 * 1024 * 1024
|
maxMessageBytes = 35 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
stdmail "net/mail"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseMBOXMultipleMessages(t *testing.T) {
|
func TestParseMBOXMultipleMessages(t *testing.T) {
|
||||||
@@ -65,22 +69,27 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
|||||||
t.Fatalf("owner login=%d", code)
|
t.Fatalf("owner login=%d", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||||
|
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
|
||||||
var imported struct {
|
var imported struct {
|
||||||
Imported int `json:"imported"`
|
Imported int `json:"imported"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
|
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
|
||||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
var list struct {
|
var list struct {
|
||||||
Items []MailMessage `json:"items"`
|
Items []MailMessage `json:"items"`
|
||||||
}
|
}
|
||||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
|
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "中文标题" || list.Items[1].Subject != "older imported message" {
|
||||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
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)
|
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
@@ -90,18 +99,50 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(zr.File) != 1 {
|
if len(zr.File) != 2 {
|
||||||
t.Fatalf("zip entries=%d", len(zr.File))
|
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()
|
entry, err := zr.File[0].Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
exported, err := io.ReadAll(entry)
|
exported, err := io.ReadAll(entry)
|
||||||
entry.Close()
|
entry.Close()
|
||||||
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
|
if err != nil {
|
||||||
t.Fatalf("exported message err=%v raw=%q", err, exported)
|
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
|
var denied map[string]any
|
||||||
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
||||||
@@ -113,6 +154,64 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login=%d", code)
|
||||||
|
}
|
||||||
|
var domains struct {
|
||||||
|
Items []Domain `json:"items"`
|
||||||
|
}
|
||||||
|
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||||
|
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||||
|
}
|
||||||
|
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil)
|
||||||
|
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
|
||||||
|
owner := &testClient{t: t, server: ts}
|
||||||
|
other := &testClient{t: t, server: ts}
|
||||||
|
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("owner login=%d", code)
|
||||||
|
}
|
||||||
|
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("other login=%d", code)
|
||||||
|
}
|
||||||
|
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
|
||||||
|
var imported map[string]any
|
||||||
|
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
|
||||||
|
t.Fatalf("other import=%d response=%v", code, imported)
|
||||||
|
}
|
||||||
|
var otherList struct {
|
||||||
|
Items []MailMessage `json:"items"`
|
||||||
|
}
|
||||||
|
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
|
||||||
|
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
|
||||||
|
}
|
||||||
|
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
|
||||||
|
status, archive := getMailExport(t, owner, path)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("cross-owner export status=%d body=%q", status, archive)
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(zr.File) != 0 {
|
||||||
|
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntryNames(files []*zip.File) []string {
|
||||||
|
names := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
names = append(names, file.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ type translateMailMessageResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.MailTranslateEnabled {
|
if !a.config().MailTranslateEnabled {
|
||||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,26 +60,32 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
|||||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxChars := a.cfg.MailTranslateMaxChars
|
maxChars := a.config().MailTranslateMaxChars
|
||||||
if maxChars <= 0 {
|
if maxChars <= 0 {
|
||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.MailTranslateEnabled {
|
if !a.config().MailTranslateEnabled {
|
||||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,58 +133,102 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
|||||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxChars := a.cfg.MailTranslateMaxChars
|
maxChars := a.config().MailTranslateMaxChars
|
||||||
if maxChars <= 0 {
|
if maxChars <= 0 {
|
||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||||
|
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
|
||||||
|
|
||||||
|
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
|
||||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
type translationJob struct {
|
||||||
|
node *html.Node
|
||||||
|
original string
|
||||||
|
text string
|
||||||
|
}
|
||||||
remaining := maxChars
|
remaining := maxChars
|
||||||
var translateNode func(*html.Node) error
|
jobs := make([]translationJob, 0)
|
||||||
translateNode = func(n *html.Node) error {
|
var collect func(*html.Node)
|
||||||
|
collect = func(n *html.Node) {
|
||||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
if n.Type == html.TextNode {
|
if n.Type == html.TextNode {
|
||||||
text := strings.TrimSpace(n.Data)
|
text := strings.TrimSpace(n.Data)
|
||||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||||
limited, _ := truncateRunes(text, remaining)
|
limited, _ := truncateRunes(text, remaining)
|
||||||
remaining -= utf8.RuneCountInString(limited)
|
remaining -= utf8.RuneCountInString(limited)
|
||||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
if err := translateNode(c); err != nil {
|
collect(c)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if err := translateNode(n); err != nil {
|
collect(n)
|
||||||
return "", err
|
}
|
||||||
}
|
results := make([]string, len(jobs))
|
||||||
|
jobIndexes := make(chan int)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
workers := min(4, len(jobs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for index := range jobIndexes {
|
||||||
|
translated, _, translateErr := translator(ctx, jobs[index].text, target)
|
||||||
|
if translateErr != nil {
|
||||||
|
select {
|
||||||
|
case errCh <- translateErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[index] = translated
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for index := range jobs {
|
||||||
|
jobIndexes <- index
|
||||||
|
}
|
||||||
|
close(jobIndexes)
|
||||||
|
wg.Wait()
|
||||||
|
select {
|
||||||
|
case translateErr := <-errCh:
|
||||||
|
return "", translateErr
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
for index, job := range jobs {
|
||||||
|
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
|
||||||
}
|
}
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||||
raw := []any{
|
raw := []any{
|
||||||
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
|
||||||
|
translator := func(_ context.Context, text, target string) (string, string, error) {
|
||||||
|
return strings.ToUpper(text) + "-" + target, "en", nil
|
||||||
|
}
|
||||||
|
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
|
||||||
|
t.Fatalf("translated HTML = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<pre>keep me</pre>`) {
|
||||||
|
t.Fatalf("code block was translated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTruncateRunes(t *testing.T) {
|
func TestTruncateRunes(t *testing.T) {
|
||||||
got, truncated := truncateRunes("你好world", 4)
|
got, truncated := truncateRunes("你好world", 4)
|
||||||
if got != "你好wo" || !truncated {
|
if got != "你好wo" || !truncated {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/text/encoding"
|
"golang.org/x/text/encoding"
|
||||||
"golang.org/x/text/encoding/ianaindex"
|
"golang.org/x/text/encoding/ianaindex"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
type maildirMailbox struct {
|
type maildirMailbox struct {
|
||||||
@@ -45,13 +46,13 @@ type parsedMail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) maildirWorker(ctx context.Context) {
|
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 {
|
if interval <= 0 {
|
||||||
interval = 30 * time.Second
|
interval = 30 * time.Second
|
||||||
}
|
}
|
||||||
nextRunAt := a.now().UTC()
|
nextRunAt := a.now().UTC()
|
||||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
|
||||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||||
a.log.Warn("initial maildir sync failed", "error", err)
|
a.log.Warn("initial maildir sync failed", "error", err)
|
||||||
} else if n := counts.total(); n > 0 {
|
} else if n := counts.total(); n > 0 {
|
||||||
@@ -98,7 +99,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||||
if root == "" {
|
if root == "" {
|
||||||
return maildirSyncCounts{}, nil
|
return maildirSyncCounts{}, nil
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
|||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
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`)
|
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -216,13 +217,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
|
||||||
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
|
||||||
return counts.Imported, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||||
counts := maildirSyncCounts{}
|
counts := maildirSyncCounts{}
|
||||||
for _, sub := range []string{"new", "cur"} {
|
for _, sub := range []string{"new", "cur"} {
|
||||||
select {
|
select {
|
||||||
@@ -297,7 +293,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
|||||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||||
return false, nil
|
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
|
return err == nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +337,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
|||||||
}
|
}
|
||||||
msg.MailboxID = mb.ID
|
msg.MailboxID = mb.ID
|
||||||
msg.FolderID = folder.ID
|
msg.FolderID = folder.ID
|
||||||
|
if strings.TrimSpace(msg.RecipientAddr) == "" {
|
||||||
|
msg.RecipientAddr = mb.Address
|
||||||
|
}
|
||||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||||
msg.RawPath = path
|
msg.RawPath = path
|
||||||
if msg.MessageUID == "" {
|
if msg.MessageUID == "" {
|
||||||
@@ -372,6 +374,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
|||||||
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
||||||
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
||||||
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
||||||
|
if a.shouldNotifyTelegramMessage(ctx, id) {
|
||||||
|
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return err == nil, err
|
return err == nil, err
|
||||||
}
|
}
|
||||||
@@ -394,16 +399,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
|||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
|
|
||||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
|
||||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
|
|
||||||
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
var samePathID, oldFolderID string
|
var samePathID, oldFolderID string
|
||||||
@@ -514,7 +509,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||||
@@ -600,6 +595,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
|
|||||||
|
|
||||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||||
domain = normalizeDomain(domain)
|
domain = normalizeDomain(domain)
|
||||||
|
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
|
||||||
|
return address
|
||||||
|
}
|
||||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||||
address = normalizeEmail(address)
|
address = normalizeEmail(address)
|
||||||
if strings.HasSuffix(address, "@"+domain) {
|
if strings.HasSuffix(address, "@"+domain) {
|
||||||
@@ -624,11 +622,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
|||||||
if len(to) == 0 {
|
if len(to) == 0 {
|
||||||
to = []string{fallbackTo}
|
to = []string{fallbackTo}
|
||||||
}
|
}
|
||||||
|
recipientAddr := originalMailRecipient(m.Header)
|
||||||
sentAt := parseMailDate(m.Header.Get("Date"))
|
sentAt := parseMailDate(m.Header.Get("Date"))
|
||||||
parsed := &parsedMail{}
|
parsed := &parsedMail{}
|
||||||
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
||||||
return storedMessage{}, nil, err
|
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)
|
bodyHTML := a.policy.Sanitize(parsed.HTML)
|
||||||
bodyText := parsed.Text
|
bodyText := parsed.Text
|
||||||
if strings.TrimSpace(bodyText) == "" {
|
if strings.TrimSpace(bodyText) == "" {
|
||||||
@@ -644,6 +649,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
|||||||
return storedMessage{
|
return storedMessage{
|
||||||
MessageUID: newID("uid"),
|
MessageUID: newID("uid"),
|
||||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||||
|
RecipientAddr: recipientAddr,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
From: from,
|
From: from,
|
||||||
FromName: fromName,
|
FromName: fromName,
|
||||||
@@ -659,6 +665,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
|||||||
}, parsed.Attachments, nil
|
}, 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 {
|
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
|
||||||
contentType := header.Get("Content-Type")
|
contentType := header.Get("Content-Type")
|
||||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
@@ -697,6 +718,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)})
|
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
|
||||||
return nil
|
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) {
|
switch strings.ToLower(mediaType) {
|
||||||
case "text/html":
|
case "text/html":
|
||||||
if parsed.HTML == "" {
|
if parsed.HTML == "" {
|
||||||
@@ -792,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
|
|||||||
if charset == "utf-8" || charset == "us-ascii" {
|
if charset == "utf-8" || charset == "us-ascii" {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
// GB2312 is commonly used as a label for GBK-compatible mail content.
|
||||||
|
// ianaindex does not consistently resolve these real-world aliases.
|
||||||
|
switch charset {
|
||||||
|
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
|
||||||
|
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
|
||||||
|
case "gb18030":
|
||||||
|
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
|
||||||
|
}
|
||||||
enc, err := ianaindex.IANA.Encoding(charset)
|
enc, err := ianaindex.IANA.Encoding(charset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
|
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
raw, err := BuildMIME(MIMEMessage{
|
raw, err := BuildMIME(MIMEMessage{
|
||||||
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg, err := a.storedMessageByID(ctx, messageID)
|
msg, err := a.storedMessageByID(ctx, messageID)
|
||||||
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
state, err := a.maildirMessageState(ctx, messageID)
|
state, err := a.maildirMessageState(ctx, messageID)
|
||||||
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||||
folderBase := maildirFolderPath(base, folderName)
|
folderBase := maildirFolderPath(base, folderName)
|
||||||
subdir := "cur"
|
subdir := "cur"
|
||||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||||
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||||
if stateErr != nil {
|
if stateErr != nil {
|
||||||
return stateErr
|
return stateErr
|
||||||
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||||
folderBase := maildirFolderPath(base, folderName)
|
folderBase := maildirFolderPath(base, folderName)
|
||||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
state, err := a.maildirMessageState(ctx, messageID)
|
state, err := a.maildirMessageState(ctx, messageID)
|
||||||
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||||
return 0, nil
|
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`)
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||||
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||||
if root == "" || strings.TrimSpace(path) == "" {
|
if root == "" || strings.TrimSpace(path) == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
|
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 {
|
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
|
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
|
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -196,8 +196,12 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
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
|
return
|
||||||
}
|
}
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||||
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
|
var ownerPasswordHash, ownerRole string
|
||||||
|
var ownerStorageQuotaMB int
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quotaMB := req.QuotaMB
|
||||||
|
if quotaMB == 0 {
|
||||||
|
quotaMB = ownerStorageQuotaMB
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
displayName = current.DisplayName
|
displayName = current.DisplayName
|
||||||
}
|
}
|
||||||
quotaMB := req.QuotaMB
|
quotaMB := req.QuotaMB
|
||||||
if quotaMB <= 0 {
|
if quotaMB < 0 {
|
||||||
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quotaMB == 0 {
|
||||||
quotaMB = current.QuotaMB
|
quotaMB = current.QuotaMB
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = current.UserID
|
userID = current.UserID
|
||||||
}
|
}
|
||||||
|
if current.Primary && userID != current.UserID {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current.Primary && status != current.Status {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
var ownerRole, ownerPasswordHash string
|
||||||
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||||
|
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
return
|
||||||
@@ -318,26 +355,17 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
var owner string
|
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
|
} else {
|
||||||
|
badRequest(w, err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current := currentUser(r)
|
|
||||||
if current != nil && owner == current.ID {
|
|
||||||
var count int
|
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if count <= 1 {
|
|
||||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
messageIDs := []string{}
|
messageIDs := []string{}
|
||||||
@@ -353,11 +381,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
@@ -371,8 +399,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var userID string
|
var userID string
|
||||||
@@ -826,7 +854,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
|||||||
return "", errors.New("invalid owner email")
|
return "", errors.New("invalid owner email")
|
||||||
}
|
}
|
||||||
var existing string
|
var existing string
|
||||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
|
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
@@ -838,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
|||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = email
|
displayName = email
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,15 +951,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
|
|||||||
}
|
}
|
||||||
return limit
|
return limit
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseOpenAPIOffset(r *http.Request) int {
|
|
||||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
|
||||||
if cursor == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
offset, err := strconv.Atoi(cursor)
|
|
||||||
if err != nil || offset < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return offset
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
var existingSystem int
|
var existingSystem int
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
var existingName, existingDescription string
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "permission group not found")
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if id == PermissionGroupSuperAdmin {
|
if intBool(existingSystem) && id != PermissionGroupRegular {
|
||||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if id == PermissionGroupRegular {
|
||||||
|
name = existingName
|
||||||
|
req.Description = existingDescription
|
||||||
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
badRequest(w, errors.New("name is required"))
|
badRequest(w, errors.New("name is required"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
|
|||||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||||
|
|
||||||
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
||||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
|
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
|
||||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
|
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"},
|
||||||
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
||||||
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
||||||
|
|
||||||
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
|
{Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"},
|
||||||
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
|
{Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"},
|
||||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
|
{Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"},
|
||||||
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
|
{Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"},
|
||||||
|
|
||||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||||
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
|||||||
{
|
{
|
||||||
ID: PermissionGroupSuperAdmin,
|
ID: PermissionGroupSuperAdmin,
|
||||||
Name: "管理员",
|
Name: "管理员",
|
||||||
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
|
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。",
|
||||||
Permissions: allPermissionKeys(),
|
Permissions: allPermissionKeys(),
|
||||||
Limits: PermissionLimits{},
|
Limits: PermissionLimits{},
|
||||||
System: true,
|
System: true,
|
||||||
@@ -490,21 +490,6 @@ func regularUserDefaultPermissions() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fixedPermissionGroupIDs() map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, group := range defaultPermissionGroups() {
|
|
||||||
out[group.ID] = true
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignablePermissionGroupIDs() map[string]bool {
|
|
||||||
out := fixedPermissionGroupIDs()
|
|
||||||
delete(out, PermissionGroupSuperAdmin)
|
|
||||||
delete(out, PermissionGroupRegular)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAssignablePermissionGroupID(groupID string) bool {
|
func isAssignablePermissionGroupID(groupID string) bool {
|
||||||
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||||
}
|
}
|
||||||
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
|
|||||||
return out
|
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 {
|
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
for _, item := range defaultPermissionGroups() {
|
for _, item := range defaultPermissionGroups() {
|
||||||
@@ -1054,11 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||||
if u == nil {
|
return u != nil && u.Role == "admin"
|
||||||
return false
|
|
||||||
}
|
|
||||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
|
||||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortPermissionGroups(items []PermissionGroup) {
|
func sortPermissionGroups(items []PermissionGroup) {
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, MailboxApplyOptions{
|
respondJSON(w, http.StatusOK, MailboxApplyOptions{
|
||||||
Enabled: a.cfg.UserMailboxApplyEnabled,
|
Enabled: a.config().UserMailboxApplyEnabled,
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
|
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.UserMailboxApplyEnabled {
|
if !a.config().UserMailboxApplyEnabled {
|
||||||
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
|
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
reserved := map[string]bool{}
|
reserved := map[string]bool{}
|
||||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||||
reserved[item] = true
|
reserved[item] = true
|
||||||
}
|
}
|
||||||
if reserved[localPart] {
|
if reserved[localPart] {
|
||||||
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
|
quotaMB := defaultUserStorageQuotaMB
|
||||||
|
if user.Role == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
||||||
@@ -129,10 +133,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
|
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
|
||||||
if !a.cfg.UserMailboxApplyEnabled {
|
if !a.config().UserMailboxApplyEnabled {
|
||||||
return []Domain{}, nil
|
return []Domain{}, nil
|
||||||
}
|
}
|
||||||
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
|
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return []Domain{}, nil
|
return []Domain{}, nil
|
||||||
}
|
}
|
||||||
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appliedCount := int64(0)
|
appliedCount := int64(0)
|
||||||
if req.ApplyToExisting && enabled {
|
if req.ApplyToExisting {
|
||||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||||
item, err := scanRule(row)
|
item, err := scanRule(row)
|
||||||
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
var quotaMB int64
|
||||||
var quotaMB int64
|
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil {
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
return
|
||||||
return
|
}
|
||||||
}
|
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
if stats.QuotaBytes > 0 {
|
||||||
if stats.QuotaBytes > 0 {
|
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var quotaMB int64
|
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
|
||||||
if stats.QuotaBytes > 0 {
|
|
||||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
|||||||
if !ruleMatches(rule, msg) {
|
if !ruleMatches(rule, msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rule.StopProcessing {
|
if rule.StopProcessing {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
|
|||||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||||
var msg ruleMessage
|
var msg ruleMessage
|
||||||
var toAddrs, ccAddrs, receivedAt string
|
var toAddrs, ccAddrs, receivedAt string
|
||||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ruleMessage{}, false
|
return ruleMessage{}, false
|
||||||
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
|||||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
parts = append(parts, filename, contentType)
|
parts = append(parts, filename)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
|||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "contains"
|
operator = "contains"
|
||||||
}
|
}
|
||||||
switch operator {
|
if !validRuleConditionOperator(field, operator) {
|
||||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
|
||||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
|
||||||
default:
|
|
||||||
return MailRuleCondition{}, false
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validRuleConditionOperator(field, operator string) bool {
|
||||||
|
switch field {
|
||||||
|
case "size":
|
||||||
|
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||||
|
case "date":
|
||||||
|
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||||
|
default:
|
||||||
|
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRuleMatchMode(matchMode string) string {
|
func normalizeRuleMatchMode(matchMode string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||||
case "any", "or":
|
case "any", "or":
|
||||||
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
|||||||
for _, action := range normalizeRuleActions(actions, "") {
|
for _, action := range normalizeRuleActions(actions, "") {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "archive":
|
case "archive":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "trash":
|
case "trash":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "move":
|
case "move":
|
||||||
target := ruleTargetFolder(action.Value)
|
target, err := normalizeFolderNameForUser(action.Value)
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
if err != nil {
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
var folderID string
|
||||||
|
if isSystemFolderName(target) {
|
||||||
|
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||||
|
} else {
|
||||||
|
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "star":
|
case "star":
|
||||||
starred := true
|
starred := true
|
||||||
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruleTargetFolder(value string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "inbox":
|
|
||||||
return "Inbox"
|
|
||||||
case "archive":
|
|
||||||
return "Archive"
|
|
||||||
case "spam":
|
|
||||||
return "Spam"
|
|
||||||
case "trash":
|
|
||||||
return "Trash"
|
|
||||||
default:
|
|
||||||
return "Archive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||||
args := []any{userID}
|
args := []any{userID}
|
||||||
where := `mb.user_id=?`
|
where := `mb.user_id=?`
|
||||||
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
|||||||
where += ` AND m.mailbox_id=?`
|
where += ` AND m.mailbox_id=?`
|
||||||
args = append(args, mailboxID)
|
args = append(args, mailboxID)
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func (a *App) Router() http.Handler {
|
|||||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
r.With(a.requirePermission(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)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
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)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||||
@@ -138,6 +139,20 @@ func (a *App) Router() http.Handler {
|
|||||||
r.Use(a.requireAdminAccess)
|
r.Use(a.requireAdminAccess)
|
||||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||||
|
r.Get("/admin/backups", a.handleListBackups)
|
||||||
|
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||||
|
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||||
|
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||||
|
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||||
|
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||||
|
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
|
||||||
|
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
|
||||||
|
r.Post("/admin/backups", a.handleCreateBackup)
|
||||||
|
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
|
||||||
|
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
|
||||||
|
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
|
||||||
|
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
|
||||||
|
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
|
||||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||||
@@ -170,6 +185,9 @@ func (a *App) Router() http.Handler {
|
|||||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
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(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
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(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
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)
|
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||||
@@ -215,7 +233,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
|
|||||||
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
origin := r.Header.Get("Origin")
|
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("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
@@ -273,7 +291,7 @@ func currentUser(r *http.Request) *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
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 == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return nil, errors.New("no session")
|
return nil, errors.New("no session")
|
||||||
}
|
}
|
||||||
@@ -344,10 +362,9 @@ func bearerToken(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||||
loginName := normalizeLoginName(email)
|
email = normalizeEmail(email)
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
||||||
FROM users WHERE login_name=? OR email=?
|
FROM users WHERE email=? LIMIT 1`, email)
|
||||||
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
|
|
||||||
var u User
|
var u User
|
||||||
var passwordHash string
|
var passwordHash string
|
||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type sendQueueItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
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
|
return "", nil
|
||||||
}
|
}
|
||||||
now := in.Now.UTC()
|
now := in.Now.UTC()
|
||||||
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||||
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
|||||||
if safeID == "" || safeID == "." {
|
if safeID == "" || safeID == "." {
|
||||||
safeID = "unknown"
|
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 {
|
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 {
|
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||||
token := randomToken()
|
token := randomToken()
|
||||||
sessionID := newID("ses")
|
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(?,?,?,?,?)`,
|
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 {
|
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: a.cfg.CookieName,
|
Name: a.config().CookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
MaxAge: int(time.Until(expires).Seconds()),
|
MaxAge: int(time.Until(expires).Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Secure: !a.cfg.AllowInsecureHTTP,
|
Secure: !a.config().AllowInsecureHTTP,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ type SystemSettings struct {
|
|||||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
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 {
|
type systemSettingsUpdate struct {
|
||||||
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
|
|||||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
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 {
|
type PublicSettings struct {
|
||||||
@@ -100,15 +112,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
|
cfg := a.config()
|
||||||
refreshSeconds := a.cfg.MailRefreshSeconds
|
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
|
||||||
|
refreshSeconds := cfg.MailRefreshSeconds
|
||||||
if refreshSeconds <= 0 {
|
if refreshSeconds <= 0 {
|
||||||
refreshSeconds = 30
|
refreshSeconds = 30
|
||||||
}
|
}
|
||||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
|
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
|
||||||
|
|
||||||
// Include available domains for mailbox creation during registration
|
// 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`)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -126,12 +139,14 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.telegramDeliveryMu.Lock()
|
||||||
|
defer a.telegramDeliveryMu.Unlock()
|
||||||
var req systemSettingsUpdate
|
var req systemSettingsUpdate
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next := a.cfg
|
next := a.config()
|
||||||
next.PublicHostname = normalizeHostname(req.PublicHostname)
|
next.PublicHostname = normalizeHostname(req.PublicHostname)
|
||||||
if next.PublicHostname == "" {
|
if next.PublicHostname == "" {
|
||||||
badRequest(w, errors.New("publicHostname is required"))
|
badRequest(w, errors.New("publicHostname is required"))
|
||||||
@@ -203,12 +218,36 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||||
return
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cfg = next
|
a.setConfig(next)
|
||||||
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
|
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +257,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := a.cfg
|
cfg := a.config()
|
||||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||||
badRequest(w, errors.New("SMTP 主机未设置"))
|
badRequest(w, errors.New("SMTP 主机未设置"))
|
||||||
return
|
return
|
||||||
@@ -285,41 +324,49 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||||
|
cfg := a.config()
|
||||||
return SystemSettings{
|
return SystemSettings{
|
||||||
PublicHostname: a.cfg.PublicHostname,
|
PublicHostname: cfg.PublicHostname,
|
||||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
SMTPHost: a.cfg.SMTPHost,
|
SMTPHost: cfg.SMTPHost,
|
||||||
SMTPPort: a.cfg.SMTPPort,
|
SMTPPort: cfg.SMTPPort,
|
||||||
SMTPUsername: a.cfg.SMTPUsername,
|
SMTPUsername: cfg.SMTPUsername,
|
||||||
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
|
||||||
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
SMTPRequireTLS: cfg.SMTPRequireTLS,
|
||||||
MaildirRoot: a.cfg.MaildirRoot,
|
MaildirRoot: cfg.MaildirRoot,
|
||||||
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
MaildirScanSeconds: cfg.MaildirScanSeconds,
|
||||||
SessionTTLHours: a.cfg.SessionTTLHours,
|
SessionTTLHours: cfg.SessionTTLHours,
|
||||||
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
|
||||||
OpenRegistration: a.cfg.OpenRegistration,
|
OpenRegistration: cfg.OpenRegistration,
|
||||||
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
TwoFactorEnabled: cfg.TwoFactorEnabled,
|
||||||
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
TurnstileEnabled: cfg.TurnstileEnabled,
|
||||||
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
TurnstileSiteKey: cfg.TurnstileSiteKey,
|
||||||
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
|
||||||
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
CatchAllEnabled: cfg.CatchAllEnabled,
|
||||||
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
MailAutoRefresh: cfg.MailAutoRefresh,
|
||||||
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
MailRefreshSeconds: cfg.MailRefreshSeconds,
|
||||||
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
|
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
|
||||||
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
|
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
|
||||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
|
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
|
||||||
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
|
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
|
||||||
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
|
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
|
||||||
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
|
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
|
||||||
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
|
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
|
||||||
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
|
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
|
||||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
|
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||||
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
|
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
|
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 {
|
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||||
|
cfg := a.config()
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
|
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -332,79 +379,95 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
switch key {
|
switch key {
|
||||||
case "publicHostname":
|
case "publicHostname":
|
||||||
a.cfg.PublicHostname = value
|
cfg.PublicHostname = value
|
||||||
case "publicBaseUrl":
|
case "publicBaseUrl":
|
||||||
a.cfg.PublicBaseURL = value
|
cfg.PublicBaseURL = value
|
||||||
case "smtpHost":
|
case "smtpHost":
|
||||||
a.cfg.SMTPHost = value
|
cfg.SMTPHost = value
|
||||||
case "smtpPort":
|
case "smtpPort":
|
||||||
a.cfg.SMTPPort = value
|
cfg.SMTPPort = value
|
||||||
case "smtpUsername":
|
case "smtpUsername":
|
||||||
a.cfg.SMTPUsername = value
|
cfg.SMTPUsername = value
|
||||||
case "smtpPassword":
|
case "smtpPassword":
|
||||||
a.cfg.SMTPPassword = value
|
cfg.SMTPPassword = value
|
||||||
case "smtpRequireTls":
|
case "smtpRequireTls":
|
||||||
a.cfg.SMTPRequireTLS = value == "true"
|
cfg.SMTPRequireTLS = value == "true"
|
||||||
case "maildirRoot":
|
case "maildirRoot":
|
||||||
a.cfg.MaildirRoot = value
|
cfg.MaildirRoot = value
|
||||||
case "maildirScanSeconds":
|
case "maildirScanSeconds":
|
||||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||||
a.cfg.MaildirScanSeconds = n
|
cfg.MaildirScanSeconds = n
|
||||||
}
|
}
|
||||||
case "sessionTtlHours":
|
case "sessionTtlHours":
|
||||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||||
a.cfg.SessionTTLHours = n
|
cfg.SessionTTLHours = n
|
||||||
}
|
}
|
||||||
case "allowInsecureHttp":
|
case "allowInsecureHttp":
|
||||||
a.cfg.AllowInsecureHTTP = value == "true"
|
cfg.AllowInsecureHTTP = value == "true"
|
||||||
case "openRegistration":
|
case "openRegistration":
|
||||||
a.cfg.OpenRegistration = value == "true"
|
cfg.OpenRegistration = value == "true"
|
||||||
case "twoFactorEnabled":
|
case "twoFactorEnabled":
|
||||||
a.cfg.TwoFactorEnabled = value == "true"
|
cfg.TwoFactorEnabled = value == "true"
|
||||||
case "turnstileEnabled":
|
case "turnstileEnabled":
|
||||||
a.cfg.TurnstileEnabled = value == "true"
|
cfg.TurnstileEnabled = value == "true"
|
||||||
case "turnstileSiteKey":
|
case "turnstileSiteKey":
|
||||||
a.cfg.TurnstileSiteKey = value
|
cfg.TurnstileSiteKey = value
|
||||||
case "turnstileSecretKey":
|
case "turnstileSecretKey":
|
||||||
a.cfg.TurnstileSecretKey = value
|
cfg.TurnstileSecretKey = value
|
||||||
case "catchAllEnabled":
|
case "catchAllEnabled":
|
||||||
a.cfg.CatchAllEnabled = value == "true"
|
cfg.CatchAllEnabled = value == "true"
|
||||||
case "mailAutoRefresh":
|
case "mailAutoRefresh":
|
||||||
a.cfg.MailAutoRefresh = value == "true"
|
cfg.MailAutoRefresh = value == "true"
|
||||||
case "mailRefreshSeconds":
|
case "mailRefreshSeconds":
|
||||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||||
a.cfg.MailRefreshSeconds = n
|
cfg.MailRefreshSeconds = n
|
||||||
}
|
}
|
||||||
case "userMailboxApplyEnabled":
|
case "userMailboxApplyEnabled":
|
||||||
a.cfg.UserMailboxApplyEnabled = value == "true"
|
cfg.UserMailboxApplyEnabled = value == "true"
|
||||||
case "userMailboxDomainIds":
|
case "userMailboxDomainIds":
|
||||||
a.cfg.UserMailboxDomainIDs = value
|
cfg.UserMailboxDomainIDs = value
|
||||||
case "reservedMailboxPrefixes":
|
case "reservedMailboxPrefixes":
|
||||||
a.cfg.ReservedMailboxPrefixes = value
|
cfg.ReservedMailboxPrefixes = value
|
||||||
case "externalImapEnabled":
|
case "externalImapEnabled":
|
||||||
a.cfg.ExternalIMAPEnabled = value == "true"
|
cfg.ExternalIMAPEnabled = value == "true"
|
||||||
case "externalImapSecretKey":
|
case "externalImapSecretKey":
|
||||||
a.cfg.ExternalIMAPSecretKey = value
|
cfg.ExternalIMAPSecretKey = value
|
||||||
case "externalImapSyncSeconds":
|
case "externalImapSyncSeconds":
|
||||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||||
a.cfg.ExternalIMAPSyncSeconds = n
|
cfg.ExternalIMAPSyncSeconds = n
|
||||||
}
|
}
|
||||||
case "externalImapAllowPrivateHosts":
|
case "externalImapAllowPrivateHosts":
|
||||||
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||||
case "externalImapGmailClientId":
|
case "externalImapGmailClientId":
|
||||||
a.cfg.ExternalIMAPGmailClientID = value
|
cfg.ExternalIMAPGmailClientID = value
|
||||||
case "externalImapGmailClientSecret":
|
case "externalImapGmailClientSecret":
|
||||||
a.cfg.ExternalIMAPGmailClientSecret = value
|
cfg.ExternalIMAPGmailClientSecret = value
|
||||||
case "externalImapOutlookClientId":
|
case "externalImapOutlookClientId":
|
||||||
a.cfg.ExternalIMAPOutlookClientID = value
|
cfg.ExternalIMAPOutlookClientID = value
|
||||||
case "externalImapOutlookClientSecret":
|
case "externalImapOutlookClientSecret":
|
||||||
a.cfg.ExternalIMAPOutlookClientSecret = value
|
cfg.ExternalIMAPOutlookClientSecret = value
|
||||||
|
case "telegramMailEnabled":
|
||||||
|
cfg.TelegramMailEnabled = value == "true"
|
||||||
|
case "telegramBotToken":
|
||||||
|
cfg.TelegramBotToken = value
|
||||||
|
case "telegramPrivateChatId":
|
||||||
|
cfg.TelegramPrivateChatID = value
|
||||||
|
case "telegramBodyMode":
|
||||||
|
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
|
||||||
|
case "telegramMailboxIds":
|
||||||
|
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
|
||||||
|
case "telegramIncludeUnregistered":
|
||||||
|
cfg.TelegramIncludeUnregistered = value == "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setConfig(cfg)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"publicHostname": cfg.PublicHostname,
|
"publicHostname": cfg.PublicHostname,
|
||||||
"publicBaseUrl": cfg.PublicBaseURL,
|
"publicBaseUrl": cfg.PublicBaseURL,
|
||||||
@@ -436,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
|||||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
"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)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
tx, err := a.db.BeginTx(ctx, nil)
|
tx, err := a.db.BeginTx(ctx, nil)
|
||||||
@@ -449,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
|||||||
return err
|
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()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
|
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
|
||||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
now := a.now().UTC()
|
now := a.now().UTC()
|
||||||
@@ -39,7 +39,7 @@ func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) statusWebhookWorker(ctx context.Context) {
|
func (a *App) statusWebhookWorker(ctx context.Context) {
|
||||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.log.Info("status webhook worker started")
|
a.log.Info("status webhook worker started")
|
||||||
@@ -59,7 +59,7 @@ func (a *App) statusWebhookWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
|
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
|
||||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
|
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
|
||||||
@@ -104,7 +104,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
|
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
|
||||||
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
|
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
|
||||||
_, _ = mac.Write([]byte(timestamp + "."))
|
_, _ = mac.Write([]byte(timestamp + "."))
|
||||||
_, _ = mac.Write(payload)
|
_, _ = mac.Write(payload)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||||
@@ -134,17 +134,17 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
|
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
|
||||||
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
|
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
|
||||||
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
|
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
|
||||||
}
|
}
|
||||||
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
|
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
|
||||||
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
|
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
|
||||||
return nil, errors.New("invalid status webhook URL")
|
return nil, errors.New("invalid status webhook URL")
|
||||||
}
|
}
|
||||||
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
|
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
|
||||||
return nil, errors.New("status webhook URL must use HTTPS")
|
return nil, errors.New("status webhook URL must use HTTPS")
|
||||||
}
|
}
|
||||||
if !a.cfg.StatusWebhookAllowPrivateHosts {
|
if !a.config().StatusWebhookAllowPrivateHosts {
|
||||||
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
|
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if a.cfg.StatusWebhookAllowPrivateHosts {
|
if a.config().StatusWebhookAllowPrivateHosts {
|
||||||
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
|
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
|
||||||
}
|
}
|
||||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
|
|||||||
|
|
||||||
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
||||||
return &SubmissionServers{
|
return &SubmissionServers{
|
||||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
|
||||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, 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 := smtpserver.NewServer(submissionBackend{app: a})
|
||||||
s.Addr = addr
|
s.Addr = addr
|
||||||
s.Domain = a.cfg.PublicHostname
|
s.Domain = a.config().PublicHostname
|
||||||
s.TLSConfig = tlsConfig
|
s.TLSConfig = tlsConfig
|
||||||
s.AllowInsecureAuth = false
|
s.AllowInsecureAuth = false
|
||||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||||
s.ReadTimeout = smtpSessionTimeout
|
s.ReadTimeout = smtpSessionTimeout
|
||||||
s.WriteTimeout = smtpSessionTimeout
|
s.WriteTimeout = smtpSessionTimeout
|
||||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||||
@@ -121,21 +121,59 @@ type submissionSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *submissionSession) AuthMechanisms() []string {
|
func (s *submissionSession) AuthMechanisms() []string {
|
||||||
return []string{sasl.Plain}
|
return []string{sasl.Plain, sasl.Login}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||||
if !strings.EqualFold(mech, sasl.Plain) {
|
authenticate := func(username, password string) error {
|
||||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
|
||||||
}
|
|
||||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
|
||||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return smtpserver.ErrAuthFailed
|
return smtpserver.ErrAuthFailed
|
||||||
}
|
}
|
||||||
s.user, s.mailbox = user, mailbox
|
s.user, s.mailbox = user, mailbox
|
||||||
return nil
|
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 {
|
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
|
||||||
a.log.Error("trigger system update", "error", err)
|
|
||||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -94,10 +88,11 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"targetVersion": info.LatestVersion,
|
"targetVersion": info.LatestVersion,
|
||||||
"message": "更新已启动,服务会在完成后自动恢复",
|
"message": "更新已启动,服务会在完成后自动恢复",
|
||||||
})
|
})
|
||||||
|
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||||
current := strings.TrimSpace(a.cfg.AppVersion)
|
current := strings.TrimSpace(a.config().AppVersion)
|
||||||
if current == "" {
|
if current == "" {
|
||||||
current = BuildVersion
|
current = BuildVersion
|
||||||
}
|
}
|
||||||
@@ -124,7 +119,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||||
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
|
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
|
||||||
parsed, err := url.Parse(endpoint)
|
parsed, err := url.Parse(endpoint)
|
||||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return githubRelease{}, errors.New("invalid release API URL")
|
return githubRelease{}, errors.New("invalid release API URL")
|
||||||
@@ -134,7 +129,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
|||||||
return githubRelease{}, err
|
return githubRelease{}, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
|
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
@@ -161,11 +156,11 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) updateEnabled() bool {
|
func (a *App) updateEnabled() bool {
|
||||||
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
|
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) triggerUpdateService(ctx context.Context) error {
|
func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
|
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
|
||||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return errors.New("invalid update service URL")
|
return errors.New("invalid update service URL")
|
||||||
}
|
}
|
||||||
@@ -173,9 +168,9 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 10 * time.Minute,
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -192,8 +187,20 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
return nil
|
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) {
|
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||||
backupDir := filepath.Join(a.cfg.DataDir, "backups")
|
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
defer releaseServer.Close()
|
defer releaseServer.Close()
|
||||||
|
|
||||||
var updateRequests atomic.Int32
|
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) {
|
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Errorf("update method = %s", r.Method)
|
t.Errorf("update method = %s", r.Method)
|
||||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Errorf("authorization = %q", got)
|
t.Errorf("authorization = %q", got)
|
||||||
}
|
}
|
||||||
updateRequests.Add(1)
|
updateRequests.Add(1)
|
||||||
|
updateStarted <- struct{}{}
|
||||||
|
<-releaseUpdate
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
defer updateServer.Close()
|
defer updateServer.Close()
|
||||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Fatalf("unexpected version response: %+v", version)
|
t.Fatalf("unexpected version response: %+v", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
var update map[string]any
|
type updateResponse struct {
|
||||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
code int
|
||||||
t.Fatalf("update code=%d response=%v", code, update)
|
err error
|
||||||
}
|
}
|
||||||
if updateRequests.Load() != 1 {
|
response := make(chan updateResponse, 1)
|
||||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
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"))
|
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||||
if err != nil || len(backups) != 1 {
|
if err != nil || len(backups) != 1 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"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 TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
|
||||||
|
subject := "验证 Apple 账户电子邮件地址"
|
||||||
|
body := "你的 Apple 验证码是 978534"
|
||||||
|
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
|
||||||
|
"To: admin@example.com\r\n" +
|
||||||
|
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
|
||||||
|
"Content-Type: text/plain; charset=gb2312\r\n" +
|
||||||
|
"Content-Transfer-Encoding: base64\r\n\r\n" +
|
||||||
|
base64.StdEncoding.EncodeToString(encodedBody))
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if msg.Subject != subject {
|
||||||
|
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
|
||||||
|
}
|
||||||
|
if msg.BodyText != body {
|
||||||
|
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
|
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
|
||||||
if !a.cfg.TurnstileEnabled {
|
if !a.config().TurnstileEnabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
|
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
|
||||||
if secret == "" || token == "" {
|
if secret == "" || token == "" {
|
||||||
return errors.New("turnstile verification required")
|
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 != "" {
|
if ip := normalizeRemoteIP(remoteIP); ip != "" {
|
||||||
form.Set("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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
|
|||||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
|
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 {
|
func totpProvisioningURI(issuer, account, secret string) string {
|
||||||
issuer = strings.TrimSpace(issuer)
|
issuer = strings.TrimSpace(issuer)
|
||||||
account = strings.TrimSpace(account)
|
account = strings.TrimSpace(account)
|
||||||
@@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
|
|||||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
|
_, _ = 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) {
|
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
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 u User
|
||||||
@@ -144,7 +214,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.TwoFactorEnabled {
|
if !a.config().TwoFactorEnabled {
|
||||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,7 +249,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.cfg.TwoFactorEnabled {
|
if !a.config().TwoFactorEnabled {
|
||||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||||
return
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||||
return
|
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) {
|
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||||
return
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ type User struct {
|
|||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
User
|
User
|
||||||
MailboxCount int `json:"mailboxCount"`
|
MailboxCount int `json:"mailboxCount"`
|
||||||
Mailboxes []string `json:"mailboxes"`
|
Mailboxes []string `json:"mailboxes"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToken struct {
|
type APIToken struct {
|
||||||
@@ -71,6 +72,7 @@ type Mailbox struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,7 @@ type MailFolder struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||||
|
|
||||||
|
const minimumPasswordLength = 6
|
||||||
|
|
||||||
|
func hasMinimumPasswordLength(password string) bool {
|
||||||
|
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||||
|
}
|
||||||
|
|
||||||
func NewHTMLPolicy() *HTMLPolicy {
|
func NewHTMLPolicy() *HTMLPolicy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
p.AllowElements("html", "head", "body", "center", "font")
|
p.AllowElements("html", "head", "body", "center", "font")
|
||||||
@@ -187,6 +194,32 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
|||||||
return loginName, nil
|
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 {
|
func dedupeEmails(items []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
out := make([]string, 0, len(items))
|
out := make([]string, 0, len(items))
|
||||||
|
|||||||
@@ -26,27 +26,22 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tanstack/react-query": "5.59.16",
|
"@tanstack/react-query": "5.59.16",
|
||||||
"@tiptap/core": "^3.27.0",
|
"@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-image": "^3.27.0",
|
||||||
"@tiptap/extension-link": "^3.27.0",
|
"@tiptap/extension-link": "^3.27.0",
|
||||||
"@tiptap/extension-placeholder": "^3.27.0",
|
"@tiptap/extension-placeholder": "^3.27.0",
|
||||||
"@tiptap/extension-text-align": "^3.27.0",
|
"@tiptap/extension-text-align": "^3.27.0",
|
||||||
"@tiptap/extension-text-style": "^3.27.0",
|
"@tiptap/extension-text-style": "^3.27.0",
|
||||||
"@tiptap/extension-underline": "^3.27.0",
|
|
||||||
"@tiptap/pm": "^3.27.0",
|
"@tiptap/pm": "^3.27.0",
|
||||||
"@tiptap/react": "^3.27.0",
|
"@tiptap/react": "^3.27.0",
|
||||||
"@tiptap/starter-kit": "^3.27.0",
|
"@tiptap/starter-kit": "^3.27.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dompurify": "3.4.10",
|
"dompurify": "3.4.13",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-router-dom": "7.18.2",
|
||||||
"react-router-dom": "6.30.4",
|
|
||||||
"tailwind-merge": "2.5.4"
|
"tailwind-merge": "2.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,7 +50,7 @@
|
|||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"postcss": "8.5.15",
|
"postcss": "8.5.25",
|
||||||
"tailwindcss": "3.4.15",
|
"tailwindcss": "3.4.15",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Navigate, useLocation } from "react-router-dom"
|
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 { AuthLoading, AuthError } from "@/components/auth-states"
|
||||||
|
import { isUnauthorizedError } from "@/lib/api"
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
if (me.isLoading) return <AuthLoading />
|
if (me.isLoading) return <AuthLoading />
|
||||||
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
if (me.isError && !isUnauthorizedError(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 || !me.data?.user) {
|
||||||
|
const from = `${location.pathname}${location.search}${location.hash}`
|
||||||
|
return <Navigate to="/login" replace state={{ from }} />
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function AuthLoading() {
|
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 }) {
|
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
return (
|
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="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>
|
<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>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Mail } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function BrandMark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
|
||||||
|
<Mail className="size-6 stroke-[1.8]" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
|
||||||
@@ -27,12 +26,12 @@ export function ConfirmDialog({
|
|||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { AuthGuard } from "@/components/auth-guard"
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||||
|
import { BrandMark } from "@/components/brand-mark"
|
||||||
import { hasAnyPermission } from "@/lib/permissions"
|
import { hasAnyPermission } from "@/lib/permissions"
|
||||||
import type { PermissionKey } from "@/lib/api-types"
|
import type { PermissionKey } from "@/lib/api-types"
|
||||||
import {
|
import {
|
||||||
@@ -28,14 +28,15 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
{ key: "overview", label: "仪表盘", icon: <LayoutDashboard />, permissions: ["admin.overview.view"] },
|
||||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||||
{ key: "permissionGroups", label: "权限配额", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||||
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||||
|
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
|
||||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ function ProtectedContent() {
|
|||||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
|
||||||
|
|
||||||
if (isMailRoute || isProfileRoute) {
|
if (isMailRoute || isProfileRoute) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
@@ -72,9 +73,7 @@ function ProtectedContent() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<BrandMark className="size-8 rounded-md [&>svg]:size-5" />
|
||||||
<Mail className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,17 +107,14 @@ function ProtectedContent() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.displayName}</span>
|
<span className="truncate font-semibold">{user.displayName}</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
|
||||||
{user.role === "admin" ? "管理员" : "普通用户"}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
|
<Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||||
<LogOut className="h-3.5 w-3.5" />退出登录
|
<LogOut className="h-3.5 w-3.5" />退出登录
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
setUpdatePhase("starting")
|
setUpdatePhase("starting")
|
||||||
const result = await api.updateSystem()
|
const targetVersion = version.data?.latestVersion
|
||||||
|
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||||
|
try {
|
||||||
|
result = await api.updateSystem()
|
||||||
|
} catch (error) {
|
||||||
|
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||||
|
result = {
|
||||||
|
ok: true,
|
||||||
|
currentVersion,
|
||||||
|
targetVersion,
|
||||||
|
message: "更新请求已发送,正在等待服务恢复",
|
||||||
|
}
|
||||||
|
}
|
||||||
setUpdatePhase("restarting")
|
setUpdatePhase("restarting")
|
||||||
await waitForUpdatedService(result.targetVersion)
|
await waitForUpdatedService(result.targetVersion)
|
||||||
return result
|
return result
|
||||||
@@ -69,11 +81,11 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="border-b pb-4 text-center">
|
<div className="border-b pb-3 text-center">
|
||||||
<div className="text-sm text-muted-foreground">当前版本</div>
|
<div className="text-xs text-muted-foreground">当前版本</div>
|
||||||
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
|
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
|
||||||
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
||||||
@@ -91,7 +103,7 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
{version.data?.releaseNotes && (
|
{version.data?.releaseNotes && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">更新日志</div>
|
<div className="text-sm font-medium">更新日志</div>
|
||||||
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
|
<div className="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
|
||||||
{version.data.releaseNotes}
|
{version.data.releaseNotes}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
|||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
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:
|
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",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const Card = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-header"
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-title"
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
))
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user