Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -39,6 +40,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
|
||||
bash tests/install_test.sh
|
||||
bash tests/dkim_sync_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
||||
+128
-59
@@ -1,6 +1,7 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -26,6 +27,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
|
||||
bash tests/install_test.sh
|
||||
bash tests/dkim_sync_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -135,8 +145,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare image name
|
||||
id: image
|
||||
@@ -160,7 +170,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
|
||||
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
|
||||
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -196,77 +206,136 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
version="${{ needs.release.outputs.version }}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
repo_url="https://github.com/${repo}"
|
||||
image_base="${REGISTRY}/${repo}"
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
if [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
cp generated-release-notes.md release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
id: release_result
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin Email ${tag}"
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Notify Telegram release channel
|
||||
if: steps.release_result.outputs.created == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
RELEASE_URL: ${{ needs.release.outputs.release_url }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
|
||||
echo "::notice::Telegram release notification is not configured; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
|
||||
entries = []
|
||||
for raw in notes.splitlines():
|
||||
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
|
||||
line = re.sub(r"^[-*+]\s+", "", line)
|
||||
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
|
||||
line = re.sub(r"`([^`]+)`", r"\1", line)
|
||||
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
|
||||
if line:
|
||||
entries.append(line)
|
||||
|
||||
sections = []
|
||||
for index, entry in enumerate(entries, 1):
|
||||
parts = re.split(r"[::]", entry, maxsplit=1)
|
||||
if len(parts) == 1:
|
||||
parts = re.split(r"[,;。]", entry, maxsplit=1)
|
||||
title = parts[0].strip()
|
||||
description = parts[1].strip() if len(parts) > 1 else ""
|
||||
section = f"<b>{index:02d} · {html.escape(title)}</b>"
|
||||
if description:
|
||||
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
|
||||
sections.append(section)
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
|
||||
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
|
||||
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
|
||||
available = max(0, 3600 - len(prefix) - len(footer))
|
||||
visible_sections = []
|
||||
used = 0
|
||||
for section in sections:
|
||||
added = len(section) + (2 if visible_sections else 0)
|
||||
if used + added > available:
|
||||
break
|
||||
visible_sections.append(section)
|
||||
used += added
|
||||
body = "\n\n".join(visible_sections)
|
||||
if len(visible_sections) < len(sections):
|
||||
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
|
||||
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
|
||||
PY
|
||||
|
||||
jq -n \
|
||||
--arg chat_id "${TELEGRAM_CHAT_ID}" \
|
||||
--rawfile text telegram-release-message.txt \
|
||||
'{
|
||||
chat_id:$chat_id,
|
||||
text:$text,
|
||||
parse_mode:"HTML",
|
||||
disable_web_page_preview:true
|
||||
}' > telegram-release-payload.json
|
||||
|
||||
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
|
||||
-o telegram-release-response.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @telegram-release-payload.json \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
|
||||
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
|
||||
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
|
||||
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Telegram release notification sent."
|
||||
|
||||
+5
-1
@@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
|
||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||
|
||||
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
|
||||
|
||||
## Update
|
||||
|
||||
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
|
||||
@@ -39,10 +41,12 @@ Useful commands:
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
The uninstall command 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -16,7 +16,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -29,10 +29,76 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
### 管理面板
|
||||
|
||||
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
|
||||
|
||||
```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
|
||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 自动检测并确认邮箱地址域名;创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`;回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -48,6 +114,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
@@ -56,21 +124,32 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email repair
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -104,10 +183,11 @@ sudo newszxcn-email uninstall
|
||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
|-- data/ # SQLite、附件和更新前备份
|
||||
|-- mail/ # Maildir 邮件原文
|
||||
`-- dkim/ # DKIM 私钥
|
||||
|-- dkim/ # DKIM 私钥
|
||||
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
@@ -151,6 +231,8 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
|
||||
+17
-5
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
|
||||
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
|
||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
├── data/ # SQLite、附件和更新前备份
|
||||
├── mail/ # Maildir 邮件原文
|
||||
└── dkim/ # DKIM 私钥
|
||||
├── dkim/ # DKIM 私钥
|
||||
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
+8
-8
@@ -3,9 +3,14 @@ module lanqin-email-api
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.38.0
|
||||
modernc.org/sqlite v1.31.1
|
||||
)
|
||||
@@ -13,19 +18,14 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // 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/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
+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-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -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=
|
||||
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
@@ -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-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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
|
||||
@@ -52,7 +52,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
@@ -108,14 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = loginName
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
@@ -125,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
if role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
@@ -137,8 +142,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -155,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -183,6 +188,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
@@ -211,7 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
if existing.Role == "admin" && role != "admin" {
|
||||
badRequest(w, errors.New("唯一管理员不能降级"))
|
||||
return
|
||||
}
|
||||
if existing.Role != "admin" && role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
primaryEmail := existing.Email
|
||||
loginName := existing.LoginName
|
||||
if strings.TrimSpace(emailInput) != "" {
|
||||
primaryEmail, err = cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
loginName = primaryEmail
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
@@ -223,6 +252,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" && disabled {
|
||||
badRequest(w, errors.New("唯一管理员不能停用"))
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||
if req.MailboxLimitOverride != nil {
|
||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
@@ -278,8 +311,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -293,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" {
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.AdminEmail = primaryEmail
|
||||
})
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
@@ -320,8 +362,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -538,8 +580,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
role := req.Role
|
||||
@@ -551,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
@@ -593,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
|
||||
ownerEmailInput := req.OwnerEmail
|
||||
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||
ownerEmailInput = req.OwnerLoginName
|
||||
}
|
||||
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -607,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
userID = newID("usr")
|
||||
ownerDisplayName := displayName
|
||||
if !strings.EqualFold(ownerLoginName, address) {
|
||||
ownerDisplayName = ownerLoginName
|
||||
if !strings.EqualFold(ownerEmail, address) {
|
||||
ownerDisplayName = ownerEmail
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -706,26 +749,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if current != nil && owner == current.ID {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -741,12 +767,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
|
||||
+451
-105
@@ -23,15 +23,39 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
turnstileURL string
|
||||
telegramURL string
|
||||
telegramPairMu sync.Mutex
|
||||
telegramPairs map[string]telegramPairing
|
||||
telegramDeliveryMu sync.Mutex
|
||||
}
|
||||
|
||||
func (a *App) config() Config {
|
||||
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) {
|
||||
@@ -51,7 +75,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
@@ -73,16 +97,29 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) != "" {
|
||||
a.startWorker(func() { a.maildirWorker(workerCtx) })
|
||||
}
|
||||
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -166,6 +203,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, code_hash)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -272,6 +317,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT 'folder',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -401,6 +447,20 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS telegram_mail_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL UNIQUE,
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
lease_until TEXT NOT NULL DEFAULT '',
|
||||
telegram_message_id INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
||||
AFTER DELETE ON mailboxes BEGIN
|
||||
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
||||
@@ -631,6 +691,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -643,12 +706,117 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateTelegramNotifications(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateTelegramNotifications(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "lease_until", `ALTER TABLE telegram_mail_outbox ADD COLUMN lease_until TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "telegram_message_id", `ALTER TABLE telegram_mail_outbox ADD COLUMN telegram_message_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateDefaultMailLabels(ctx context.Context) error {
|
||||
const marker = "defaultMailLabelsInitialized"
|
||||
var initialized int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized > 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mailboxIDs []string
|
||||
for rows.Next() {
|
||||
var mailboxID string
|
||||
if err := rows.Scan(&mailboxID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
mailboxIDs = append(mailboxIDs, mailboxID)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, mailboxID := range mailboxIDs {
|
||||
if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var mailboxSettingExists int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramMailboxIds'`).Scan(&mailboxSettingExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if mailboxSettingExists == 0 {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM mailboxes m JOIN users u ON u.id=m.user_id WHERE u.role='admin' AND m.status='active' ORDER BY m.address`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mailboxIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
mailboxIDs = append(mailboxIDs, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramMailboxIds',?,?)`, strings.Join(mailboxIDs, ","), now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var includeSettingExists int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramIncludeUnregistered'`).Scan(&includeSettingExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if includeSettingExists == 0 {
|
||||
var enabled string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='telegramMailEnabled'`).Scan(&enabled)
|
||||
includeUnregistered := "false"
|
||||
if strings.EqualFold(enabled, "true") {
|
||||
includeUnregistered = "true"
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramIncludeUnregistered',?,?)`, includeUnregistered, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateForwardingVerification(ctx context.Context) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
@@ -920,81 +1088,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
|
||||
// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
|
||||
// Administrators now use a real mailbox as their primary login address, so old
|
||||
// bootstrap mailboxes must be preserved and normalized by the admin identity
|
||||
// migration instead of deleted.
|
||||
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.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
|
||||
}
|
||||
|
||||
@@ -1387,15 +1485,20 @@ func messageIndexes() []string {
|
||||
}
|
||||
|
||||
func (a *App) seed(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||
if err != nil {
|
||||
return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation")
|
||||
}
|
||||
|
||||
adminPassword := a.cfg.AdminPassword
|
||||
adminPassword := cfg.AdminPassword
|
||||
if adminPassword == "" {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
@@ -1410,13 +1513,8 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return errors.New("invalid admin email")
|
||||
}
|
||||
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||
@@ -1451,12 +1549,221 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
|
||||
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=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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, 1024, "active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxCreated = true
|
||||
} else if mailboxUserID != keeper.ID {
|
||||
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
|
||||
}
|
||||
result := map[string]any{
|
||||
"adminUserId": keeper.ID,
|
||||
"adminEmail": adminEmail,
|
||||
"emailSource": emailSource,
|
||||
"previousEmail": keeper.Email,
|
||||
"demotedAdmins": demoted,
|
||||
"mailboxId": mailboxID,
|
||||
"mailboxCreated": mailboxCreated,
|
||||
"migratedAt": now,
|
||||
}
|
||||
raw, _ := json.Marshal(result)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.updateConfig(func(current *Config) {
|
||||
current.AdminEmail = adminEmail
|
||||
if current.MailDomain == "" {
|
||||
current.MailDomain = domainName
|
||||
}
|
||||
})
|
||||
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
|
||||
// Once initialized, the database identity is authoritative. This keeps an
|
||||
// administrator email changed in the UI from reverting to the installer value.
|
||||
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
|
||||
return email, "existing_admin_email", nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.AdminEmail) != "" {
|
||||
email, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
|
||||
}
|
||||
return email, "configured_admin_email", nil
|
||||
}
|
||||
|
||||
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = normalizeLocalPart(loginName)
|
||||
}
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = "admin"
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for rows.Next() {
|
||||
var address string
|
||||
if err := rows.Scan(&address); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if email, err := cleanPrimaryEmail(address); err == nil {
|
||||
rows.Close()
|
||||
return email, "existing_admin_mailbox", nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
|
||||
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
|
||||
}
|
||||
var onlyDomain string
|
||||
var domainCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if domainCount == 1 && validMailDomain(onlyDomain) {
|
||||
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
|
||||
}
|
||||
publicDomain := normalizeDomain(cfg.PublicHostname)
|
||||
if strings.HasPrefix(publicDomain, "mail.") {
|
||||
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
|
||||
}
|
||||
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
|
||||
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
|
||||
}
|
||||
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
|
||||
}
|
||||
|
||||
func validMailDomain(domain string) bool {
|
||||
domain = normalizeDomain(domain)
|
||||
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
|
||||
}
|
||||
|
||||
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1511,6 +1818,30 @@ func defaultFolderDefs() []struct{ name, role string } {
|
||||
}
|
||||
}
|
||||
|
||||
type defaultMailLabel struct {
|
||||
name string
|
||||
color string
|
||||
}
|
||||
|
||||
func defaultMailLabelDefs() []defaultMailLabel {
|
||||
return []defaultMailLabel{
|
||||
{name: "个人", color: "#10b981"},
|
||||
{name: "家人", color: "#ec4899"},
|
||||
{name: "朋友", color: "#06b6d4"},
|
||||
{name: "工作", color: "#3b82f6"},
|
||||
{name: "重要", color: "#f59e0b"},
|
||||
}
|
||||
}
|
||||
|
||||
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
|
||||
for _, label := range defaultMailLabelDefs() {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -1568,24 +1899,39 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
cfg := a.config()
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC()
|
||||
systemDomain := normalizeDomain(cfg.MailDomain)
|
||||
if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") {
|
||||
systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1])
|
||||
}
|
||||
if systemDomain == "" {
|
||||
systemDomain = normalizeDomain(cfg.PublicHostname)
|
||||
}
|
||||
if systemDomain == "" {
|
||||
systemDomain = "lanqin.local"
|
||||
}
|
||||
systemAddress := "system@" + systemDomain
|
||||
subject := "欢迎使用 NewSzxcn 邮箱"
|
||||
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
||||
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
|
||||
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
|
||||
rendered := renderMailTemplate(tpl, templateRenderData{
|
||||
To: a.cfg.AdminEmail,
|
||||
From: "system@lanqin.local",
|
||||
PublicHostname: a.cfg.PublicHostname,
|
||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||
To: cfg.AdminEmail,
|
||||
From: systemAddress,
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Time: now,
|
||||
})
|
||||
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
|
||||
@@ -1594,11 +1940,11 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
MailboxID: mailboxID,
|
||||
FolderID: folderID,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
|
||||
MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
|
||||
Subject: subject,
|
||||
From: "system@lanqin.local",
|
||||
From: systemAddress,
|
||||
FromName: "NewSzxcn 邮箱",
|
||||
To: []string{a.cfg.AdminEmail},
|
||||
To: []string{cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
ReceivedAt: now,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
|
||||
+1142
-109
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
||||
respondError(w, http.StatusUnauthorized, "验证码错误")
|
||||
return
|
||||
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
|
||||
if consumeErr != nil || !ok {
|
||||
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
@@ -50,12 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
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 {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||
if err != nil || user.Disabled {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
@@ -64,7 +72,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
|
||||
@@ -81,7 +89,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.OpenRegistration {
|
||||
if !a.config().OpenRegistration {
|
||||
respondError(w, http.StatusForbidden, "当前未开放注册")
|
||||
return
|
||||
}
|
||||
@@ -101,23 +109,54 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
email, err := cleanPrimaryEmail(req.Email)
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.Split(email, "@")[0]
|
||||
badRequest(w, errors.New("请输入显示名称"))
|
||||
return
|
||||
}
|
||||
if len([]rune(displayName)) > 80 {
|
||||
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
|
||||
if mailboxLocalPart == "" {
|
||||
mailboxLocalPart = normalizeLocalPart(parts[0])
|
||||
}
|
||||
mailboxDomainID := strings.TrimSpace(req.DomainID)
|
||||
var mailboxDomain string
|
||||
if mailboxDomainID != "" {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
badRequest(w, errors.New("所选邮箱域名不可用"))
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
|
||||
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
|
||||
return
|
||||
}
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
if item == mailboxLocalPart {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
return
|
||||
@@ -132,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
@@ -141,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -150,46 +207,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a mailbox for the registered user
|
||||
var mailboxDomainID string
|
||||
var mailboxLocalPart string
|
||||
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
|
||||
// User selected a specific domain and local part
|
||||
mailboxDomainID = strings.TrimSpace(req.DomainID)
|
||||
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
|
||||
} else {
|
||||
// Auto-detect: use the first active domain and email local part
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
|
||||
mailboxDomainID = ""
|
||||
}
|
||||
if mailboxDomainID != "" {
|
||||
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
|
||||
}
|
||||
}
|
||||
if mailboxDomainID != "" && mailboxLocalPart != "" {
|
||||
// Check reserved prefixes
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[mailboxLocalPart] {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
|
||||
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
|
||||
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -199,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
@@ -239,8 +268,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
||||
|
||||
@@ -14,7 +14,9 @@ type Config struct {
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
MailDomain string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
@@ -50,6 +52,12 @@ type Config struct {
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
TelegramMailEnabled bool
|
||||
TelegramBotToken string
|
||||
TelegramPrivateChatID string
|
||||
TelegramBodyMode string
|
||||
TelegramMailboxIDs string
|
||||
TelegramIncludeUnregistered bool
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
DeliveryWebhookSecret string
|
||||
@@ -70,7 +78,9 @@ func LoadConfig() Config {
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
|
||||
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
@@ -106,6 +116,12 @@ func LoadConfig() Config {
|
||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
||||
TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false),
|
||||
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
|
||||
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")),
|
||||
TelegramMailboxIDs: getenv("LANQIN_TELEGRAM_MAILBOX_IDS", ""),
|
||||
TelegramIncludeUnregistered: getenvBool("LANQIN_TELEGRAM_INCLUDE_UNREGISTERED", false),
|
||||
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
|
||||
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
|
||||
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
|
||||
|
||||
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
|
||||
name := strings.TrimSuffix(d.Name, ".")
|
||||
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
|
||||
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
|
||||
return []DNSRecord{
|
||||
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
|
||||
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
|
||||
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
for _, item := range mx {
|
||||
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
|
||||
found = append(found, entry)
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
|
||||
dkimName := d.DKIMSelector + "._domainkey." + d.Name
|
||||
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
|
||||
checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录")
|
||||
checks["dkim"] = checkDKIMRecord(dkimTXT, d.DKIMPublicKey)
|
||||
|
||||
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
|
||||
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
|
||||
@@ -85,6 +85,42 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks}
|
||||
}
|
||||
|
||||
func checkDKIMRecord(records []string, expectedPublicKey string) DNSCheckStatus {
|
||||
found := append([]string{}, records...)
|
||||
expectedPublicKey = compactDKIMPublicKey(expectedPublicKey)
|
||||
dkimFound := false
|
||||
for _, record := range records {
|
||||
tags := map[string]string{}
|
||||
for _, part := range strings.Split(record, ";") {
|
||||
key, value, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
|
||||
}
|
||||
if !strings.EqualFold(tags["v"], "DKIM1") {
|
||||
continue
|
||||
}
|
||||
dkimFound = true
|
||||
if expectedPublicKey != "" && compactDKIMPublicKey(tags["p"]) == expectedPublicKey {
|
||||
return DNSCheckStatus{OK: true, Message: "DKIM 公钥匹配", Found: found}
|
||||
}
|
||||
}
|
||||
if dkimFound {
|
||||
return DNSCheckStatus{OK: false, Message: "DKIM 公钥与后台生成的记录不一致", Found: found}
|
||||
}
|
||||
return DNSCheckStatus{OK: false, Message: "未找到 DKIM 记录", Found: found}
|
||||
}
|
||||
|
||||
func compactDKIMPublicKey(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
|
||||
found := append([]string{}, records...)
|
||||
for _, item := range records {
|
||||
|
||||
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
|
||||
if !a.cfg.ExternalIMAPEnabled {
|
||||
if !a.config().ExternalIMAPEnabled {
|
||||
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)
|
||||
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
|
||||
|
||||
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
|
||||
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
|
||||
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) {
|
||||
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
|
||||
}
|
||||
|
||||
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
|
||||
if a.cfg.ExternalIMAPAllowPrivateHosts {
|
||||
if a.config().ExternalIMAPAllowPrivateHosts {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPKey() ([]byte, error) {
|
||||
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
|
||||
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
|
||||
if secret == "" {
|
||||
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) {
|
||||
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 {
|
||||
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 &oauth2.Config{
|
||||
ClientID: a.cfg.ExternalIMAPGmailClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
|
||||
ClientID: a.config().ExternalIMAPGmailClientID,
|
||||
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
|
||||
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
|
||||
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 &oauth2.Config{
|
||||
ClientID: a.cfg.ExternalIMAPOutlookClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
|
||||
ClientID: a.config().ExternalIMAPOutlookClientID,
|
||||
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||||
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 {
|
||||
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
|
||||
}
|
||||
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
|
||||
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
|
||||
}
|
||||
imported++
|
||||
} else {
|
||||
skipped++
|
||||
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
|
||||
}
|
||||
|
||||
type externalIMAPFolderState struct {
|
||||
LastUID uint32
|
||||
LastUID uint32
|
||||
Initialized bool
|
||||
}
|
||||
|
||||
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
|
||||
var state externalIMAPFolderState
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
|
||||
state.Initialized = true
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -1376,15 +1382,6 @@ func safeExternalEMLFilename(subject string) string {
|
||||
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 {
|
||||
now := time.Now().UTC()
|
||||
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)
|
||||
return
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
@@ -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)
|
||||
return nil
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
|
||||
if len(targets) == 0 {
|
||||
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
||||
}
|
||||
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||||
if len(targets) == 0 {
|
||||
return nil, userID, mailboxAddress, nil
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -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) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return "", errors.New("SMTP 未配置,无法发送验证邮件")
|
||||
}
|
||||
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 {
|
||||
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
|
||||
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
|
||||
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)
|
||||
}
|
||||
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
|
||||
title := "邮箱转发验证"
|
||||
heading := "验证失败"
|
||||
color := "#dc2626"
|
||||
statusMark := "!"
|
||||
closingMessage := "请联系验证发起人重新发送链接"
|
||||
if ok {
|
||||
heading = "验证完成"
|
||||
color = "#2563eb"
|
||||
color = "#16a34a"
|
||||
statusMark = "✓"
|
||||
closingMessage = "验证结果已记录,可以关闭此页面"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
|
||||
title, heading, htmlEscape(message), htmlEscape(email), color)
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:8px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><div aria-hidden="true" style="display:grid;place-items:center;width:44px;height:44px;margin:0 0 20px;border-radius:50%%;background:%s;color:white;font-size:24px;font-weight:700">%s</div><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 24px;font-size:15px;color:#64748b;word-break:break-all">%s</p><p style="margin:0;padding-top:20px;border-top:1px solid #e2e8f0;font-size:15px;color:#64748b">%s</p></section></main></body></html>`,
|
||||
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
|
||||
}
|
||||
|
||||
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
||||
|
||||
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateFolderIcons(ctx context.Context) error {
|
||||
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
|
||||
}
|
||||
|
||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
@@ -232,25 +236,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
|
||||
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) {
|
||||
if folderID == "" {
|
||||
var dbFolderID sql.NullString
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -98,12 +100,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
ORDER BY CASE
|
||||
WHEN lower(f.name)='inbox' THEN 1000
|
||||
WHEN lower(f.name)='sent' THEN 5000
|
||||
@@ -121,7 +123,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -132,7 +134,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||
@@ -158,7 +160,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -264,13 +266,9 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -285,7 +283,49 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("system folder already exists"))
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
return
|
||||
@@ -299,16 +339,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if folderID == "" {
|
||||
badRequest(w, errors.New("folder id is required"))
|
||||
return
|
||||
}
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
a.handleDeleteAllMailFolders(w, r, folderID)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var folderName string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -378,8 +422,197 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
||||
return a.ensureFolder(ctx, mailboxID, name)
|
||||
func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
|
||||
folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
|
||||
if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
|
||||
folderName = strings.TrimSpace(folderID[4:])
|
||||
}
|
||||
name, err := normalizeCustomFolderName(folderName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if isSystemFolderName(name) {
|
||||
badRequest(w, errors.New("system folders cannot be deleted"))
|
||||
return
|
||||
}
|
||||
user := currentUser(r)
|
||||
type folderTarget struct {
|
||||
folderID string
|
||||
mailboxID string
|
||||
inboxID string
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||
return
|
||||
}
|
||||
targets := []folderTarget{}
|
||||
for rows.Next() {
|
||||
var target folderTarget
|
||||
if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
rows.Close()
|
||||
if len(targets) == 0 {
|
||||
respondError(w, http.StatusNotFound, "folder not found")
|
||||
return
|
||||
}
|
||||
for i := range targets {
|
||||
targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load inbox")
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
moved := 0
|
||||
for _, target := range targets {
|
||||
messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for messageRows.Next() {
|
||||
var messageID string
|
||||
if err := messageRows.Scan(&messageID); err != nil {
|
||||
messageRows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := messageRows.Err(); err != nil {
|
||||
messageRows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
messageRows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
|
||||
return
|
||||
}
|
||||
moved++
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
for _, target := range targets {
|
||||
_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, 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) {
|
||||
@@ -397,11 +630,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
|
||||
return
|
||||
}
|
||||
folder := r.URL.Query().Get("folder")
|
||||
@@ -971,7 +1205,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
for _, rcpt := range localRecipients {
|
||||
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
|
||||
if err != nil {
|
||||
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||
continue
|
||||
}
|
||||
copyMsg := base
|
||||
@@ -982,11 +1216,12 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rcptMailbox.Status != "active" {
|
||||
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = ""
|
||||
copyMsg.FolderID = ""
|
||||
@@ -995,6 +1230,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -1006,12 +1242,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = rcptMailbox.ID
|
||||
copyMsg.FolderID = inboxID
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
||||
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
|
||||
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2007,15 +2247,83 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folder, err := normalizeFolderNameForUser(req.Folder)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(req.IDs))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range req.IDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
badRequest(w, errors.New("请选择要移动的邮件"))
|
||||
return
|
||||
}
|
||||
type itemResult struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
results := make([]itemResult, 0, len(ids))
|
||||
folderByMailbox := map[string]string{}
|
||||
moved := 0
|
||||
for _, id := range ids {
|
||||
msg, err := a.loadMessageForRequest(r, id, false)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
|
||||
continue
|
||||
}
|
||||
folderID := folderByMailbox[msg.MailboxID]
|
||||
if folderID == "" {
|
||||
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
|
||||
continue
|
||||
}
|
||||
folderByMailbox[msg.MailboxID] = folderID
|
||||
}
|
||||
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
|
||||
}
|
||||
failed := len(results) - moved
|
||||
message := fmt.Sprintf("已移动 %d 封邮件", moved)
|
||||
if failed > 0 {
|
||||
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
|
||||
}
|
||||
|
||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||
var f MailFolder
|
||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
@@ -2345,7 +2653,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -2382,7 +2690,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE l.mailbox_id=?
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, mailboxID)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2399,13 +2707,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
}
|
||||
|
||||
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
|
||||
FROM mail_labels l
|
||||
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE mb.user_id=? AND mb.status='active'
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, userID)
|
||||
GROUP BY lower(l.name)
|
||||
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2425,7 +2733,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
||||
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE ml.message_id=?
|
||||
ORDER BY lower(l.name)`, messageID)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2456,7 +2764,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
|
||||
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
|
||||
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
|
||||
ORDER BY lower(l.name)`, args...)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2474,6 +2782,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func mailLabelOrderSQL(alias string) string {
|
||||
return mailLabelNameOrderSQL(alias + `.name`)
|
||||
}
|
||||
|
||||
func mailLabelNameOrderSQL(expression string) string {
|
||||
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
|
||||
}
|
||||
|
||||
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
|
||||
name = normalizeLabelName(name)
|
||||
if name == "" {
|
||||
@@ -2517,6 +2833,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
|
||||
var name string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if len([]rune(name)) > 32 {
|
||||
@@ -2550,7 +2874,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
||||
_ = 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) {
|
||||
|
||||
@@ -13,13 +13,20 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxMailImportBytes int64 = 256 << 20
|
||||
|
||||
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
const maxSelectedMailExport = 200
|
||||
|
||||
type exportedMessage struct {
|
||||
raw []byte
|
||||
subject string
|
||||
date time.Time
|
||||
}
|
||||
|
||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := a.exportMessageIDs(r)
|
||||
@@ -43,19 +50,21 @@ func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := make(map[string]int, len(ids))
|
||||
for index, id := range ids {
|
||||
raw, subject, err := a.rawMessageForExport(r.Context(), id)
|
||||
for _, id := range ids {
|
||||
message, err := a.rawMessageForExport(r.Context(), id)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
|
||||
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
|
||||
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
|
||||
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
|
||||
header.SetModTime(message.date)
|
||||
entry, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(raw); err != nil {
|
||||
if _, err := entry.Write(message.raw); err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
@@ -74,6 +83,10 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
selectedIDs, err := selectedExportMessageIDs(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if view == "unknown" {
|
||||
if user.Role != "admin" {
|
||||
@@ -109,12 +122,29 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
if isAllMailboxID(mailboxID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))")
|
||||
args = append(args, labelName)
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
}
|
||||
if len(selectedIDs) > 0 {
|
||||
placeholders := make([]string, 0, len(selectedIDs))
|
||||
for _, id := range selectedIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
|
||||
if err != nil {
|
||||
@@ -132,40 +162,77 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
|
||||
func selectedExportMessageIDs(r *http.Request) ([]string, error) {
|
||||
values := r.URL.Query()["messageId"]
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) > maxSelectedMailExport {
|
||||
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
|
||||
msg, err := a.storedMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
exportDate := msg.ReceivedAt
|
||||
if exportDate.IsZero() {
|
||||
exportDate = messageDate(msg)
|
||||
}
|
||||
if msg.RawPath != "" {
|
||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||
return raw, msg.Subject, nil
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
||||
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||
Date: messageDate(msg), Attachments: attachments,
|
||||
})
|
||||
return raw, msg.Subject, err
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
|
||||
}
|
||||
|
||||
func exportMessageFilename(subject, id string, index int) string {
|
||||
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
|
||||
name = strings.Trim(name, ".-_")
|
||||
func exportMessageFilename(subject string, date time.Time) string {
|
||||
name := strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(subject))
|
||||
name = strings.Trim(name, " .-_")
|
||||
if name == "" {
|
||||
name = "message"
|
||||
name = "无主题"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
runes := []rune(name)
|
||||
if len(runes) > 80 {
|
||||
name = string(runes[:80])
|
||||
}
|
||||
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
|
||||
if date.IsZero() {
|
||||
return name + ".eml"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
|
||||
}
|
||||
|
||||
func uniqueExportFilename(name string, used map[string]int) string {
|
||||
@@ -216,7 +283,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
imported, skipped := 0, 0
|
||||
problems := []string{}
|
||||
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||
if maxMessageBytes <= 0 {
|
||||
maxMessageBytes = 35 * 1024 * 1024
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
stdmail "net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseMBOXMultipleMessages(t *testing.T) {
|
||||
@@ -65,22 +69,27 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
|
||||
var imported struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
|
||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "中文标题" || list.Items[1].Subject != "older imported message" {
|
||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC)
|
||||
if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
|
||||
if status != http.StatusOK {
|
||||
@@ -90,18 +99,50 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 1 {
|
||||
if len(zr.File) != 2 {
|
||||
t.Fatalf("zip entries=%d", len(zr.File))
|
||||
}
|
||||
if zr.File[0].Name != "中文标题 (20240103).eml" {
|
||||
t.Fatalf("first filename=%q", zr.File[0].Name)
|
||||
}
|
||||
wantModified := receivedAt
|
||||
if !zr.File[0].Modified.Equal(wantModified) {
|
||||
t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified)
|
||||
}
|
||||
entry, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported, err := io.ReadAll(entry)
|
||||
entry.Close()
|
||||
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
|
||||
if err != nil {
|
||||
t.Fatalf("exported message err=%v raw=%q", err, exported)
|
||||
}
|
||||
parsed, err := stdmail.ReadMessage(bytes.NewReader(exported))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
|
||||
if err != nil || decodedSubject != "中文标题" {
|
||||
t.Fatalf("decoded subject=%q err=%v", decodedSubject, err)
|
||||
}
|
||||
messageDate, err := parsed.Header.Date()
|
||||
if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("message date=%s err=%v", messageDate, err)
|
||||
}
|
||||
|
||||
selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID)
|
||||
status, selectedArchive := getMailExport(t, owner, selectedPath)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("selected export status=%d body=%q", status, selectedArchive)
|
||||
}
|
||||
selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" {
|
||||
t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File))
|
||||
}
|
||||
|
||||
var denied map[string]any
|
||||
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
||||
@@ -113,6 +154,64 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||
}
|
||||
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil)
|
||||
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
|
||||
owner := &testClient{t: t, server: ts}
|
||||
other := &testClient{t: t, server: ts}
|
||||
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login=%d", code)
|
||||
}
|
||||
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
|
||||
var imported map[string]any
|
||||
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
|
||||
t.Fatalf("other import=%d response=%v", code, imported)
|
||||
}
|
||||
var otherList struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
|
||||
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
|
||||
}
|
||||
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
|
||||
status, archive := getMailExport(t, owner, path)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cross-owner export status=%d body=%q", status, archive)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 0 {
|
||||
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
|
||||
}
|
||||
}
|
||||
|
||||
func exportEntryNames(files []*zip.File) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
|
||||
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.cfg.MailTranslateMaxChars
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(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")
|
||||
return
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.cfg.MailTranslateMaxChars
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var mailboxes struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
|
||||
t.Fatalf("delete final mailbox code=%d", code)
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
|
||||
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
var me map[string]any
|
||||
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("account was not preserved code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@ type parsedMail struct {
|
||||
}
|
||||
|
||||
func (a *App) maildirWorker(ctx context.Context) {
|
||||
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
|
||||
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
nextRunAt := a.now().UTC()
|
||||
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 {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||
if root == "" {
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.CatchAllEnabled {
|
||||
if a.config().CatchAllEnabled {
|
||||
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
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) {
|
||||
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{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
@@ -297,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err == nil {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
@@ -338,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
if strings.TrimSpace(msg.RecipientAddr) == "" {
|
||||
msg.RecipientAddr = mb.Address
|
||||
}
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
@@ -372,6 +373,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
||||
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
||||
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
||||
if a.shouldNotifyTelegramMessage(ctx, id) {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -394,16 +398,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
||||
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) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var samePathID, oldFolderID string
|
||||
@@ -514,7 +508,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
@@ -600,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
|
||||
return address
|
||||
}
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
address = normalizeEmail(address)
|
||||
if strings.HasSuffix(address, "@"+domain) {
|
||||
@@ -624,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
if len(to) == 0 {
|
||||
to = []string{fallbackTo}
|
||||
}
|
||||
recipientAddr := originalMailRecipient(m.Header)
|
||||
sentAt := parseMailDate(m.Header.Get("Date"))
|
||||
parsed := &parsedMail{}
|
||||
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
||||
return storedMessage{}, nil, err
|
||||
}
|
||||
if looksLikeHTMLDocument(parsed.Text) {
|
||||
if strings.TrimSpace(parsed.HTML) == "" {
|
||||
parsed.HTML = parsed.Text
|
||||
}
|
||||
parsed.Text = telegramHTMLToText(parsed.Text)
|
||||
}
|
||||
bodyHTML := a.policy.Sanitize(parsed.HTML)
|
||||
bodyText := parsed.Text
|
||||
if strings.TrimSpace(bodyText) == "" {
|
||||
@@ -644,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
RecipientAddr: recipientAddr,
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
@@ -659,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
func originalMailRecipient(header netmail.Header) string {
|
||||
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
|
||||
value := strings.TrimSpace(header.Get(key))
|
||||
if key == "Original-Recipient" {
|
||||
if _, suffix, ok := strings.Cut(value, ";"); ok {
|
||||
value = strings.TrimSpace(suffix)
|
||||
}
|
||||
}
|
||||
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
|
||||
return address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
|
||||
contentType := header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
@@ -697,6 +717,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
|
||||
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
|
||||
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
|
||||
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
|
||||
if converted, readErr := io.ReadAll(reader); readErr == nil {
|
||||
decoded = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
decoded = []byte(strings.ToValidUTF8(string(decoded), "�"))
|
||||
}
|
||||
switch strings.ToLower(mediaType) {
|
||||
case "text/html":
|
||||
if parsed.HTML == "" {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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 {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
||||
if err != nil {
|
||||
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)
|
||||
subdir := "cur"
|
||||
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 {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
||||
if err != nil {
|
||||
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)
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
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 {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
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) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
|
||||
}
|
||||
|
||||
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) == "" {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
|
||||
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
|
||||
if secret == "" {
|
||||
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
|
||||
return
|
||||
|
||||
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
@@ -318,26 +318,9 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
current := currentUser(r)
|
||||
if current != nil && owner == current.ID {
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -353,11 +336,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -371,8 +354,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
@@ -826,7 +809,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
||||
return "", errors.New("invalid owner email")
|
||||
}
|
||||
var existing string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -923,15 +906,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
if intBool(existingSystem) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
|
||||
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||
|
||||
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"},
|
||||
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
||||
|
||||
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"},
|
||||
|
||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
{
|
||||
ID: PermissionGroupSuperAdmin,
|
||||
Name: "管理员",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
@@ -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 {
|
||||
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||
}
|
||||
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func permissionGroupNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = group.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range defaultPermissionGroups() {
|
||||
@@ -1054,11 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
return u != nil && u.Role == "admin"
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
|
||||
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, MailboxApplyOptions{
|
||||
Enabled: a.cfg.UserMailboxApplyEnabled,
|
||||
Enabled: a.config().UserMailboxApplyEnabled,
|
||||
Domains: domains,
|
||||
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
|
||||
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.UserMailboxApplyEnabled {
|
||||
if !a.config().UserMailboxApplyEnabled {
|
||||
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
|
||||
return
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[localPart] {
|
||||
@@ -129,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
|
||||
if !a.cfg.UserMailboxApplyEnabled {
|
||||
if !a.config().UserMailboxApplyEnabled {
|
||||
return []Domain{}, nil
|
||||
}
|
||||
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
|
||||
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
|
||||
if len(ids) == 0 {
|
||||
return []Domain{}, nil
|
||||
}
|
||||
@@ -534,12 +534,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
appliedCount := int64(0)
|
||||
if req.ApplyToExisting && enabled {
|
||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
if req.ApplyToExisting {
|
||||
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||
return
|
||||
}
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||
item, err := scanRule(row)
|
||||
@@ -1330,7 +1334,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||
continue
|
||||
}
|
||||
if rule.StopProcessing {
|
||||
break
|
||||
}
|
||||
@@ -1375,7 +1381,7 @@ type ruleMessage struct {
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
@@ -1407,7 +1413,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
parts = append(parts, filename)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -1453,15 +1459,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
if !validRuleConditionOperator(field, operator) {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func validRuleConditionOperator(field, operator string) bool {
|
||||
switch field {
|
||||
case "size":
|
||||
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||
case "date":
|
||||
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||
default:
|
||||
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
@@ -1710,23 +1724,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := normalizeFolderNameForUser(action.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderID string
|
||||
if isSystemFolderName(target) {
|
||||
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||
} else {
|
||||
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "star":
|
||||
starred := true
|
||||
@@ -1789,21 +1817,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
||||
return err
|
||||
}
|
||||
|
||||
func ruleTargetFolder(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "inbox":
|
||||
return "Inbox"
|
||||
case "archive":
|
||||
return "Archive"
|
||||
case "spam":
|
||||
return "Spam"
|
||||
case "trash":
|
||||
return "Trash"
|
||||
default:
|
||||
return "Archive"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||
args := []any{userID}
|
||||
where := `mb.user_id=?`
|
||||
@@ -1811,7 +1824,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
@@ -170,6 +171,9 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
@@ -215,7 +219,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
|
||||
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -273,7 +277,7 @@ func currentUser(r *http.Request) *User {
|
||||
}
|
||||
|
||||
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
cookie, err := r.Cookie(a.cfg.CookieName)
|
||||
cookie, err := r.Cookie(a.config().CookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, errors.New("no session")
|
||||
}
|
||||
@@ -344,10 +348,9 @@ func bearerToken(r *http.Request) string {
|
||||
}
|
||||
|
||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
loginName := normalizeLoginName(email)
|
||||
email = normalizeEmail(email)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
||||
FROM users WHERE login_name=? OR email=?
|
||||
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
|
||||
FROM users WHERE email=? LIMIT 1`, email)
|
||||
var u User
|
||||
var passwordHash string
|
||||
var disabled, twoFactorEnabled int
|
||||
|
||||
@@ -67,7 +67,7 @@ type sendQueueItem struct {
|
||||
}
|
||||
|
||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return "", nil
|
||||
}
|
||||
now := in.Now.UTC()
|
||||
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
||||
if safeID == "" || safeID == "." {
|
||||
safeID = "unknown"
|
||||
}
|
||||
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
}
|
||||
|
||||
func (a *App) writeSendQueueDeliveredMarker(id string) error {
|
||||
|
||||
@@ -8,20 +8,20 @@ import (
|
||||
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Name: a.config().CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
Secure: !a.config().AllowInsecureHTTP,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ type SystemSettings struct {
|
||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type systemSettingsUpdate struct {
|
||||
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
|
||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotToken string `json:"telegramBotToken"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
@@ -100,15 +112,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := a.cfg.MailRefreshSeconds
|
||||
cfg := a.config()
|
||||
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := cfg.MailRefreshSeconds
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = 30
|
||||
}
|
||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, 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
|
||||
if a.cfg.OpenRegistration {
|
||||
if cfg.OpenRegistration {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
@@ -126,12 +139,14 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
var req systemSettingsUpdate
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next := a.cfg
|
||||
next := a.config()
|
||||
next.PublicHostname = normalizeHostname(req.PublicHostname)
|
||||
if next.PublicHostname == "" {
|
||||
badRequest(w, errors.New("publicHostname is required"))
|
||||
@@ -203,12 +218,36 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||
return
|
||||
}
|
||||
next.TelegramMailEnabled = req.TelegramMailEnabled
|
||||
if strings.TrimSpace(req.TelegramBotToken) != "" {
|
||||
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
|
||||
}
|
||||
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
|
||||
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
|
||||
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
|
||||
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
|
||||
if next.TelegramMailEnabled {
|
||||
if next.TelegramBotToken == "" {
|
||||
badRequest(w, errors.New("Telegram Bot Token 未设置"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
|
||||
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
|
||||
return
|
||||
}
|
||||
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
|
||||
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||
previous := a.config()
|
||||
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
|
||||
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||
return
|
||||
}
|
||||
a.cfg = next
|
||||
a.setConfig(next)
|
||||
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
|
||||
}
|
||||
|
||||
@@ -218,7 +257,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.cfg
|
||||
cfg := a.config()
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
badRequest(w, errors.New("SMTP 主机未设置"))
|
||||
return
|
||||
@@ -285,41 +324,49 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
cfg := a.config()
|
||||
return SystemSettings{
|
||||
PublicHostname: a.cfg.PublicHostname,
|
||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||
SMTPHost: a.cfg.SMTPHost,
|
||||
SMTPPort: a.cfg.SMTPPort,
|
||||
SMTPUsername: a.cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
||||
MaildirRoot: a.cfg.MaildirRoot,
|
||||
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: a.cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: a.cfg.OpenRegistration,
|
||||
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
|
||||
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
|
||||
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
|
||||
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
|
||||
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
|
||||
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUsername: cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: cfg.SMTPRequireTLS,
|
||||
MaildirRoot: cfg.MaildirRoot,
|
||||
MaildirScanSeconds: cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: cfg.OpenRegistration,
|
||||
TwoFactorEnabled: cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
|
||||
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
|
||||
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
|
||||
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
|
||||
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
|
||||
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
TelegramMailEnabled: cfg.TelegramMailEnabled,
|
||||
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
|
||||
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
|
||||
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -332,79 +379,95 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
}
|
||||
switch key {
|
||||
case "publicHostname":
|
||||
a.cfg.PublicHostname = value
|
||||
cfg.PublicHostname = value
|
||||
case "publicBaseUrl":
|
||||
a.cfg.PublicBaseURL = value
|
||||
cfg.PublicBaseURL = value
|
||||
case "smtpHost":
|
||||
a.cfg.SMTPHost = value
|
||||
cfg.SMTPHost = value
|
||||
case "smtpPort":
|
||||
a.cfg.SMTPPort = value
|
||||
cfg.SMTPPort = value
|
||||
case "smtpUsername":
|
||||
a.cfg.SMTPUsername = value
|
||||
cfg.SMTPUsername = value
|
||||
case "smtpPassword":
|
||||
a.cfg.SMTPPassword = value
|
||||
cfg.SMTPPassword = value
|
||||
case "smtpRequireTls":
|
||||
a.cfg.SMTPRequireTLS = value == "true"
|
||||
cfg.SMTPRequireTLS = value == "true"
|
||||
case "maildirRoot":
|
||||
a.cfg.MaildirRoot = value
|
||||
cfg.MaildirRoot = value
|
||||
case "maildirScanSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MaildirScanSeconds = n
|
||||
cfg.MaildirScanSeconds = n
|
||||
}
|
||||
case "sessionTtlHours":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.SessionTTLHours = n
|
||||
cfg.SessionTTLHours = n
|
||||
}
|
||||
case "allowInsecureHttp":
|
||||
a.cfg.AllowInsecureHTTP = value == "true"
|
||||
cfg.AllowInsecureHTTP = value == "true"
|
||||
case "openRegistration":
|
||||
a.cfg.OpenRegistration = value == "true"
|
||||
cfg.OpenRegistration = value == "true"
|
||||
case "twoFactorEnabled":
|
||||
a.cfg.TwoFactorEnabled = value == "true"
|
||||
cfg.TwoFactorEnabled = value == "true"
|
||||
case "turnstileEnabled":
|
||||
a.cfg.TurnstileEnabled = value == "true"
|
||||
cfg.TurnstileEnabled = value == "true"
|
||||
case "turnstileSiteKey":
|
||||
a.cfg.TurnstileSiteKey = value
|
||||
cfg.TurnstileSiteKey = value
|
||||
case "turnstileSecretKey":
|
||||
a.cfg.TurnstileSecretKey = value
|
||||
cfg.TurnstileSecretKey = value
|
||||
case "catchAllEnabled":
|
||||
a.cfg.CatchAllEnabled = value == "true"
|
||||
cfg.CatchAllEnabled = value == "true"
|
||||
case "mailAutoRefresh":
|
||||
a.cfg.MailAutoRefresh = value == "true"
|
||||
cfg.MailAutoRefresh = value == "true"
|
||||
case "mailRefreshSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MailRefreshSeconds = n
|
||||
cfg.MailRefreshSeconds = n
|
||||
}
|
||||
case "userMailboxApplyEnabled":
|
||||
a.cfg.UserMailboxApplyEnabled = value == "true"
|
||||
cfg.UserMailboxApplyEnabled = value == "true"
|
||||
case "userMailboxDomainIds":
|
||||
a.cfg.UserMailboxDomainIDs = value
|
||||
cfg.UserMailboxDomainIDs = value
|
||||
case "reservedMailboxPrefixes":
|
||||
a.cfg.ReservedMailboxPrefixes = value
|
||||
cfg.ReservedMailboxPrefixes = value
|
||||
case "externalImapEnabled":
|
||||
a.cfg.ExternalIMAPEnabled = value == "true"
|
||||
cfg.ExternalIMAPEnabled = value == "true"
|
||||
case "externalImapSecretKey":
|
||||
a.cfg.ExternalIMAPSecretKey = value
|
||||
cfg.ExternalIMAPSecretKey = value
|
||||
case "externalImapSyncSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.ExternalIMAPSyncSeconds = n
|
||||
cfg.ExternalIMAPSyncSeconds = n
|
||||
}
|
||||
case "externalImapAllowPrivateHosts":
|
||||
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||
case "externalImapGmailClientId":
|
||||
a.cfg.ExternalIMAPGmailClientID = value
|
||||
cfg.ExternalIMAPGmailClientID = value
|
||||
case "externalImapGmailClientSecret":
|
||||
a.cfg.ExternalIMAPGmailClientSecret = value
|
||||
cfg.ExternalIMAPGmailClientSecret = value
|
||||
case "externalImapOutlookClientId":
|
||||
a.cfg.ExternalIMAPOutlookClientID = value
|
||||
cfg.ExternalIMAPOutlookClientID = value
|
||||
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{
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
@@ -436,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
||||
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
|
||||
"telegramBotToken": cfg.TelegramBotToken,
|
||||
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
|
||||
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
|
||||
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
@@ -449,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if clearPendingTelegram {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
||||
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
||||
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
|
||||
return nil
|
||||
}
|
||||
_, _ = 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
|
||||
}
|
||||
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(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) {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
|
||||
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
|
||||
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 != "" {
|
||||
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")
|
||||
}
|
||||
if !a.cfg.StatusWebhookAllowPrivateHosts {
|
||||
if !a.config().StatusWebhookAllowPrivateHosts {
|
||||
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.StatusWebhookAllowPrivateHosts {
|
||||
if a.config().StatusWebhookAllowPrivateHosts {
|
||||
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
|
||||
}
|
||||
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 {
|
||||
return &SubmissionServers{
|
||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
|
||||
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
|
||||
}
|
||||
s := smtpserver.NewServer(submissionBackend{app: a})
|
||||
s.Addr = addr
|
||||
s.Domain = a.cfg.PublicHostname
|
||||
s.Domain = a.config().PublicHostname
|
||||
s.TLSConfig = tlsConfig
|
||||
s.AllowInsecureAuth = false
|
||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.ReadTimeout = smtpSessionTimeout
|
||||
s.WriteTimeout = smtpSessionTimeout
|
||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||
@@ -121,21 +121,59 @@ type submissionSession struct {
|
||||
}
|
||||
|
||||
func (s *submissionSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain}
|
||||
return []string{sasl.Plain, sasl.Login}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||
if !strings.EqualFold(mech, sasl.Plain) {
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
authenticate := func(username, password string) error {
|
||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||
if err != nil {
|
||||
return smtpserver.ErrAuthFailed
|
||||
}
|
||||
s.user, s.mailbox = user, mailbox
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(mech, sasl.Plain):
|
||||
return sasl.NewPlainServer(func(_, username, password string) error {
|
||||
return authenticate(username, password)
|
||||
}), nil
|
||||
case strings.EqualFold(mech, sasl.Login):
|
||||
return &submissionLoginServer{authenticate: authenticate}, nil
|
||||
default:
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
}
|
||||
|
||||
type submissionLoginServer struct {
|
||||
authenticate func(username, password string) error
|
||||
username string
|
||||
step int
|
||||
}
|
||||
|
||||
func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) {
|
||||
switch s.step {
|
||||
case 0:
|
||||
if response == nil {
|
||||
s.step = 1
|
||||
return []byte("Username:"), false, nil
|
||||
}
|
||||
s.username = string(response)
|
||||
s.step = 2
|
||||
return []byte("Password:"), false, nil
|
||||
case 1:
|
||||
s.username = string(response)
|
||||
s.step = 2
|
||||
return []byte("Password:"), false, nil
|
||||
case 2:
|
||||
if err := s.authenticate(s.username, string(response)); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
s.step = 3
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, sasl.ErrUnexpectedClientResponse
|
||||
}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||
|
||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
||||
a.log.Error("trigger system update", "error", err)
|
||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
||||
return
|
||||
}
|
||||
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
@@ -94,10 +88,11 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
current := strings.TrimSpace(a.cfg.AppVersion)
|
||||
current := strings.TrimSpace(a.config().AppVersion)
|
||||
if current == "" {
|
||||
current = BuildVersion
|
||||
}
|
||||
@@ -124,7 +119,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, 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)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return githubRelease{}, errors.New("invalid release API URL")
|
||||
@@ -134,7 +129,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
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{
|
||||
Timeout: 8 * time.Second,
|
||||
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 {
|
||||
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 {
|
||||
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") {
|
||||
return errors.New("invalid update service URL")
|
||||
}
|
||||
@@ -173,9 +168,9 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
if err != nil {
|
||||
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{
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: 10 * time.Minute,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -192,8 +187,20 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||
go func() {
|
||||
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := a.triggerUpdateService(ctx); err != nil {
|
||||
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.cfg.DataDir, "backups")
|
||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
defer releaseServer.Close()
|
||||
|
||||
var updateRequests atomic.Int32
|
||||
updateStarted := make(chan struct{}, 1)
|
||||
releaseUpdate := make(chan struct{})
|
||||
var releaseUpdateOnce sync.Once
|
||||
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||
defer releaseBlockedUpdate()
|
||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("update method = %s", r.Method)
|
||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
updateRequests.Add(1)
|
||||
updateStarted <- struct{}{}
|
||||
<-releaseUpdate
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer updateServer.Close()
|
||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Fatalf("unexpected version response: %+v", version)
|
||||
}
|
||||
|
||||
var update map[string]any
|
||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
||||
t.Fatalf("update code=%d response=%v", code, update)
|
||||
type updateResponse struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
if updateRequests.Load() != 1 {
|
||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
||||
response := make(chan updateResponse, 1)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
req.AddCookie(admin.cookie)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
response <- updateResponse{code: resp.StatusCode}
|
||||
}()
|
||||
select {
|
||||
case result := <-response:
|
||||
if result.err != nil || result.code != http.StatusAccepted {
|
||||
t.Fatalf("update response=%+v", result)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
releaseBlockedUpdate()
|
||||
t.Fatal("update response waited for container replacement")
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduled update request did not start")
|
||||
}
|
||||
releaseBlockedUpdate()
|
||||
if got := updateRequests.Load(); got != 1 {
|
||||
t.Fatalf("update requests=%d", got)
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||
if err != nil || len(backups) != 1 {
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
nethtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
telegramMailMaxAttempts = 8
|
||||
telegramMessageBudget = 3800
|
||||
telegramPairingTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
type telegramPairing struct {
|
||||
TokenFingerprint string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type telegramMailPayload struct {
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
Recipient string `json:"recipient"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
Body string `json:"body"`
|
||||
BodyMode string `json:"bodyMode"`
|
||||
OTP string `json:"otp,omitempty"`
|
||||
AttachmentNames []string `json:"attachmentNames,omitempty"`
|
||||
AttachmentCount int `json:"attachmentCount,omitempty"`
|
||||
}
|
||||
|
||||
type telegramCredentialsRequest struct {
|
||||
BotToken string `json:"botToken"`
|
||||
ChatID string `json:"chatId"`
|
||||
PairingCode string `json:"pairingCode"`
|
||||
}
|
||||
|
||||
type telegramAPIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Parameters struct {
|
||||
RetryAfter int `json:"retry_after"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
|
||||
type telegramUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *struct {
|
||||
Text string `json:"text"`
|
||||
Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
} `json:"chat"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
type telegramAPIError struct {
|
||||
HTTPStatus int
|
||||
ErrorCode int
|
||||
Description string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *telegramAPIError) Error() string {
|
||||
description := strings.TrimSpace(e.Description)
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("HTTP %d", e.HTTPStatus)
|
||||
}
|
||||
return "Telegram 发送失败: " + description
|
||||
}
|
||||
|
||||
type telegramSentMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
|
||||
type telegramFormattedMessage struct {
|
||||
HTML string
|
||||
PlainText string
|
||||
OTP string
|
||||
}
|
||||
|
||||
func normalizeTelegramBodyMode(value string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "full") {
|
||||
return "full"
|
||||
}
|
||||
return "summary"
|
||||
}
|
||||
|
||||
func validTelegramPrivateChatID(value string) bool {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return err == nil && id > 0
|
||||
}
|
||||
|
||||
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(a.config().TelegramBotToken)
|
||||
}
|
||||
if token == "" {
|
||||
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
|
||||
return
|
||||
}
|
||||
var bot struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := a.callTelegram(r.Context(), token, "getMe", map[string]any{}, &bot); err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(bot.Username) == "" {
|
||||
respondError(w, http.StatusBadGateway, "Telegram 机器人没有可用的用户名")
|
||||
return
|
||||
}
|
||||
code, err := newTelegramPairingCode()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "无法生成 Telegram 绑定码")
|
||||
return
|
||||
}
|
||||
expiresAt := a.now().UTC().Add(telegramPairingTTL)
|
||||
a.telegramPairMu.Lock()
|
||||
for value, pairing := range a.telegramPairs {
|
||||
if !pairing.ExpiresAt.After(a.now().UTC()) {
|
||||
delete(a.telegramPairs, value)
|
||||
}
|
||||
}
|
||||
a.telegramPairs[code] = telegramPairing{TokenFingerprint: telegramTokenFingerprint(token), ExpiresAt: expiresAt}
|
||||
a.telegramPairMu.Unlock()
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"code": code,
|
||||
"botUsername": bot.Username,
|
||||
"deepLink": "https://t.me/" + url.PathEscape(bot.Username) + "?start=" + url.QueryEscape(code),
|
||||
"expiresAt": expiresAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(a.config().TelegramBotToken)
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
|
||||
if token == "" || code == "" {
|
||||
badRequest(w, errors.New("请先生成 Telegram 一次性绑定码"))
|
||||
return
|
||||
}
|
||||
a.telegramPairMu.Lock()
|
||||
pairing, ok := a.telegramPairs[code]
|
||||
a.telegramPairMu.Unlock()
|
||||
if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
|
||||
badRequest(w, errors.New("Telegram 绑定码无效或已过期,请重新生成"))
|
||||
return
|
||||
}
|
||||
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token, code)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
a.telegramPairMu.Lock()
|
||||
delete(a.telegramPairs, code)
|
||||
a.telegramPairMu.Unlock()
|
||||
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName})
|
||||
}
|
||||
|
||||
func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token, chatID := a.telegramCredentials(req)
|
||||
if token == "" {
|
||||
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(chatID) {
|
||||
badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID"))
|
||||
return
|
||||
}
|
||||
now := a.now().Local().Format("2006-01-02 15:04:05 MST")
|
||||
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html.EscapeString(now)
|
||||
if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) {
|
||||
cfg := a.config()
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(cfg.TelegramBotToken)
|
||||
}
|
||||
chatID := strings.TrimSpace(req.ChatID)
|
||||
if chatID == "" {
|
||||
chatID = strings.TrimSpace(cfg.TelegramPrivateChatID)
|
||||
}
|
||||
return token, chatID
|
||||
}
|
||||
|
||||
func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCode string) (string, string, error) {
|
||||
var updates []telegramUpdate
|
||||
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
|
||||
"limit": 100,
|
||||
"timeout": 0,
|
||||
"allowed_updates": []string{"message"},
|
||||
}, &updates); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for i := len(updates) - 1; i >= 0; i-- {
|
||||
message := updates[i].Message
|
||||
if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(message.Text)
|
||||
if text != pairingCode && text != "/start "+pairingCode {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
|
||||
if name == "" && message.Chat.Username != "" {
|
||||
name = "@" + message.Chat.Username
|
||||
}
|
||||
return strconv.FormatInt(message.Chat.ID, 10), name, nil
|
||||
}
|
||||
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
||||
}
|
||||
|
||||
func newTelegramPairingCode() (string, error) {
|
||||
raw := make([]byte, 6)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(raw)), nil
|
||||
}
|
||||
|
||||
func telegramTokenFingerprint(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func telegramMailboxAllowed(cfg Config, mailboxID string) bool {
|
||||
mailboxID = strings.TrimSpace(mailboxID)
|
||||
if mailboxID == "" {
|
||||
return cfg.TelegramIncludeUnregistered
|
||||
}
|
||||
for _, id := range cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")) {
|
||||
if id == mailboxID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) activeTelegramMailboxIDs(ctx context.Context, values []string) []string {
|
||||
ids := cleanIDList(values)
|
||||
active := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
var exists int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT 1 FROM mailboxes WHERE id=? AND status='active'`, id).Scan(&exists); err == nil && exists == 1 {
|
||||
active = append(active, id)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
|
||||
cfg := a.config()
|
||||
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) || !telegramMailboxAllowed(cfg, msg.MailboxID) {
|
||||
return
|
||||
}
|
||||
recipient := normalizeEmail(msg.RecipientAddr)
|
||||
if recipient == "" && len(msg.To) > 0 {
|
||||
recipient = normalizeEmail(msg.To[0])
|
||||
}
|
||||
body := telegramMessageBody(msg)
|
||||
otp := detectTelegramOTP(msg.Subject, body)
|
||||
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
|
||||
limit := 800
|
||||
if mode == "full" {
|
||||
limit = 2600
|
||||
}
|
||||
body, truncated := truncateRunes(body, limit)
|
||||
if truncated {
|
||||
body += "..."
|
||||
}
|
||||
if body == "" {
|
||||
body = normalizeTelegramText(msg.Snippet)
|
||||
}
|
||||
from, _ := truncateRunes(strings.TrimSpace(msg.From), 254)
|
||||
fromName, _ := truncateRunes(strings.TrimSpace(msg.FromName), 160)
|
||||
subject, _ := truncateRunes(strings.TrimSpace(msg.Subject), 240)
|
||||
names := make([]string, 0, min(len(attachments), 5))
|
||||
for _, attachment := range attachments {
|
||||
name := sanitizeTelegramAttachmentName(attachment.Filename)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
payload := telegramMailPayload{
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
Recipient: recipient,
|
||||
Subject: subject,
|
||||
ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
|
||||
Body: body,
|
||||
BodyMode: mode,
|
||||
OTP: otp,
|
||||
AttachmentNames: names,
|
||||
AttachmentCount: len(attachments),
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil {
|
||||
a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func telegramMessageBody(msg storedMessage) string {
|
||||
text := strings.TrimSpace(msg.BodyText)
|
||||
text, _ = truncateRunes(text, 128*1024)
|
||||
if text != "" && looksLikeHTMLDocument(text) {
|
||||
text = telegramHTMLToText(text)
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
text = telegramHTMLToText(msg.BodyHTML)
|
||||
}
|
||||
return stripTelegramQuotedContent(normalizeTelegramText(text))
|
||||
}
|
||||
|
||||
func looksLikeHTMLDocument(value string) bool {
|
||||
value, _ = truncateRunes(value, 128*1024)
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if strings.HasPrefix(value, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
|
||||
return true
|
||||
}
|
||||
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
|
||||
return len(matches) >= 3
|
||||
}
|
||||
|
||||
func telegramHTMLToText(value string) string {
|
||||
value = strings.ToValidUTF8(value, "�")
|
||||
value, _ = truncateRunes(value, 128*1024)
|
||||
doc, err := nethtml.Parse(strings.NewReader(value))
|
||||
if err != nil {
|
||||
return stripTags(value)
|
||||
}
|
||||
var out strings.Builder
|
||||
var walk func(*nethtml.Node, bool)
|
||||
walk = func(node *nethtml.Node, skipped bool) {
|
||||
if node.Type == nethtml.ElementNode {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "script", "style", "head", "noscript", "svg":
|
||||
skipped = true
|
||||
case "br":
|
||||
if !skipped {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Type == nethtml.TextNode && !skipped {
|
||||
out.WriteString(node.Data)
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, skipped)
|
||||
}
|
||||
if node.Type == nethtml.ElementNode && !skipped {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc, false)
|
||||
return normalizeTelegramText(out.String())
|
||||
}
|
||||
|
||||
func normalizeTelegramText(value string) string {
|
||||
value = strings.ReplaceAll(strings.ToValidUTF8(value, "�"), "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
lines := strings.Split(value, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
empty := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
if r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, line))
|
||||
line = strings.Join(strings.Fields(line), " ")
|
||||
if line == "" {
|
||||
if !empty && len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
empty = true
|
||||
continue
|
||||
}
|
||||
empty = false
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[::]|from[::].+|_{5,})$`)
|
||||
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
|
||||
|
||||
func stripTelegramQuotedContent(value string) string {
|
||||
lines := strings.Split(value, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
|
||||
lines = lines[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func sanitizeTelegramAttachmentName(value string) string {
|
||||
value = strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.ToValidUTF8(value, "�")))
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
value, truncated := truncateRunes(value, 100)
|
||||
if truncated {
|
||||
value += "..."
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
var (
|
||||
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
|
||||
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
|
||||
telegramEmailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||
telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||
)
|
||||
|
||||
func detectTelegramOTP(subject, body string) string {
|
||||
text := normalizeTelegramText(strings.TrimSpace(subject) + "\n" + body)
|
||||
keywords := telegramOTPKeywordRe.FindAllStringIndex(text, -1)
|
||||
if len(keywords) == 0 {
|
||||
return ""
|
||||
}
|
||||
type candidateScore struct {
|
||||
value string
|
||||
score int
|
||||
count int
|
||||
}
|
||||
scores := map[string]candidateScore{}
|
||||
subjectEnd := len(strings.TrimSpace(subject))
|
||||
excludedRanges := append(telegramEmailRe.FindAllStringIndex(text, -1), telegramURLRe.FindAllStringIndex(text, -1)...)
|
||||
for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) {
|
||||
if telegramRangeOverlaps(match, excludedRanges) {
|
||||
continue
|
||||
}
|
||||
if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) {
|
||||
continue
|
||||
}
|
||||
if match[1] < len(text) && isTelegramOTPAlphaNumeric(rune(text[match[1]])) {
|
||||
continue
|
||||
}
|
||||
value := strings.ToUpper(text[match[0]:match[1]])
|
||||
hasDigit := false
|
||||
for _, r := range value {
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
|
||||
continue
|
||||
}
|
||||
if isTelegramOTPNonCode(value) {
|
||||
continue
|
||||
}
|
||||
best := 0
|
||||
for _, keyword := range keywords {
|
||||
distance := match[0] - keyword[1]
|
||||
if distance < 0 {
|
||||
distance = keyword[0] - match[1]
|
||||
}
|
||||
if distance < 0 {
|
||||
distance = 0
|
||||
}
|
||||
score := 0
|
||||
switch {
|
||||
case distance <= 16:
|
||||
score = 100
|
||||
case distance <= 48:
|
||||
score = 80
|
||||
case distance <= 100:
|
||||
score = 55
|
||||
}
|
||||
if match[0] <= subjectEnd {
|
||||
score += 15
|
||||
}
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
}
|
||||
if best == 0 {
|
||||
continue
|
||||
}
|
||||
current := scores[value]
|
||||
current.value = value
|
||||
current.count++
|
||||
if best > current.score {
|
||||
current.score = best
|
||||
}
|
||||
scores[value] = current
|
||||
}
|
||||
items := make([]candidateScore, 0, len(scores))
|
||||
for _, item := range scores {
|
||||
item.score += min(item.count-1, 2) * 5
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
|
||||
if len(items) == 0 || items[0].score < 55 {
|
||||
return ""
|
||||
}
|
||||
if len(items) > 1 && items[1].score >= items[0].score-25 {
|
||||
return ""
|
||||
}
|
||||
return items[0].value
|
||||
}
|
||||
|
||||
func telegramRangeOverlaps(candidate []int, ranges [][]int) bool {
|
||||
for _, item := range ranges {
|
||||
if len(item) == 2 && candidate[0] < item[1] && candidate[1] > item[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTelegramOTPNonCode(value string) bool {
|
||||
if len(value) == 4 {
|
||||
if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(value) == 8 {
|
||||
if _, err := time.Parse("20060102", value); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTelegramOTPAlphaNumeric(r rune) bool {
|
||||
return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r))
|
||||
}
|
||||
|
||||
func (a *App) shouldNotifyTelegramMessage(ctx context.Context, messageID string) bool {
|
||||
var folder string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT lower(COALESCE(NULLIF(f.role,''),f.name,'')) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, messageID).Scan(&folder); err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(folder) {
|
||||
case "spam", "junk", "trash", "deleted":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) telegramMailWorker(ctx context.Context) {
|
||||
a.log.Info("Telegram mail notification worker started")
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.log.Warn("Telegram mail notification worker failed", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("Telegram mail notification worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
|
||||
cfg := a.config()
|
||||
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
|
||||
return nil
|
||||
}
|
||||
nowText := a.now().UTC().Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type queueItem struct {
|
||||
id string
|
||||
payload telegramMailPayload
|
||||
attempt int
|
||||
invalid bool
|
||||
}
|
||||
items := []queueItem{}
|
||||
for rows.Next() {
|
||||
var item queueItem
|
||||
var raw string
|
||||
if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
|
||||
item.invalid = true
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.invalid {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
now := a.now().UTC()
|
||||
leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
|
||||
result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
continue
|
||||
}
|
||||
formatted := formatTelegramMailMessage(item.payload)
|
||||
telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
|
||||
now = a.now().UTC()
|
||||
if err != nil {
|
||||
attempts := item.attempt + 1
|
||||
delay := sendRetryDelay(attempts)
|
||||
var apiErr *telegramAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.RetryAfter > 0 {
|
||||
delay = apiErr.RetryAfter
|
||||
}
|
||||
code := apiErr.ErrorCode
|
||||
if code == 0 {
|
||||
code = apiErr.HTTPStatus
|
||||
}
|
||||
if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
|
||||
attempts = telegramMailMaxAttempts
|
||||
}
|
||||
}
|
||||
next := now.Add(delay)
|
||||
if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
stamp := now.Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=?,lease_until='',telegram_message_id=?,payload_json='{}' WHERE id=? AND delivered_at IS NULL`, stamp, stamp, telegramMessageID, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
|
||||
subject := strings.TrimSpace(payload.Subject)
|
||||
if subject == "" || subject == "(no subject)" {
|
||||
subject = "(无主题)"
|
||||
}
|
||||
from := strings.TrimSpace(payload.From)
|
||||
if name := strings.TrimSpace(payload.FromName); name != "" {
|
||||
from = name + " <" + from + ">"
|
||||
}
|
||||
receivedAt := parseTime(payload.ReceivedAt)
|
||||
timeText := strings.TrimSpace(payload.ReceivedAt)
|
||||
if !receivedAt.IsZero() {
|
||||
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
subject, _ = truncateRunes(subject, 180)
|
||||
from, _ = truncateRunes(from, 220)
|
||||
recipient, _ := truncateRunes(strings.TrimSpace(payload.Recipient), 160)
|
||||
lines := []string{
|
||||
"📩 <b>新邮件通知</b>",
|
||||
"",
|
||||
"<b>主题:</b>" + escapeTelegramWithinBudget(subject, 420),
|
||||
"<b>发件人:</b>" + escapeTelegramWithinBudget(from, 500),
|
||||
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
|
||||
"<b>收件时间:</b>" + html.EscapeString(timeText),
|
||||
}
|
||||
if payload.OTP != "" {
|
||||
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
|
||||
}
|
||||
if len(payload.AttachmentNames) > 0 {
|
||||
names := make([]string, 0, len(payload.AttachmentNames))
|
||||
for _, name := range payload.AttachmentNames {
|
||||
names = append(names, escapeTelegramWithinBudget(name, 180))
|
||||
}
|
||||
attachmentText := strings.Join(names, "、")
|
||||
if payload.AttachmentCount > len(payload.AttachmentNames) {
|
||||
attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames))
|
||||
}
|
||||
lines = append(lines, "", fmt.Sprintf("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
|
||||
}
|
||||
body := strings.TrimSpace(payload.Body)
|
||||
if body != "" {
|
||||
label := "正文摘要"
|
||||
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
|
||||
label = "邮件正文"
|
||||
}
|
||||
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
|
||||
suffix := "</blockquote>"
|
||||
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
|
||||
lines = []string{prefix + body + suffix}
|
||||
}
|
||||
htmlText := strings.Join(lines, "\n")
|
||||
plain := formatTelegramMailPlainText(payload)
|
||||
return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP}
|
||||
}
|
||||
|
||||
func formatTelegramBodyHTML(value string, budget int) string {
|
||||
if budget <= 3 {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
used := 0
|
||||
truncated := false
|
||||
appendEscaped := func(text string) bool {
|
||||
for _, r := range text {
|
||||
escaped := html.EscapeString(string(r))
|
||||
length := utf8.RuneCountInString(escaped)
|
||||
if used+length > budget-3 {
|
||||
return false
|
||||
}
|
||||
out.WriteString(escaped)
|
||||
used += length
|
||||
}
|
||||
return true
|
||||
}
|
||||
last := 0
|
||||
for _, match := range telegramURLRe.FindAllStringIndex(value, -1) {
|
||||
if !appendEscaped(value[last:match[0]]) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
rawURL, trailing := trimTelegramURL(value[match[0]:match[1]])
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
if !appendEscaped(value[match[0]:match[1]]) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
last = match[1]
|
||||
continue
|
||||
}
|
||||
display := rawURL
|
||||
if utf8.RuneCountInString(display) > 72 {
|
||||
display = "🔗 " + parsed.Hostname() + " 链接"
|
||||
}
|
||||
anchor := `<a href="` + html.EscapeString(rawURL) + `">` + html.EscapeString(display) + `</a>`
|
||||
length := utf8.RuneCountInString(anchor)
|
||||
if used+length > budget-3 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out.WriteString(anchor)
|
||||
used += length
|
||||
if !appendEscaped(trailing) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
last = match[1]
|
||||
}
|
||||
if !truncated && last < len(value) && !appendEscaped(value[last:]) {
|
||||
truncated = true
|
||||
}
|
||||
if truncated {
|
||||
out.WriteString("...")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func trimTelegramURL(value string) (string, string) {
|
||||
trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】")
|
||||
return trimmed, value[len(trimmed):]
|
||||
}
|
||||
|
||||
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
|
||||
_, err := a.sendTelegramPayload(ctx, token, map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) deliverTelegramMailMessage(ctx context.Context, token, chatID string, message telegramFormattedMessage) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": message.HTML,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup != nil {
|
||||
payload["reply_markup"] = markup
|
||||
}
|
||||
result, err := a.sendTelegramPayload(ctx, token, payload)
|
||||
if err == nil {
|
||||
return result.MessageID, nil
|
||||
}
|
||||
var apiErr *telegramAPIError
|
||||
if !errors.As(err, &apiErr) || apiErr.ErrorCode != http.StatusBadRequest {
|
||||
return 0, err
|
||||
}
|
||||
fallback := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": message.PlainText,
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup != nil {
|
||||
fallback["reply_markup"] = markup
|
||||
}
|
||||
result, err = a.sendTelegramPayload(ctx, token, fallback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.MessageID, nil
|
||||
}
|
||||
|
||||
func (a *App) sendTelegramPayload(ctx context.Context, token string, payload map[string]any) (telegramSentMessage, error) {
|
||||
var result telegramSentMessage
|
||||
err := a.callTelegram(ctx, token, "sendMessage", payload, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func telegramCopyMarkup(otp string) map[string]any {
|
||||
otp = strings.TrimSpace(otp)
|
||||
if otp == "" || utf8.RuneCountInString(otp) > 256 {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"inline_keyboard": [][]map[string]any{{{
|
||||
"text": "复制验证码",
|
||||
"copy_text": map[string]string{"text": otp},
|
||||
}}}}
|
||||
}
|
||||
|
||||
func escapeTelegramWithinBudget(value string, budget int) string {
|
||||
if budget <= 3 {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
used := 0
|
||||
truncated := false
|
||||
for _, r := range value {
|
||||
escaped := html.EscapeString(string(r))
|
||||
length := utf8.RuneCountInString(escaped)
|
||||
if used+length > budget-3 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out.WriteString(escaped)
|
||||
used += length
|
||||
}
|
||||
if truncated {
|
||||
out.WriteString("...")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatTelegramMailPlainText(payload telegramMailPayload) string {
|
||||
subject := strings.TrimSpace(payload.Subject)
|
||||
if subject == "" || subject == "(no subject)" {
|
||||
subject = "(无主题)"
|
||||
}
|
||||
from := strings.TrimSpace(payload.From)
|
||||
if name := strings.TrimSpace(payload.FromName); name != "" {
|
||||
from = name + " <" + from + ">"
|
||||
}
|
||||
receivedAt := parseTime(payload.ReceivedAt)
|
||||
timeText := strings.TrimSpace(payload.ReceivedAt)
|
||||
if !receivedAt.IsZero() {
|
||||
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
parts := []string{"新邮件通知", "", "主题:" + subject, "发件人:" + from, "收件邮箱:" + payload.Recipient, "收件时间:" + timeText}
|
||||
if payload.OTP != "" {
|
||||
parts = append(parts, "", "验证码", payload.OTP)
|
||||
}
|
||||
if payload.AttachmentCount > 0 {
|
||||
parts = append(parts, "", fmt.Sprintf("附件:%d 个", payload.AttachmentCount))
|
||||
}
|
||||
if body := strings.TrimSpace(payload.Body); body != "" {
|
||||
parts = append(parts, "", "正文摘要", body)
|
||||
}
|
||||
text := normalizeTelegramText(strings.Join(parts, "\n"))
|
||||
text, truncated := truncateRunes(text, telegramMessageBudget-3)
|
||||
if truncated {
|
||||
text += "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || strings.ContainsAny(token, "/\\\r\n") {
|
||||
return errors.New("Telegram Bot Token 无效")
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/")
|
||||
endpoint := base + "/bot" + url.PathEscape(token) + "/" + method
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0")
|
||||
client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("Telegram 请求失败,请检查网络连接和机器人配置")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var apiResponse telegramAPIResponse
|
||||
if err := json.Unmarshal(raw, &apiResponse); err != nil {
|
||||
return fmt.Errorf("Telegram 返回了无效响应(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK {
|
||||
description := strings.TrimSpace(apiResponse.Description)
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return &telegramAPIError{HTTPStatus: resp.StatusCode, ErrorCode: apiResponse.ErrorCode, Description: description, RetryAfter: time.Duration(apiResponse.Parameters.RetryAfter) * time.Second}
|
||||
}
|
||||
if result != nil && len(apiResponse.Result) > 0 {
|
||||
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
|
||||
type sentMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ReplyMarkup map[string]any `json:"reply_markup"`
|
||||
}
|
||||
var sent []sentMessage
|
||||
var pairingCode atomic.Value
|
||||
pairingCode.Store("")
|
||||
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/bottest-token/getMe":
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
|
||||
case "/bottest-token/getUpdates":
|
||||
code, _ := pairingCode.Load().(string)
|
||||
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
|
||||
case "/bottest-token/sendMessage":
|
||||
var message sentMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
t.Fatalf("decode Telegram message: %v", err)
|
||||
}
|
||||
sent = append(sent, message)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer telegramServer.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = telegramServer.URL
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
payload["telegramBotToken"] = "test-token"
|
||||
payload["telegramPrivateChatId"] = "123456789"
|
||||
payload["telegramBodyMode"] = "full"
|
||||
var adminMailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["telegramMailboxIds"] = []string{adminMailboxID}
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
|
||||
}
|
||||
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
|
||||
t.Fatalf("unexpected Telegram settings: %+v", settings)
|
||||
}
|
||||
if a.config().TelegramBotToken != "test-token" {
|
||||
t.Fatal("Telegram token was not persisted in runtime config")
|
||||
}
|
||||
|
||||
var pairing struct {
|
||||
Code string `json:"code"`
|
||||
DeepLink string `json:"deepLink"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
|
||||
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
|
||||
}
|
||||
pairingCode.Store(pairing.Code)
|
||||
var discovered map[string]string
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
|
||||
t.Fatalf("discover chat code=%d response=%v", code, discovered)
|
||||
}
|
||||
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
|
||||
t.Fatalf("unexpected discovered chat: %v", discovered)
|
||||
}
|
||||
var testResult map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
|
||||
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
|
||||
}
|
||||
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
|
||||
t.Fatalf("unexpected Telegram test message: %+v", sent)
|
||||
}
|
||||
|
||||
sent = nil
|
||||
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
|
||||
MailboxID: adminMailboxID,
|
||||
RecipientAddr: "admin@example.com",
|
||||
Subject: "账单 <已生成>",
|
||||
From: "billing@example.net",
|
||||
FromName: "Billing & Support",
|
||||
ReceivedAt: receivedAt,
|
||||
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
|
||||
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatalf("process Telegram mail queue: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
|
||||
}
|
||||
text := sent[0].Text
|
||||
for _, expected := range []string{"新邮件通知", "Billing & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "<VIP> & 续费信息"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
|
||||
}
|
||||
}
|
||||
if sent[0].ReplyMarkup == nil {
|
||||
t.Fatal("Telegram OTP copy button was not included")
|
||||
}
|
||||
var delivered, storedPayload string
|
||||
var telegramMessageID int64
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
|
||||
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
|
||||
}
|
||||
if storedPayload != "{}" || telegramMessageID != 8 {
|
||||
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
|
||||
}
|
||||
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
|
||||
disablePayload := systemSettingsPayload(settings)
|
||||
disablePayload["telegramMailEnabled"] = false
|
||||
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("disable Telegram settings code=%d", code)
|
||||
}
|
||||
var pending int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
|
||||
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
var body map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
|
||||
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = serverURL
|
||||
const token = "123456:secret-token-value"
|
||||
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected Telegram network request to fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
|
||||
t.Fatalf("Telegram error exposed Bot Token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
|
||||
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
|
||||
cleaned := stripTelegramQuotedContent(body)
|
||||
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
|
||||
t.Fatalf("unexpected OTP %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
|
||||
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: strings.Repeat("R&D <team@example.com> ", 30),
|
||||
Recipient: "admin@example.com",
|
||||
Subject: strings.Repeat("超长主题 & <test> ", 50),
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: strings.Repeat("正文内容 & <重要> ", 1000),
|
||||
BodyMode: "full",
|
||||
OTP: "846981",
|
||||
AttachmentNames: []string{
|
||||
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
|
||||
},
|
||||
AttachmentCount: 12,
|
||||
})
|
||||
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
|
||||
t.Fatalf("Telegram HTML exceeds budget: %d", got)
|
||||
}
|
||||
if !strings.Contains(message.HTML, "&") || !strings.Contains(message.HTML, "<") || !strings.Contains(message.HTML, "<code>846981</code>") {
|
||||
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup == nil {
|
||||
t.Fatal("copy_text markup missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIQiyiOTPDetection(t *testing.T) {
|
||||
subject := "825534 是您的动态安全验证码"
|
||||
body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved"
|
||||
otp := detectTelegramOTP(subject, body)
|
||||
if otp != "825534" {
|
||||
t.Fatalf("iQiyi OTP not detected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp})
|
||||
if !strings.Contains(message.HTML, "<code>825534</code>") || telegramCopyMarkup(message.OTP) == nil {
|
||||
t.Fatalf("iQiyi OTP section or copy button missing: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
|
||||
body := `---------- Forwarded message ---------
|
||||
Date: 2026年8月6日周四 17:59
|
||||
Subject: 登录验证码 (https://www.gate.com)
|
||||
|
||||
Gate 检测到您的账号正试图从此 IP 获得登录验证码:
|
||||
IP: 87.83.105.229
|
||||
如为您本人登录,请输入如下验证码完成操作:
|
||||
311665
|
||||
如非本人操作,请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
|
||||
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
|
||||
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
|
||||
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
|
||||
})
|
||||
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
|
||||
t.Fatalf("normal URL was not linkified: %s", message.HTML)
|
||||
}
|
||||
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
|
||||
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
|
||||
}
|
||||
if strings.Contains(message.HTML, "<a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
|
||||
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
|
||||
}
|
||||
markup := telegramCopyMarkup(message.OTP)
|
||||
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
|
||||
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
|
||||
t.Fatalf("copy OTP button missing: %#v", markup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
|
||||
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
|
||||
text := telegramMessageBody(storedMessage{BodyText: pseudo})
|
||||
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
|
||||
t.Fatalf("pseudo HTML was not cleaned: %q", text)
|
||||
}
|
||||
|
||||
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
|
||||
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
retryAfter time.Duration
|
||||
}{
|
||||
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
|
||||
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
|
||||
var apiErr *telegramAPIError
|
||||
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
|
||||
t.Fatalf("unexpected Telegram error: %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
cfg.TelegramMailboxIDs = mailboxID
|
||||
})
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
|
||||
var count int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.RecipientAddr != "admin@lanqin.local" {
|
||||
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
|
||||
return
|
||||
}
|
||||
if _, exists := payload["parse_mode"]; exists {
|
||||
t.Fatal("plain-text fallback still included parse_mode")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
|
||||
if err != nil || messageID != 99 || calls.Load() != 2 {
|
||||
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
})
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var badAttempts int
|
||||
var delivered string
|
||||
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
|
||||
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
|
||||
}
|
||||
|
||||
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
|
||||
if !a.cfg.TurnstileEnabled {
|
||||
if !a.config().TurnstileEnabled {
|
||||
return nil
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
|
||||
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
|
||||
if secret == "" || token == "" {
|
||||
return errors.New("turnstile verification required")
|
||||
}
|
||||
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
|
||||
if ip := normalizeRemoteIP(remoteIP); ip != "" {
|
||||
form.Set("remoteip", ip)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
|
||||
verifyURL := strings.TrimSpace(a.turnstileURL)
|
||||
if verifyURL == "" {
|
||||
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func newTwoFactorRecoveryCode() (string, error) {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
|
||||
if len(value) > 10 {
|
||||
value = value[:10]
|
||||
}
|
||||
return value[:5] + "-" + value[5:], nil
|
||||
}
|
||||
|
||||
func normalizeRecoveryCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
return code
|
||||
}
|
||||
|
||||
func totpProvisioningURI(issuer, account, secret string) string {
|
||||
issuer = strings.TrimSpace(issuer)
|
||||
account = strings.TrimSpace(account)
|
||||
@@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
|
||||
}
|
||||
|
||||
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
codes := make([]string, 0, 8)
|
||||
for len(codes) < 8 {
|
||||
code, err := newTwoFactorRecoveryCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
|
||||
newID("rcv"), userID, hashToken(normalized), now)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
if len(normalized) < 8 {
|
||||
return false, nil
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
@@ -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) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
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) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
return
|
||||
}
|
||||
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
|
||||
@@ -14,12 +14,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
const minimumPasswordLength = 6
|
||||
|
||||
func hasMinimumPasswordLength(password string) bool {
|
||||
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||
}
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
@@ -187,6 +194,32 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
||||
return loginName, nil
|
||||
}
|
||||
|
||||
func cleanUsername(value string) (string, error) {
|
||||
username, err := cleanLoginName(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return "", errors.New("登录名不能使用邮箱地址")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func cleanPrimaryEmail(value string) (string, error) {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
if len([]rune(email)) > 254 {
|
||||
return "", errors.New("邮箱地址不能超过 254 个字符")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
|
||||
@@ -26,27 +26,22 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@tiptap/core": "^3.27.0",
|
||||
"@tiptap/extension-color": "^3.27.0",
|
||||
"@tiptap/extension-font-family": "^3.27.0",
|
||||
"@tiptap/extension-highlight": "^3.27.0",
|
||||
"@tiptap/extension-image": "^3.27.0",
|
||||
"@tiptap/extension-link": "^3.27.0",
|
||||
"@tiptap/extension-placeholder": "^3.27.0",
|
||||
"@tiptap/extension-text-align": "^3.27.0",
|
||||
"@tiptap/extension-text-style": "^3.27.0",
|
||||
"@tiptap/extension-underline": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.4.10",
|
||||
"dompurify": "3.4.13",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "6.30.4",
|
||||
"react-router-dom": "7.18.2",
|
||||
"tailwind-merge": "2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,7 +50,7 @@
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.25",
|
||||
"tailwindcss": "3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.6.3",
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React from "react"
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { useMe, isTimeoutError } from "@/hooks/use-me"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { AuthLoading, AuthError } from "@/components/auth-states"
|
||||
import { isUnauthorizedError } from "@/lib/api"
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (me.isLoading) return <AuthLoading />
|
||||
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||
if (me.isError || !me.data?.user) {
|
||||
const from = `${location.pathname}${location.search}${location.hash}`
|
||||
return <Navigate to="/login" replace state={{ from }} />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function AuthLoading() {
|
||||
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
||||
return <main className="grid min-h-screen place-items-center text-muted-foreground">加载中...</main>
|
||||
}
|
||||
|
||||
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="w-full max-w-sm space-y-4 text-center">
|
||||
<div className="text-sm font-medium">无法连接后端服务</div>
|
||||
<div className="text-sm font-medium">服务暂时不可用</div>
|
||||
<div className="text-sm text-muted-foreground">{message}</div>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>重新加载</Button>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>重新连接</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限配额", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||
@@ -108,7 +108,7 @@ function ProtectedContent() {
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
||||
{user.role === "admin" ? "管理员" : "普通用户"}
|
||||
|
||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const result = await api.updateSystem()
|
||||
const targetVersion = version.data?.latestVersion
|
||||
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||
try {
|
||||
result = await api.updateSystem()
|
||||
} catch (error) {
|
||||
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||
result = {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
message: "更新请求已发送,正在等待服务恢复",
|
||||
}
|
||||
}
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
@@ -69,11 +81,11 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">当前版本</div>
|
||||
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||
<div className="space-y-3">
|
||||
<div className="border-b pb-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">当前版本</div>
|
||||
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||
</div>
|
||||
|
||||
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
||||
@@ -91,7 +103,7 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
||||
{version.data?.releaseNotes && (
|
||||
<div className="space-y-2">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isUpdateConnectionInterruption(error: unknown) {
|
||||
if (!(error instanceof Error)) return false
|
||||
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||
import { api } from "@/lib/api"
|
||||
import { api, isUnauthorizedError } from "@/lib/api"
|
||||
import type { User } from "@/lib/api"
|
||||
|
||||
type MeResponse = { user: User }
|
||||
@@ -10,11 +10,7 @@ export function useMe(
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
retry: 1,
|
||||
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("请求超时")
|
||||
}
|
||||
|
||||
+21
-20
@@ -5,33 +5,34 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 0 0% 9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 224 44% 12%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 12%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 42%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 0 0% 12%;
|
||||
--destructive: 358 88% 61%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 216 22% 42%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 82%;
|
||||
--ring: 0 0% 32%;
|
||||
--radius: 0.5rem;
|
||||
--app-sidebar-width: 18rem;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 224 44% 12%;
|
||||
--sidebar-foreground: 0 0% 16%;
|
||||
--sidebar-primary: 0 0% 12%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 210 40% 96.1%;
|
||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-border: 214.3 31.8% 91.4%;
|
||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
||||
--sidebar-accent: 0 0% 95%;
|
||||
--sidebar-accent-foreground: 0 0% 12%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 42%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
@@ -85,7 +86,7 @@
|
||||
@layer components {
|
||||
.mail-shell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(236px, 17vw, 344px) minmax(0, 1fr);
|
||||
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mail-content-grid {
|
||||
|
||||
@@ -57,7 +57,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailMessage = {
|
||||
@@ -233,10 +233,20 @@ export type SystemSettings = {
|
||||
externalImapGmailClientSecretSet: boolean
|
||||
externalImapOutlookClientId: string
|
||||
externalImapOutlookClientSecretSet: boolean
|
||||
telegramMailEnabled: boolean
|
||||
telegramBotTokenSet: boolean
|
||||
telegramPrivateChatId: string
|
||||
telegramBodyMode: "summary" | "full"
|
||||
telegramMailboxIds: string[]
|
||||
telegramIncludeUnregistered: boolean
|
||||
}
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
|
||||
export type TelegramPrivateChat = { chatId: string; displayName: string }
|
||||
export type TelegramPairing = { code: string; botUsername: string; deepLink: string; expiresAt: string }
|
||||
export type PublicDomain = { id: string; name: string }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
|
||||
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
|
||||
export type TwoFactorEnableResponse = { user: User; recoveryCodes: string[] }
|
||||
export type BulkMoveResult = { ok: boolean; moved: number; failed: number; message: string; items: { id: string; mailboxId?: string; ok: boolean; message: string }[] }
|
||||
|
||||
+44
-24
@@ -1,9 +1,20 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401
|
||||
}
|
||||
|
||||
export type MailSearchParams = {
|
||||
q?: string
|
||||
from?: string
|
||||
@@ -45,46 +56,44 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
|
||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||
const { timeoutMs, ...requestInit } = init
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
|
||||
let timedOut = false
|
||||
const timeout = window.setTimeout(() => {
|
||||
timedOut = true
|
||||
controller.abort()
|
||||
}, timeoutMs || REQUEST_TIMEOUT_MS)
|
||||
const externalSignal = requestInit.signal
|
||||
const abortFromExternalSignal = () => controller.abort()
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) controller.abort()
|
||||
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
|
||||
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
|
||||
}
|
||||
try {
|
||||
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("请求超时,请检查后端服务是否正常")
|
||||
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
|
||||
}
|
||||
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
|
||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFile(path: string): Promise<Blob> {
|
||||
const res = await fetch(path, { credentials: "include" })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试")
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
@@ -111,7 +120,7 @@ export const api = {
|
||||
updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }),
|
||||
setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }),
|
||||
enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
enableTwoFactor: (code: string) => request<TwoFactorEnableResponse>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
|
||||
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -163,8 +172,8 @@ export const api = {
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||
@@ -172,7 +181,7 @@ export const api = {
|
||||
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
|
||||
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
|
||||
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
||||
@@ -206,6 +215,9 @@ export const api = {
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
|
||||
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
|
||||
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
|
||||
@@ -221,15 +233,21 @@ export const api = {
|
||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
||||
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
||||
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
|
||||
},
|
||||
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
|
||||
},
|
||||
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||
deleteFolder: (id: string, mailboxId?: string, folderName?: string) => {
|
||||
const query = new URLSearchParams()
|
||||
if (mailboxId) query.set("mailboxId", mailboxId)
|
||||
if (folderName) query.set("folderName", folderName)
|
||||
const suffix = query.toString()
|
||||
return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" })
|
||||
},
|
||||
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
@@ -254,12 +272,13 @@ export const api = {
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
|
||||
},
|
||||
exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
|
||||
exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string; messageIds?: string[] }) => {
|
||||
const query = new URLSearchParams({ view: params.view })
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.folder) query.set("folder", params.folder)
|
||||
if (params.labelId) query.set("labelId", params.labelId)
|
||||
return requestFile(`/api/mail/export?${query.toString()}`)
|
||||
params.messageIds?.forEach((id) => query.append("messageId", id))
|
||||
return `/api/mail/export?${query.toString()}`
|
||||
},
|
||||
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
|
||||
const form = new FormData()
|
||||
@@ -297,5 +316,6 @@ export const api = {
|
||||
addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
removeLabel: (id: string, labelID: string) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels/${labelID}`, { method: "DELETE" }),
|
||||
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
|
||||
bulkMove: (ids: string[], folder: string) => request<BulkMoveResult>("/api/mail/messages/bulk-move", { method: "POST", body: JSON.stringify({ ids, folder }) }),
|
||||
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
@@ -179,6 +180,12 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
||||
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
|
||||
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
|
||||
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
|
||||
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
|
||||
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
|
||||
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
|
||||
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
|
||||
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
|
||||
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
|
||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function safeReturnPath(value: unknown, fallback = "/"): string {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
|
||||
return value
|
||||
}
|
||||
+10
-7
@@ -6,14 +6,15 @@ import { Toaster } from "@/components/ui/toaster"
|
||||
import { LanguageDomSync } from "@/lib/language"
|
||||
import { ProtectedLayout } from "@/components/protected-layout"
|
||||
import { AdminOnly } from "@/components/admin-only"
|
||||
import { LoginPage } from "@/pages/login"
|
||||
import { RegisterPage } from "@/pages/register"
|
||||
import { MailPage } from "@/pages/mail"
|
||||
import { AdminPage } from "@/pages/admin"
|
||||
import { ProfilePage } from "@/pages/profile"
|
||||
import { NotFoundPage } from "@/pages/not-found"
|
||||
import "./index.css"
|
||||
|
||||
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
|
||||
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
|
||||
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
|
||||
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
|
||||
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">加载中...</div>}>
|
||||
<RouterProvider router={router} />
|
||||
</React.Suspense>
|
||||
<Toaster />
|
||||
<LanguageDomSync />
|
||||
</QueryClientProvider>
|
||||
|
||||
+328
-87
@@ -24,15 +24,16 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
|
||||
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
|
||||
permissionGroups: { label: "权限配额", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" },
|
||||
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
||||
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
|
||||
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
||||
@@ -57,7 +58,15 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultMailboxLimitOverride = 9
|
||||
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email
|
||||
const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
|
||||
|
||||
function compareAdminUsers(left: AdminUser, right: AdminUser) {
|
||||
if (left.role === "admin" && right.role !== "admin") return -1
|
||||
if (left.role !== "admin" && right.role === "admin") return 1
|
||||
return accountPrimaryEmail(left).localeCompare(accountPrimaryEmail(right), "en", { sensitivity: "base" }) ||
|
||||
left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) ||
|
||||
left.createdAt.localeCompare(right.createdAt)
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
@@ -78,7 +87,7 @@ export function AdminPage() {
|
||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||||
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -92,6 +101,14 @@ export function AdminPage() {
|
||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
const sectionQuery = section === "overview" ? overview
|
||||
: section === "users" ? users
|
||||
: section === "permissionGroups" ? permissionGroups
|
||||
: section === "domains" ? domains
|
||||
: section === "mailboxes" ? mailboxes
|
||||
: section === "aliases" ? aliases
|
||||
: section === "settings" ? settings
|
||||
: null
|
||||
|
||||
async function refreshAdminPage() {
|
||||
if (refreshing) return
|
||||
@@ -115,6 +132,8 @@ export function AdminPage() {
|
||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
|
||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||
@@ -132,7 +151,7 @@ export function AdminPage() {
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -244,14 +263,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.users.create")
|
||||
const canDelete = hasPermission(user, "admin.users.delete")
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const loginName = accountLoginName(user)
|
||||
const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||
const matchesRole = roleFilter === "all" || user.role === roleFilter
|
||||
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
|
||||
return matchesKeyword && matchesRole && matchesStatus
|
||||
})
|
||||
const filteredUsers = users
|
||||
.filter((user) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const primaryEmail = accountPrimaryEmail(user)
|
||||
const matchesKeyword = !keyword || [primaryEmail, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||
const matchesRole = roleFilter === "all" || user.role === roleFilter
|
||||
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
|
||||
return matchesKeyword && matchesRole && matchesStatus
|
||||
})
|
||||
.sort(compareAdminUsers)
|
||||
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
<Card>
|
||||
@@ -290,9 +311,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{user.displayName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{accountLoginName(user)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
|
||||
</div>
|
||||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<RoleBadge user={user} />
|
||||
@@ -306,20 +327,20 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>账号</TableHead><TableHead>身份</TableHead><TableHead>权限配额</TableHead><TableHead className="w-[22rem]">邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableHeader><TableRow><TableHead>账号</TableHead><TableHead>身份</TableHead><TableHead>权限配置</TableHead><TableHead className="w-[22rem]">邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">{accountLoginName(user)}</div>
|
||||
<div className="text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
|
||||
</TableCell>
|
||||
<TableCell><RoleBadge user={user} /></TableCell>
|
||||
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||||
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
|
||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
||||
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -348,7 +369,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
onSuccess: () => {
|
||||
setPendingConfirm(null)
|
||||
invalidateAdmin(qc)
|
||||
toast({ title: "权限配额已删除" })
|
||||
toast({ title: "权限配置已删除" })
|
||||
},
|
||||
onError: (e) => toast({ title: "删除失败", description: e.message }),
|
||||
})
|
||||
@@ -357,20 +378,20 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
if (!keyword) return true
|
||||
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin"
|
||||
const isEditable = (group: PermissionGroup) => !group.system
|
||||
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>权限配额</CardTitle>
|
||||
<CardTitle>权限配置</CardTitle>
|
||||
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配额、说明或权限键" className="pl-9" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配置、说明或权限键" className="pl-9" />
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{filtered.map((group) => (
|
||||
@@ -386,16 +407,16 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配额</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
disabled={!isDeletable(group) || !canDelete}
|
||||
onSelect={() => setPendingConfirm({ title: "删除权限配额?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配额", onConfirm: () => remove.mutate(group.id) })}
|
||||
onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}
|
||||
>
|
||||
删除权限配额
|
||||
删除权限配置
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>}
|
||||
@@ -405,7 +426,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && <Empty text="暂无匹配的权限配额" />}
|
||||
{filtered.length === 0 && <Empty text="暂无匹配的权限配置" />}
|
||||
</CardContent>
|
||||
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
@@ -422,13 +443,13 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
||||
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
||||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
|
||||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultLimits)
|
||||
React.useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setPermissions(group?.permissions || [])
|
||||
setLimits(group?.limits || defaultPermissionLimits)
|
||||
setLimits(group?.limits || defaultLimits)
|
||||
}
|
||||
}, [dialogOpen, group])
|
||||
}, [defaultLimits, dialogOpen, group])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
const payload = {
|
||||
@@ -442,20 +463,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
onSuccess: () => {
|
||||
invalidateAdmin(qc)
|
||||
setDialogOpen(false)
|
||||
toast({ title: group ? "权限配额已更新" : "权限配额已创建" })
|
||||
toast({ title: group ? "权限配置已更新" : "权限配置已创建" })
|
||||
},
|
||||
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
|
||||
})
|
||||
const trigger = group ? null : (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4" />权限配额</Button>
|
||||
<Button size="sm"><Plus className="h-4 w-4" />权限配置</Button>
|
||||
</DialogTrigger>
|
||||
)
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger}
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{group ? "编辑权限配额" : "创建权限配额"}</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{group ? "编辑权限配置" : "创建权限配置"}</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||
@@ -892,7 +913,8 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</Table>
|
||||
</div>
|
||||
{messages.isLoading && <Empty text="加载中..." />}
|
||||
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
|
||||
{messages.isError && <QueryFailure error={messages.error} onRetry={() => { void messages.refetch() }} compact />}
|
||||
{!messages.isLoading && !messages.isError && items.length === 0 && <Empty text="暂无邮件" />}
|
||||
{!messages.isLoading && messages.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
||||
@@ -1005,7 +1027,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||
{audit.isError && <QueryFailure error={audit.error} onRetry={() => { void audit.refetch() }} compact />}
|
||||
{!audit.isLoading && !audit.isError && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
@@ -1018,7 +1041,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { settings?: SystemSettings; domains: Domain[]; mailboxes: MailboxType[]; initialTab?: string | null }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
@@ -1030,7 +1053,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1043,6 +1067,13 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
|
||||
const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false)
|
||||
const [telegramBotToken, setTelegramBotToken] = React.useState("")
|
||||
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("")
|
||||
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary")
|
||||
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>([])
|
||||
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false)
|
||||
const [telegramPairing, setTelegramPairing] = React.useState<TelegramPairing | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (!settings) return
|
||||
setSmtpRequireTls(settings.smtpRequireTls)
|
||||
@@ -1056,7 +1087,36 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
||||
setExternalImapEnabled(settings.externalImapEnabled)
|
||||
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
|
||||
setTelegramMailEnabled(settings.telegramMailEnabled)
|
||||
setTelegramBotToken("")
|
||||
setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
|
||||
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
|
||||
setTelegramMailboxIds(settings.telegramMailboxIds || [])
|
||||
setTelegramIncludeUnregistered(settings.telegramIncludeUnregistered)
|
||||
setTelegramPairing(null)
|
||||
}, [settings])
|
||||
const createTelegramPairing = useMutation({
|
||||
mutationFn: () => api.createTelegramPairing(telegramBotToken),
|
||||
onSuccess: (pairing) => {
|
||||
setTelegramPairing(pairing)
|
||||
toast({ title: "Telegram 绑定码已生成" })
|
||||
},
|
||||
onError: (error) => toast({ title: "生成失败", description: error.message }),
|
||||
})
|
||||
const discoverTelegram = useMutation({
|
||||
mutationFn: () => api.discoverTelegramChat(telegramBotToken, telegramPairing?.code || ""),
|
||||
onSuccess: (chat) => {
|
||||
setTelegramPrivateChatId(chat.chatId)
|
||||
setTelegramPairing(null)
|
||||
toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId })
|
||||
},
|
||||
onError: (error) => toast({ title: "获取失败", description: error.message }),
|
||||
})
|
||||
const testTelegram = useMutation({
|
||||
mutationFn: () => api.testTelegram(telegramBotToken, telegramPrivateChatId),
|
||||
onSuccess: () => toast({ title: "Telegram 测试通知已发送" }),
|
||||
onError: (error) => toast({ title: "发送失败", description: error.message }),
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateSystemSettings({
|
||||
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
|
||||
@@ -1089,6 +1149,12 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
|
||||
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
|
||||
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
|
||||
telegramMailEnabled,
|
||||
telegramBotToken,
|
||||
telegramPrivateChatId,
|
||||
telegramBodyMode,
|
||||
telegramMailboxIds,
|
||||
telegramIncludeUnregistered,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
@@ -1130,6 +1196,12 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
settings.externalImapGmailClientSecretSet,
|
||||
settings.externalImapOutlookClientId,
|
||||
settings.externalImapOutlookClientSecretSet,
|
||||
settings.telegramMailEnabled,
|
||||
settings.telegramBotTokenSet,
|
||||
settings.telegramPrivateChatId,
|
||||
settings.telegramBodyMode,
|
||||
(settings.telegramMailboxIds || []).join(","),
|
||||
settings.telegramIncludeUnregistered,
|
||||
].join("|") : "loading"
|
||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||
...(canSettingsView ? [
|
||||
@@ -1137,6 +1209,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
{ key: "smtp" as const, label: "SMTP" },
|
||||
{ key: "storage" as const, label: "存储" },
|
||||
{ key: "mail" as const, label: "邮件" },
|
||||
{ key: "notifications" as const, label: "通知" },
|
||||
{ key: "externalImap" as const, label: "外部 IMAP" },
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
@@ -1246,6 +1319,85 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "notifications" && <Card>
|
||||
<CardHeader><CardTitle>Telegram 邮件通知</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<SwitchRow label="私聊新邮件通知" checked={telegramMailEnabled} onCheckedChange={setTelegramMailEnabled} />
|
||||
{telegramMailEnabled && (
|
||||
<div className="space-y-5 border-t pt-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Bot Token</Label>
|
||||
<Input type="password" value={telegramBotToken} onChange={(event) => setTelegramBotToken(event.target.value)} placeholder={settings?.telegramBotTokenSet ? "已保存,留空不变" : "123456789:..."} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>私聊 Chat ID</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input inputMode="numeric" value={telegramPrivateChatId} onChange={(event) => setTelegramPrivateChatId(event.target.value)} placeholder="123456789" />
|
||||
<Button type="button" variant="outline" className="shrink-0" disabled={createTelegramPairing.isPending} onClick={() => createTelegramPairing.mutate()}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />{createTelegramPairing.isPending ? "生成中" : "安全绑定"}
|
||||
</Button>
|
||||
</div>
|
||||
{telegramPairing && (
|
||||
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="复制绑定码" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild type="button" variant="outline" size="sm">
|
||||
<a href={telegramPairing.deepLink} target="_blank" rel="noreferrer"><ExternalLink className="mr-2 h-4 w-4" />打开机器人</a>
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "绑定中" : "完成绑定"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-5">
|
||||
<Label>通知邮箱</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{mailboxes.filter((mailbox) => mailbox.status === "active").map((mailbox) => (
|
||||
<label key={mailbox.id} className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox
|
||||
checked={telegramMailboxIds.includes(mailbox.id)}
|
||||
onCheckedChange={(checked) => setTelegramMailboxIds((items) => checked === true ? Array.from(new Set([...items, mailbox.id])) : items.filter((id) => id !== mailbox.id))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{mailbox.address}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox checked={telegramIncludeUnregistered} onCheckedChange={(checked) => setTelegramIncludeUnregistered(checked === true)} />
|
||||
<span className="text-sm font-medium">未知收件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>邮件正文</Label>
|
||||
<Select value={telegramBodyMode} onValueChange={(value) => setTelegramBodyMode(value === "full" ? "full" : "summary")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="summary">正文摘要</SelectItem>
|
||||
<SelectItem value="full">尽量显示完整正文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="outline" disabled={testTelegram.isPending || !telegramPrivateChatId} onClick={() => testTelegram.mutate()}>
|
||||
<Mail className="mr-2 h-4 w-4" />{testTelegram.isPending ? "发送中" : "测试通知"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "externalImap" && <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>外部 IMAP 接入</CardTitle>
|
||||
@@ -1289,7 +1441,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||
{settingsTab === "templates" && canViewTemplates && templates.isError && <QueryFailure error={templates.error} onRetry={() => { void templates.refetch() }} />}
|
||||
{settingsTab === "templates" && canViewTemplates && !templates.isError && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||
|
||||
{settingsTab === "security" && <Card>
|
||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||
@@ -1668,12 +1821,24 @@ function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; va
|
||||
}
|
||||
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
||||
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||
|
||||
function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onRetry: () => void; compact?: boolean }) {
|
||||
return (
|
||||
<div className={cn("mb-4 flex flex-col gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between", compact && "mb-0")} role="alert">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-destructive">数据读取失败</div>
|
||||
<div className="mt-1 break-words text-sm text-muted-foreground">{queryErrorMessage(error)}</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0" onClick={onRetry}><RefreshCcw className="h-4 w-4" />重试</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
|
||||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||
|
||||
function UserMailboxCell({ user }: { user: AdminUser }) {
|
||||
const { toast } = useToast()
|
||||
const loginAddress = accountLoginName(user)
|
||||
const loginAddress = accountPrimaryEmail(user)
|
||||
const mailboxes = user.mailboxes || []
|
||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||
@@ -1774,7 +1939,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>权限配额</Label>
|
||||
<Label>自定义权限配置</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{groups.map((group) => {
|
||||
const checked = value.includes(group.id)
|
||||
@@ -1789,7 +1954,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groups.length === 0 && <Empty text="暂无可分配权限配额" />}
|
||||
{groups.length === 0 && <Empty text="暂无可分配自定义权限配置" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1813,13 +1978,14 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
||||
const canUpdate = hasPermission(currentUser, "admin.users.update")
|
||||
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
|
||||
const update = useMutation({
|
||||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||||
mutationFn: (payload: { email: string; displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) },
|
||||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||||
})
|
||||
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
||||
const role = patch.role || user.role
|
||||
function quickPatch(patch: Partial<{ disabled: boolean }>) {
|
||||
const role = user.role
|
||||
update.mutate({
|
||||
email: accountPrimaryEmail(user),
|
||||
displayName: user.displayName,
|
||||
role,
|
||||
disabled: patch.disabled ?? user.disabled,
|
||||
@@ -1827,27 +1993,24 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
||||
})
|
||||
}
|
||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
}
|
||||
|
||||
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
|
||||
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [role, setRole] = React.useState<"admin" | "user">("user")
|
||||
const [status, setStatus] = React.useState("active")
|
||||
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||||
const create = useMutation({
|
||||
mutationFn: (form: FormData) => api.createUser({
|
||||
loginName: String(form.get("loginName") || ""),
|
||||
email: String(form.get("email") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
password: String(form.get("password") || ""),
|
||||
role,
|
||||
role: "user",
|
||||
disabled: status === "disabled",
|
||||
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined,
|
||||
permissionGroupIds: role === "user" ? permissionGroupIds : [],
|
||||
mailboxLimitOverride: mailboxLimitFromForm(form),
|
||||
}),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) },
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
|
||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||
})
|
||||
return (
|
||||
@@ -1856,15 +2019,14 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>创建账号</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||
<Field name="email" label="主登录邮箱" type="email" autoComplete="off" placeholder="user@example.com" />
|
||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||
<Field name="password" label="初始密码" type="password" minLength={6} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
|
||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||
</div>
|
||||
{role === "user" && <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />}
|
||||
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
|
||||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -1875,12 +2037,12 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
if (!canUpdate && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||
}
|
||||
|
||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||
if (!onToggle && !onDelete) return null
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -1896,6 +2058,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
}, [user, open])
|
||||
const mut = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateUser(user.id, {
|
||||
email: String(form.get("email") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
role,
|
||||
disabled: disabled === "disabled",
|
||||
@@ -1910,11 +2073,11 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>编辑账号</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly />
|
||||
<Field name="email" label="主登录邮箱" defaultValue={accountPrimaryEmail(user)} type="email" autoComplete="off" />
|
||||
<Field name="displayName" label="显示名称" defaultValue={user.displayName} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
|
||||
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected} />
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
|
||||
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
|
||||
</div>
|
||||
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
|
||||
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||
@@ -1927,7 +2090,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
|
||||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="主登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -1944,10 +2107,56 @@ function CreateDomainDialog() {
|
||||
}
|
||||
|
||||
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [domainId, setDomainId] = React.useState("")
|
||||
const [ownerMode, setOwnerMode] = React.useState("new")
|
||||
const [userId, setUserId] = React.useState("")
|
||||
React.useEffect(() => {
|
||||
if (!domainId && domains[0]) setDomainId(domains[0].id)
|
||||
if (!userId && users[0]) setUserId(users[0].id)
|
||||
}, [domains, domainId, users, userId])
|
||||
const mut = useMutation({
|
||||
mutationFn: (form: FormData) => api.createMailbox({
|
||||
domainId,
|
||||
localPart: String(form.get("localPart")),
|
||||
displayName: String(form.get("displayName")),
|
||||
password: String(form.get("password")),
|
||||
quotaMb: Number(form.get("quotaMb") || 1024),
|
||||
role: "user",
|
||||
ownerEmail: String(form.get("ownerEmail") || ""),
|
||||
userId: ownerMode === "existing" ? userId : "",
|
||||
}),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
|
||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||
})
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
|
||||
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
|
||||
<Field name="displayName" label="显示名" placeholder="Alice" />
|
||||
</div>
|
||||
<SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[["new", "新建/按主登录邮箱匹配账号"], ["existing", "追加到已有账号"]]} />
|
||||
{ownerMode === "existing" ? (
|
||||
<SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} />
|
||||
) : (
|
||||
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
|
||||
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
|
||||
</div>
|
||||
<DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
@@ -1962,15 +2171,15 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
const user = me.data?.user
|
||||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) })
|
||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||
const content = <>
|
||||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||||
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
|
||||
{records.isError ? <QueryFailure error={records.error} onRetry={() => { void records.refetch() }} compact /> : <div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>}
|
||||
{check.data && <>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <DNSCheckRow key={k} name={k} check={v} />)}</div>
|
||||
</>}</>
|
||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||
@@ -1978,9 +2187,18 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||
}
|
||||
|
||||
const dnsDescriptions: Record<string, string> = {
|
||||
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
|
||||
TXT: "", // 具体含义根据内容区分
|
||||
function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) {
|
||||
const visibleRecords = check.found?.filter(Boolean) ?? []
|
||||
return <div className="space-y-1 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className={`mt-0.5 h-4 w-4 shrink-0 ${check.ok ? "text-green-600" : "text-destructive"}`} />
|
||||
<div className="min-w-0"><span className="font-medium">{name.toUpperCase()}:</span> {check.message}</div>
|
||||
</div>
|
||||
{!check.ok && visibleRecords.length > 0 && <div className="ml-6 rounded-md bg-muted/60 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-sans text-foreground">当前解析</div>
|
||||
<div className="space-y-1">{visibleRecords.map((record, index) => <div key={`${name}-${index}`} className="break-all">{record}</div>)}</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function dnsDescription(record: DNSRecord): string {
|
||||
@@ -1992,18 +2210,32 @@ function dnsDescription(record: DNSRecord): string {
|
||||
}
|
||||
|
||||
function DNSRecordRow({ record }: { record: DNSRecord }) {
|
||||
const { toast } = useToast(); const text = `${record.type} ${record.name} ${record.value}`
|
||||
const { toast } = useToast()
|
||||
const desc = dnsDescription(record)
|
||||
async function copyField(label: string, value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast({ title: `${label}已复制` })
|
||||
}
|
||||
return <div className="rounded-lg border bg-card p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Badge variant="outline" className="font-mono">{record.type}</Badge>
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}><Copy className="h-3.5 w-3.5" />复制</Button>
|
||||
</div>
|
||||
{desc && <p className="mb-2 text-xs text-muted-foreground">{desc}</p>}
|
||||
<div className="break-all font-mono text-xs text-muted-foreground">
|
||||
<div><span className="text-foreground">Name:</span> {record.name}</div>
|
||||
<div><span className="text-foreground">Value:</span> {record.value}</div>
|
||||
<div><span className="text-foreground">TTL:</span> {record.ttl}s</div>
|
||||
<div className="space-y-1 font-mono text-xs text-muted-foreground">
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">主机记录</span>
|
||||
<code className="break-all pt-1 font-mono">{record.name}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制主机记录" title="复制主机记录" onClick={() => copyField("主机记录", record.name)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">记录值</span>
|
||||
<code className="break-all pt-1 font-mono">{record.value}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制记录值" title="复制记录值" onClick={() => copyField("记录值", record.value)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
|
||||
<span className="text-foreground">TTL</span>
|
||||
<code className="font-mono">{record.ttl} 秒</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -2019,19 +2251,25 @@ function fieldNumber(form: FormData, name: string, fallback: number) {
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||
}
|
||||
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
||||
const id = React.useId()
|
||||
return (
|
||||
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
||||
<Label className="text-base font-medium">{label}</Label>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<Label htmlFor={id} className="text-base font-medium">{label}</Label>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||||
function Field({ label, required = true, id: suppliedId, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||
const generatedId = React.useId()
|
||||
const id = suppliedId || generatedId
|
||||
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Input id={id} required={required} {...props} /></div>
|
||||
}
|
||||
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
||||
const id = React.useId()
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱数量上限</Label>
|
||||
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||
<Label htmlFor={id}>邮箱数量上限</Label>
|
||||
<Input id={id} name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2043,5 +2281,8 @@ function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOver
|
||||
function effectiveMailboxLimit(user: AdminUser) {
|
||||
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
||||
}
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) {
|
||||
const id = React.useId()
|
||||
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger id={id}><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, itemLabel]) => <SelectItem key={value} value={value}>{itemLabel}</SelectItem>)}</SelectContent></Select></div>
|
||||
}
|
||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { Link, Navigate, useLocation } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
@@ -10,9 +10,12 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { safeReturnPath } from "@/lib/navigation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
|
||||
export function LoginPage() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
@@ -21,7 +24,7 @@ export function LoginPage() {
|
||||
const login = useMutation({
|
||||
mutationFn: (form: FormData) => challengeToken
|
||||
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
|
||||
: api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }),
|
||||
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
|
||||
onSuccess: async (data) => {
|
||||
if (data.twoFactorRequired && data.challengeToken) {
|
||||
setChallengeToken(data.challengeToken)
|
||||
@@ -33,9 +36,12 @@ export function LoginPage() {
|
||||
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
||||
})
|
||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||
if (me.data?.user) return <Navigate to="/" replace />
|
||||
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
|
||||
if (me.data?.user) return <Navigate to={returnPath} replace />
|
||||
if (publicSettings.isLoading) return <AuthLoading />
|
||||
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
@@ -49,8 +55,8 @@ export function LoginPage() {
|
||||
{!challengeToken ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loginName" className="text-sm font-medium">登录名</Label>
|
||||
<Input id="loginName" name="loginName" type="text" autoComplete="username" required className="h-11 text-base" />
|
||||
<Label htmlFor="email" className="text-sm font-medium">邮箱地址</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
@@ -59,8 +65,8 @@ export function LoginPage() {
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="twoFactorCode" className="text-sm font-medium">双因素验证码</Label>
|
||||
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-center text-lg tracking-[0.35em]" />
|
||||
<Label htmlFor="twoFactorCode" className="text-sm font-medium">双因素验证码或恢复码</Label>
|
||||
<Input id="twoFactorCode" name="twoFactorCode" autoComplete="one-time-code" minLength={6} required className="h-11 text-center text-lg" />
|
||||
</div>
|
||||
)}
|
||||
{!challengeToken && turnstileRequired && (
|
||||
@@ -82,6 +88,6 @@ export function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
+404
-160
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
@@ -24,6 +24,6 @@ export function NotFoundPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
+454
-890
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { TurnstileBox } from "@/components/turnstile-box"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
|
||||
export function RegisterPage() {
|
||||
const me = useMe()
|
||||
@@ -24,32 +25,28 @@ export function RegisterPage() {
|
||||
const [domainId, setDomainId] = React.useState("")
|
||||
const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || []
|
||||
const selectedDomain = domains.find((d) => d.id === domainId)
|
||||
React.useEffect(() => {
|
||||
if (!domainId && domains[0]) setDomainId(domains[0].id)
|
||||
if (domainId && !domains.some((domain) => domain.id === domainId)) setDomainId(domains[0]?.id || "")
|
||||
}, [domainId, domains])
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
const password = String(form.get("password") || "")
|
||||
const confirmPassword = String(form.get("confirmPassword") || "")
|
||||
validatePasswordConfirm(password, confirmPassword)
|
||||
|
||||
if (domainId && selectedDomain) {
|
||||
const localPart = String(form.get("localPart") || "").trim()
|
||||
if (!localPart) throw new Error("请输入邮箱前缀")
|
||||
return api.register({
|
||||
email: `${localPart}@${selectedDomain.name}`,
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
password,
|
||||
turnstileToken,
|
||||
domainId,
|
||||
localPart,
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no domains available, use email directly
|
||||
const displayName = String(form.get("displayName") || "").trim()
|
||||
if (!displayName) throw new Error("请输入显示名称")
|
||||
const localPart = String(form.get("localPart") || "").trim()
|
||||
if (!localPart) throw new Error("请输入邮箱前缀")
|
||||
if (!domainId || !selectedDomain) throw new Error("请选择邮箱域名")
|
||||
return api.register({
|
||||
email: String(form.get("email") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
email: `${localPart}@${selectedDomain.name}`,
|
||||
displayName,
|
||||
password,
|
||||
turnstileToken,
|
||||
domainId,
|
||||
localPart,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -61,8 +58,10 @@ export function RegisterPage() {
|
||||
})
|
||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||
if (me.data?.user) return <Navigate to="/" replace />
|
||||
if (publicSettings.isLoading) return <AuthLoading />
|
||||
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
@@ -102,25 +101,22 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-4 py-3 text-center text-sm text-muted-foreground">当前没有可注册的邮箱域名</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
||||
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
|
||||
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading || domains.length === 0}>
|
||||
{register.isPending ? "注册中..." : "注册"}
|
||||
{!register.isPending && <ArrowRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -136,6 +132,6 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
+38
-13
@@ -1,19 +1,44 @@
|
||||
import path from "node:path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { defineConfig, loadEnv } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "")
|
||||
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/healthz": "http://localhost:8080",
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
"/healthz": apiTarget,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: "prosemirror",
|
||||
test: /node_modules[\\/]prosemirror-/,
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
name: "tiptap",
|
||||
test: /node_modules[\\/]@tiptap/,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+34
-1
@@ -20,6 +20,18 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
# 手动部署可执行:openssl rand -hex 24
|
||||
LANQIN_UPDATE_TOKEN=
|
||||
|
||||
# 一键安装器记录的部署方式。手动部署时可以留空。
|
||||
LANQIN_INSTALL_FIREWALL_MODE=
|
||||
LANQIN_INSTALL_WEB_MODE=
|
||||
|
||||
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
LANQIN_IMAPS_BIND=993
|
||||
LANQIN_POP3S_BIND=995
|
||||
|
||||
# =========================
|
||||
# 对外访问地址
|
||||
# =========================
|
||||
@@ -39,9 +51,14 @@ LANQIN_TLS_KEY_FILE=
|
||||
# =========================
|
||||
# 初始管理员
|
||||
# =========================
|
||||
# 第一次启动时会创建这个管理员账号。
|
||||
# 第一次启动时会创建唯一管理员账号和真实管理员邮箱。
|
||||
# 网页前台和后台统一使用完整主登录邮箱 + 密码登录。
|
||||
LANQIN_MAIL_DOMAIN=example.com
|
||||
LANQIN_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# 旧版本兼容字段,网页登录不再使用;新安装脚本会写入管理员邮箱前缀。
|
||||
LANQIN_ADMIN_USERNAME=
|
||||
|
||||
# 生产环境必须改掉默认密码。
|
||||
LANQIN_ADMIN_PASSWORD=ChangeMe123!
|
||||
|
||||
@@ -166,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
||||
|
||||
# =========================
|
||||
# Telegram 私聊邮件通知
|
||||
# =========================
|
||||
# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。
|
||||
# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。
|
||||
LANQIN_TELEGRAM_MAIL_ENABLED=false
|
||||
|
||||
# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。
|
||||
LANQIN_TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。
|
||||
LANQIN_TELEGRAM_PRIVATE_CHAT_ID=
|
||||
|
||||
# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。
|
||||
LANQIN_TELEGRAM_BODY_MODE=summary
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
+47
-3
@@ -15,10 +15,20 @@ sudo newszxcn-email update
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email reset-2fa
|
||||
```
|
||||
|
||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
|
||||
首次安装会依次询问防火墙模式和邮件服务器域名,自动检测邮箱地址域名,再选择默认 `admin` 前缀或自行创建管理员邮箱前缀,最后输入密码并选择 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。例如服务器域名 `mail.newszxcn.com`、选择默认前缀会创建 `admin@newszxcn.com`;自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||
|
||||
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
|
||||
|
||||
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置唯一管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。唯一管理员 2FA 锁死时可使用 `sudo newszxcn-email reset-2fa` 应急关闭。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||
@@ -26,7 +36,7 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_MAIL_DOMAIN / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -138,13 +148,40 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
|
||||
配置完成后点击“检测”。
|
||||
|
||||
## Telegram 通知
|
||||
|
||||
### 私聊新邮件通知
|
||||
|
||||
每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知:
|
||||
|
||||
1. 使用 `@BotFather` 创建机器人并填写 Bot Token。
|
||||
2. 在 Telegram 中打开该机器人并发送 `/start`。
|
||||
3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。
|
||||
4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。
|
||||
5. 测试成功后开启“私聊新邮件通知”并保存。
|
||||
|
||||
Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。
|
||||
|
||||
手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED`、`LANQIN_TELEGRAM_BOT_TOKEN`、`LANQIN_TELEGRAM_PRIVATE_CHAT_ID` 和 `LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。
|
||||
|
||||
### GitHub Release 版本频道通知
|
||||
|
||||
版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets:
|
||||
|
||||
```text
|
||||
TELEGRAM_RELEASE_BOT_TOKEN
|
||||
TELEGRAM_RELEASE_CHAT_ID
|
||||
```
|
||||
|
||||
`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。
|
||||
|
||||
## 邮件服务边界
|
||||
|
||||
- Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。
|
||||
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
|
||||
- 第三方客户端可使用 IMAP SSL `993`、POP3 SSL `995`、SMTP SSL `465` 或 Submission `587`。
|
||||
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。
|
||||
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。
|
||||
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`;仅当密钥内容变化时重新载入签名配置,避免继续使用内存中的旧密钥。
|
||||
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
|
||||
@@ -154,6 +191,13 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
||||
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -174,7 +218,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||
|
||||
@@ -5,6 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 50m;
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -6,19 +6,17 @@ services:
|
||||
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "25:25"
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "993:993"
|
||||
- "995:995"
|
||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||
- "${LANQIN_IMAPS_BIND:-993}:993"
|
||||
- "${LANQIN_POP3S_BIND:-995}:995"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||
|
||||
@@ -3,6 +3,7 @@ server {
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 50m;
|
||||
proxy_pass http://api:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user