Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ec1979b2 | |||
| 6fa98cec97 | |||
| 71746bdfba | |||
| f15a3d3222 | |||
| 7fc91f2ed9 | |||
| c482e5b896 | |||
| 07e62e9c2d | |||
| 5797b472d9 | |||
| 3caee11e38 | |||
| 2dd3647923 | |||
| ccb8ce01d9 | |||
| 4e3b69608f | |||
| 1dbc33b0dc | |||
| 48a1d53133 | |||
| ff5578368a | |||
| fc3a3462cf | |||
| 9a572e0100 | |||
| c92140c9cc | |||
| 2fd37bf635 | |||
| 3d3a251af2 | |||
| 6ad9164be3 | |||
| ce98978ebd | |||
| ee38990ea1 | |||
| 9cb3f13b02 | |||
| d2cfad3cc4 | |||
| dbd5b95143 | |||
| 131421a0f1 | |||
| 2a26a3b127 | |||
| aeaa151e90 | |||
| 60d87a6960 | |||
| 70dd2cec4e | |||
| a9ec9360a8 | |||
| 942605b2b6 | |||
| 635ab02b29 | |||
| 4c90c1de44 | |||
| 1fa029370d | |||
| 79f920bf0b | |||
| c77f63b5af | |||
| 86773c64ca | |||
| a11e1cd2f1 | |||
| 94efc2c62b | |||
| cefbee4f7f | |||
| 06388465a2 | |||
| e6504ef492 | |||
| 39ff9ce01d | |||
| df50f8b3ef |
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 优化邮箱选择器动态宽度
|
||||
|
||||
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
|
||||
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
|
||||
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
|
||||
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 移除“加载邮箱”过渡文案
|
||||
|
||||
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
|
||||
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
|
||||
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
|
||||
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复文件夹管理
|
||||
|
||||
- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。
|
||||
- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。
|
||||
- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。
|
||||
|
||||
### 修复导入邮件的存储显示
|
||||
|
||||
- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`。
|
||||
- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。
|
||||
- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。
|
||||
|
||||
### 改进邮件下载
|
||||
|
||||
- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。
|
||||
- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。
|
||||
- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。
|
||||
|
||||
### 优化设置与后台切换
|
||||
|
||||
- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。
|
||||
- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。
|
||||
- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已通过安装脚本语法和自动化回归测试。
|
||||
- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。
|
||||
|
||||
**完整更新日志**:[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12)
|
||||
@@ -0,0 +1,30 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复选中邮件下载
|
||||
|
||||
- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。
|
||||
- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。
|
||||
|
||||
### 改进 EML 文件信息
|
||||
|
||||
- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。
|
||||
- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。
|
||||
- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。
|
||||
|
||||
### 调整邮箱与设置交互
|
||||
|
||||
- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。
|
||||
- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。
|
||||
- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。
|
||||
|
||||
### 修复最后一个邮箱删除
|
||||
|
||||
- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。
|
||||
- 删除邮箱流程的错误提示改为简体中文。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。
|
||||
|
||||
**完整更新日志**:[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13)
|
||||
@@ -0,0 +1,35 @@
|
||||
## 本次更新
|
||||
|
||||
### 统一邮箱登录与注册
|
||||
|
||||
- 前台和后台统一使用主登录邮箱与密码登录,不再接受旧用户名登录。
|
||||
- 自助注册会一次性创建账号和邮箱,并要求填写显示名称、邮箱前缀、域名和密码确认。
|
||||
- 只有主登录邮箱可以登录网页,账号下的其他邮箱不会成为额外登录入口。
|
||||
- 网页修改或管理员重置密码时,会同步更新该账号全部邮箱的 SMTP/IMAP 密码。
|
||||
|
||||
### 加强管理员与安全管理
|
||||
|
||||
- 系统只保留一名安装时创建的管理员,后台和 API 均不能新增或提升第二名管理员。
|
||||
- 内置“管理员”和“普通用户”权限配置不可修改或删除,自定义权限配置仍可正常创建和分配。
|
||||
- 双因素认证新增恢复码,服务器菜单支持重置管理员密码和管理员 2FA。
|
||||
- 保留 Cloudflare Turnstile 登录与注册防刷验证。
|
||||
|
||||
### 修复邮件批量移动与界面布局
|
||||
|
||||
- “全部邮箱”模式可以将跨邮箱选中的邮件批量移动到各邮箱对应的自定义文件夹。
|
||||
- 固定邮箱选择器、复制按钮和展开菜单宽度,切换具体邮箱时不再缩窄。
|
||||
- 邮箱页与设置页统一侧栏宽度,点击设置时分隔线和内容区域不再左右跳动。
|
||||
|
||||
### 改进安装与升级兼容
|
||||
|
||||
- 新安装默认创建 `admin@邮箱域名` 管理员邮箱,并支持自定义管理员邮箱前缀。
|
||||
- 旧数据库升级时自动保留现有管理员、邮箱、密码和双因素认证数据,不会生成错误的占位域名账号。
|
||||
- 管理员在后台修改主登录邮箱后,服务重启不会恢复成安装时的旧邮箱。
|
||||
- 安装指南、环境变量示例和管理员恢复命令已同步更新。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查和生产构建。
|
||||
- 已通过安装脚本测试、ShellCheck,以及桌面端和移动端浏览器验证。
|
||||
|
||||
**完整更新日志**:[v1.2.13...v1.2.14](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.13...v1.2.14)
|
||||
@@ -0,0 +1,14 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮箱选择器右侧空白
|
||||
|
||||
- “全部邮箱”状态下移除复制按钮的永久空白占位,选择器恢复为整行宽度。
|
||||
- 选择具体邮箱时,复制按钮覆盖显示在选择器右侧,不再挤压或缩窄选择器。
|
||||
- 下拉箭头、未读数量和复制按钮保持独立位置,长邮箱地址仍会正确截断。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、shadcn/ui 检查和生产构建。
|
||||
- 已在桌面端和 390×844 移动端验证“全部邮箱”与具体邮箱两种状态,无横向溢出或控制台错误。
|
||||
|
||||
**完整更新日志**:[v1.2.14...v1.2.15](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.14...v1.2.15)
|
||||
@@ -0,0 +1,3 @@
|
||||
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
|
||||
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
|
||||
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
|
||||
@@ -0,0 +1,6 @@
|
||||
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
|
||||
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
|
||||
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
|
||||
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
|
||||
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
|
||||
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
|
||||
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
|
||||
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
|
||||
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
|
||||
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
|
||||
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
|
||||
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
|
||||
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
|
||||
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
|
||||
@@ -0,0 +1,3 @@
|
||||
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
|
||||
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
|
||||
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
|
||||
@@ -0,0 +1,8 @@
|
||||
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
|
||||
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
|
||||
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
|
||||
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
|
||||
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
|
||||
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
|
||||
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
|
||||
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
|
||||
@@ -0,0 +1,5 @@
|
||||
- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。
|
||||
- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。
|
||||
- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。
|
||||
- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。
|
||||
- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。
|
||||
@@ -0,0 +1,5 @@
|
||||
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
|
||||
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
|
||||
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
|
||||
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
|
||||
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
|
||||
@@ -0,0 +1,3 @@
|
||||
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
|
||||
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
|
||||
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
|
||||
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
|
||||
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
|
||||
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
|
||||
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
|
||||
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
|
||||
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
|
||||
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
|
||||
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
|
||||
@@ -0,0 +1,7 @@
|
||||
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
|
||||
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
|
||||
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
|
||||
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
|
||||
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
|
||||
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
|
||||
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
|
||||
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
|
||||
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
|
||||
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
|
||||
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
|
||||
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
|
||||
@@ -0,0 +1,8 @@
|
||||
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
|
||||
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
|
||||
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
|
||||
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
|
||||
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
|
||||
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
|
||||
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
|
||||
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
|
||||
@@ -0,0 +1,13 @@
|
||||
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
|
||||
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
|
||||
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
|
||||
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
|
||||
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
|
||||
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
|
||||
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
|
||||
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
|
||||
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
|
||||
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
|
||||
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
|
||||
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
|
||||
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
|
||||
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
|
||||
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
|
||||
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
|
||||
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
|
||||
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
|
||||
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
|
||||
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
|
||||
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
|
||||
@@ -0,0 +1,7 @@
|
||||
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
|
||||
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
|
||||
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
|
||||
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
|
||||
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
|
||||
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
|
||||
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
|
||||
@@ -0,0 +1,11 @@
|
||||
- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。
|
||||
- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。
|
||||
- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。
|
||||
- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。
|
||||
- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。
|
||||
- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。
|
||||
- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。
|
||||
- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。
|
||||
- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。
|
||||
|
||||
**完整更新日志**:[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32)
|
||||
@@ -0,0 +1,9 @@
|
||||
- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。
|
||||
- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。
|
||||
- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。
|
||||
- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。
|
||||
- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。
|
||||
- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。
|
||||
- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。
|
||||
|
||||
**完整更新日志**:[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33)
|
||||
@@ -0,0 +1,9 @@
|
||||
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
|
||||
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
|
||||
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
|
||||
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
|
||||
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
|
||||
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
|
||||
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
|
||||
|
||||
**完整更新日志**:[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
|
||||
@@ -0,0 +1,7 @@
|
||||
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
|
||||
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
|
||||
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
|
||||
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
|
||||
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
|
||||
|
||||
**完整更新日志**:[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
|
||||
@@ -0,0 +1,11 @@
|
||||
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
|
||||
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
|
||||
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
|
||||
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
|
||||
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
|
||||
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
|
||||
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
|
||||
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
|
||||
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
|
||||
|
||||
**完整更新日志**:[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
|
||||
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
|
||||
|
||||
**完整更新日志**:[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
|
||||
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
|
||||
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
|
||||
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
|
||||
|
||||
**完整更新日志**:[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
|
||||
|
||||
**完整更新日志**:[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
|
||||
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
|
||||
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
|
||||
|
||||
**完整更新日志**:[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
|
||||
@@ -0,0 +1,3 @@
|
||||
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
|
||||
|
||||
**完整更新日志**:[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
|
||||
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
|
||||
|
||||
**完整更新日志**:[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
|
||||
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
|
||||
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
|
||||
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
|
||||
|
||||
**完整更新日志**:[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -44,9 +44,10 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
|
||||
bash tests/install_test.sh
|
||||
bash tests/dkim_sync_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
@@ -31,9 +31,10 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh
|
||||
bash tests/install_test.sh
|
||||
bash tests/dkim_sync_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -205,11 +206,8 @@ 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)"
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
@@ -240,38 +238,10 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
自建邮箱 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
|
||||
@@ -280,6 +250,92 @@ jobs:
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Notify Telegram release channel
|
||||
if: steps.release_result.outputs.created == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
RELEASE_URL: ${{ needs.release.outputs.release_url }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
|
||||
echo "::notice::Telegram release notification is not configured; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
|
||||
entries = []
|
||||
for raw in notes.splitlines():
|
||||
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
|
||||
line = re.sub(r"^[-*+]\s+", "", line)
|
||||
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
|
||||
line = re.sub(r"`([^`]+)`", r"\1", line)
|
||||
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
|
||||
if line:
|
||||
entries.append(line)
|
||||
|
||||
sections = []
|
||||
for index, entry in enumerate(entries, 1):
|
||||
parts = re.split(r"[::]", entry, maxsplit=1)
|
||||
if len(parts) == 1:
|
||||
parts = re.split(r"[,;。]", entry, maxsplit=1)
|
||||
title = parts[0].strip()
|
||||
description = parts[1].strip() if len(parts) > 1 else ""
|
||||
section = f"<b>{index:02d} · {html.escape(title)}</b>"
|
||||
if description:
|
||||
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
|
||||
sections.append(section)
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
|
||||
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
|
||||
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
|
||||
available = max(0, 3600 - len(prefix) - len(footer))
|
||||
visible_sections = []
|
||||
used = 0
|
||||
for section in sections:
|
||||
added = len(section) + (2 if visible_sections else 0)
|
||||
if used + added > available:
|
||||
break
|
||||
visible_sections.append(section)
|
||||
used += added
|
||||
body = "\n\n".join(visible_sections)
|
||||
if len(visible_sections) < len(sections):
|
||||
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
|
||||
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
|
||||
PY
|
||||
|
||||
jq -n \
|
||||
--arg chat_id "${TELEGRAM_CHAT_ID}" \
|
||||
--rawfile text telegram-release-message.txt \
|
||||
'{
|
||||
chat_id:$chat_id,
|
||||
text:$text,
|
||||
parse_mode:"HTML",
|
||||
disable_web_page_preview:true
|
||||
}' > telegram-release-payload.json
|
||||
|
||||
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
|
||||
-o telegram-release-response.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @telegram-release-payload.json \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
|
||||
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
|
||||
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
|
||||
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Telegram release notification sent."
|
||||
|
||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[邮箱后台配置指南](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 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -35,8 +35,60 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
### 管理面板
|
||||
|
||||
脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能:
|
||||
|
||||
```text
|
||||
==================================================
|
||||
NewSzxcn Email 管理面板
|
||||
==================================================
|
||||
状态:尚未安装
|
||||
--------------------------------------------------
|
||||
1. 一键安装 NewSzxcn Email
|
||||
0. 退出
|
||||
==================================================
|
||||
请选择 [1]:
|
||||
```
|
||||
|
||||
检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新:
|
||||
|
||||
```text
|
||||
==================================================
|
||||
NewSzxcn Email 管理面板
|
||||
==================================================
|
||||
状态:运行中
|
||||
版本:v1.2.19(示例,以实际安装版本为准)
|
||||
地址:https://mail.example.com
|
||||
--------------------------------------------------
|
||||
安装与维护
|
||||
1. 重新安装(完整备份,失败自动恢复)
|
||||
2. 更新系统(自动备份,失败自动回滚)
|
||||
3. 检查并修复现有安装
|
||||
|
||||
服务管理
|
||||
4. 查看运行状态
|
||||
5. 重启服务
|
||||
6. 查看实时日志
|
||||
|
||||
证书与恢复
|
||||
7. 管理 SSL 证书
|
||||
8. 回滚到上次更新前版本
|
||||
|
||||
账号与帮助
|
||||
9. 邮箱后台配置指南
|
||||
10. 查看管理员登录信息
|
||||
11. 重置管理员登录密码
|
||||
|
||||
危险操作
|
||||
12. 卸载服务(保留数据)
|
||||
|
||||
0. 退出
|
||||
==================================================
|
||||
请选择 [2]:
|
||||
```
|
||||
|
||||
容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
|
||||
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
|
||||
|
||||
@@ -44,7 +96,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 自动检测并确认邮箱地址域名;创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`;回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
@@ -87,6 +139,7 @@ sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email repair
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
@@ -178,6 +231,8 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
|
||||
+5
-3
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
@@ -33,8 +33,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
|
||||
- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
|
||||
|
||||
### 后台页面更新
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
Messages int64 `json:"messages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
TodaySent int64 `json:"todaySent"`
|
||||
TodayReceived int64 `json:"todayReceived"`
|
||||
SendDelivered int64 `json:"sendDelivered"`
|
||||
SendFailed int64 `json:"sendFailed"`
|
||||
QueueMessages int64 `json:"queueMessages"`
|
||||
}
|
||||
now := a.now().UTC()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||
queries := []struct {
|
||||
q string
|
||||
dest *int64
|
||||
args []any
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, &out.Users},
|
||||
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
|
||||
{`SELECT COUNT(*) FROM domains`, &out.Domains},
|
||||
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
|
||||
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
|
||||
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
|
||||
{`SELECT COUNT(*) FROM messages`, &out.Messages},
|
||||
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
|
||||
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
|
||||
{q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
|
||||
{q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
|
||||
{q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
|
||||
{q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
|
||||
{q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
|
||||
{q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
|
||||
{q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
|
||||
}
|
||||
for _, item := range queries {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
||||
return
|
||||
}
|
||||
@@ -49,10 +62,10 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
|
||||
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
@@ -64,7 +77,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created, mailboxCSV string
|
||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||
return
|
||||
}
|
||||
@@ -101,6 +114,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -108,20 +122,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(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 == "" {
|
||||
@@ -131,8 +144,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)
|
||||
@@ -143,6 +156,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
storageQuotaMB := req.StorageQuotaMB
|
||||
if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
|
||||
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
|
||||
return
|
||||
}
|
||||
if storageQuotaMB == 0 {
|
||||
storageQuotaMB = defaultUserStorageQuotaMB
|
||||
}
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
@@ -160,11 +181,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
|
||||
var primaryDomainID string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
|
||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load account domain")
|
||||
return
|
||||
}
|
||||
permissionGroupIDs := req.PermissionGroupIDs
|
||||
if role == "admin" {
|
||||
permissionGroupIDs = nil
|
||||
@@ -190,10 +222,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||
StorageQuotaMB *int `json:"storageQuotaMb"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -218,16 +252,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
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 requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
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" || role == "admin")) {
|
||||
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
@@ -239,6 +286,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" && disabled {
|
||||
badRequest(w, errors.New("唯一管理员不能停用"))
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||
if req.MailboxLimitOverride != nil {
|
||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
@@ -250,6 +301,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
var storageQuotaMB int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
|
||||
return
|
||||
}
|
||||
if req.StorageQuotaMB != nil {
|
||||
storageQuotaMB = *req.StorageQuotaMB
|
||||
}
|
||||
if storageQuotaMB < 100 {
|
||||
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -294,14 +357,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
|
||||
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("登录名已被使用"))
|
||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
@@ -317,6 +376,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")
|
||||
@@ -391,11 +455,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if a.isDefaultAdminUser(target) {
|
||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
||||
return
|
||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
||||
} else if target.Role == "admin" {
|
||||
badRequest(w, errors.New("administrator accounts cannot be deleted"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||
@@ -535,6 +596,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
m.CreatedAt = parseTime(created)
|
||||
items = append(items, m)
|
||||
}
|
||||
markPrimaryMailboxes(items)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
@@ -545,7 +607,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Role string `json:"role"`
|
||||
OwnerLoginName string `json:"ownerLoginName"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -562,25 +623,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if req.QuotaMB < 0 {
|
||||
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||
return
|
||||
}
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "user" && role != "admin" {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
@@ -596,15 +643,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
displayName := req.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = address
|
||||
}
|
||||
var disabled, ownerStorageQuotaMB int
|
||||
var passwordHash, ownerRole string
|
||||
if userID != "" {
|
||||
var disabled int
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "owner user not found")
|
||||
} else {
|
||||
@@ -612,31 +658,34 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if intBool(disabled) {
|
||||
badRequest(w, errors.New("owner user is disabled"))
|
||||
} else {
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
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,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if hashErr != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
userID = newID("usr")
|
||||
ownerDisplayName := displayName
|
||||
if !strings.EqualFold(ownerLoginName, address) {
|
||||
ownerDisplayName = ownerLoginName
|
||||
}
|
||||
_, 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)
|
||||
if err != nil {
|
||||
passwordHash = string(hash)
|
||||
ownerRole = "user"
|
||||
ownerStorageQuotaMB = defaultUserStorageQuotaMB
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -645,16 +694,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
|
||||
if intBool(disabled) {
|
||||
badRequest(w, errors.New("owner user is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
|
||||
quotaMB := req.QuotaMB
|
||||
if quotaMB == 0 {
|
||||
quotaMB = ownerStorageQuotaMB
|
||||
}
|
||||
if ownerRole == "admin" {
|
||||
quotaMB = 0
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||
return
|
||||
}
|
||||
m, err := a.mailboxByID(r.Context(), mailboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
@@ -680,8 +739,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
if req.QuotaMB <= 0 {
|
||||
req.QuotaMB = 1024
|
||||
if req.QuotaMB < 0 {
|
||||
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
@@ -691,13 +751,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
existingMailbox, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
if existingMailbox.Primary && status != existingMailbox.Status {
|
||||
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if userID == "" {
|
||||
badRequest(w, errors.New("userId is required"))
|
||||
return
|
||||
}
|
||||
if existingMailbox.Primary && userID != existingMailbox.UserID {
|
||||
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||
return
|
||||
}
|
||||
var disabled int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
var ownerRole, ownerPasswordHash string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "owner user not found")
|
||||
} else {
|
||||
@@ -709,8 +783,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("owner user is disabled"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if ownerRole == "admin" {
|
||||
req.QuotaMB = 0
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||
return
|
||||
@@ -730,26 +807,17 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
} else {
|
||||
badRequest(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if current != nil && owner == current.ID {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -765,12 +833,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
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})
|
||||
@@ -1116,15 +1184,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
||||
}
|
||||
|
||||
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
WHERE u.id=?
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created, mailboxCSV string
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
@@ -1189,9 +1257,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt = parseTime(created)
|
||||
if err := a.markMailboxPrimary(ctx, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func markPrimaryMailboxes(items []Mailbox) {
|
||||
primaryByUser := make(map[string]int)
|
||||
for i := range items {
|
||||
candidate, ok := primaryByUser[items[i].UserID]
|
||||
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
|
||||
primaryByUser[items[i].UserID] = i
|
||||
}
|
||||
}
|
||||
for _, index := range primaryByUser {
|
||||
items[index].Primary = true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
|
||||
var primaryID string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
|
||||
WHERE mb.user_id=?
|
||||
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailbox.Primary = mailbox.ID == primaryID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
|
||||
mailbox, err := a.mailboxByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mailbox.Primary {
|
||||
return errors.New("用户默认邮箱不能删除")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
||||
var m Mailbox
|
||||
|
||||
+495
-119
@@ -23,18 +23,32 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
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
|
||||
backupMu sync.Mutex
|
||||
backupJob *backupJob
|
||||
backupTransfers map[string]*backupTransfer
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUserStorageQuotaMB = 100
|
||||
defaultAdminStorageQuotaMB = 1024
|
||||
minimumStorageQuotaMB = 100
|
||||
)
|
||||
|
||||
func (a *App) config() Config {
|
||||
a.cfgMu.RLock()
|
||||
defer a.cfgMu.RUnlock()
|
||||
@@ -70,7 +84,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{}, backupTransfers: map[string]*backupTransfer{}}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
@@ -92,6 +106,22 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||
@@ -102,6 +132,8 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +182,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
mailbox_limit_override INTEGER,
|
||||
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -185,6 +218,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,
|
||||
@@ -291,6 +332,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,
|
||||
@@ -420,6 +462,20 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_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;
|
||||
@@ -632,6 +688,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUserStorageQuota(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -650,6 +709,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
|
||||
}
|
||||
@@ -662,12 +724,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
|
||||
@@ -939,81 +1106,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.config().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
|
||||
}
|
||||
|
||||
@@ -1256,6 +1353,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasColumn := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "storage_quota_mb" {
|
||||
hasColumn = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
@@ -1412,7 +1542,11 @@ func (a *App) seed(ctx context.Context) error {
|
||||
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 := cfg.AdminPassword
|
||||
@@ -1430,25 +1564,8 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
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, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(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 {
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||
@@ -1469,7 +1586,7 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Create mailbox for admin
|
||||
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
|
||||
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1483,18 +1600,221 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
|
||||
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
LoginName string `json:"loginName,omitempty"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
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
|
||||
}
|
||||
adminEmail := normalizeEmail(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail)
|
||||
var item adminUser
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||
admins = append(admins, item)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername := normalizeLoginName(cfg.AdminUsername)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername)
|
||||
var item adminUser
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||
admins = append(admins, item)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
return errors.New("no administrator user found for identity migration")
|
||||
}
|
||||
keeper := admins[0]
|
||||
adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var conflictID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil {
|
||||
return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
demoted := []adminUser{}
|
||||
for _, admin := range admins[1:] {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
admin.PasswordHash = ""
|
||||
demoted = append(demoted, admin)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`,
|
||||
adminEmail, adminEmail, now, keeper.ID); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return fmt.Errorf("admin identity migration conflict: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
parts := strings.SplitN(adminEmail, "@", 2)
|
||||
localPart := parts[0]
|
||||
domainName := normalizeDomain(parts[1])
|
||||
var domainID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
domainID, err = a.createDomainTx(ctx, tx, domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mailboxCreated := false
|
||||
var mailboxID, mailboxUserID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxCreated = true
|
||||
} else if mailboxUserID != keeper.ID {
|
||||
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
|
||||
}
|
||||
result := map[string]any{
|
||||
"adminUserId": keeper.ID,
|
||||
"adminEmail": adminEmail,
|
||||
"emailSource": emailSource,
|
||||
"previousEmail": keeper.Email,
|
||||
"demotedAdmins": demoted,
|
||||
"mailboxId": mailboxID,
|
||||
"mailboxCreated": mailboxCreated,
|
||||
"migratedAt": now,
|
||||
}
|
||||
raw, _ := json.Marshal(result)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.updateConfig(func(current *Config) {
|
||||
current.AdminEmail = adminEmail
|
||||
if current.MailDomain == "" {
|
||||
current.MailDomain = domainName
|
||||
}
|
||||
})
|
||||
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
|
||||
// Once initialized, the database identity is authoritative. This keeps an
|
||||
// administrator email changed in the UI from reverting to the installer value.
|
||||
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
|
||||
return email, "existing_admin_email", nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.AdminEmail) != "" {
|
||||
email, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
|
||||
}
|
||||
return email, "configured_admin_email", nil
|
||||
}
|
||||
|
||||
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = normalizeLocalPart(loginName)
|
||||
}
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = "admin"
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for rows.Next() {
|
||||
var address string
|
||||
if err := rows.Scan(&address); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if email, err := cleanPrimaryEmail(address); err == nil {
|
||||
rows.Close()
|
||||
return email, "existing_admin_mailbox", nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
|
||||
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
|
||||
}
|
||||
var onlyDomain string
|
||||
var domainCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if domainCount == 1 && validMailDomain(onlyDomain) {
|
||||
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
|
||||
}
|
||||
publicDomain := normalizeDomain(cfg.PublicHostname)
|
||||
if strings.HasPrefix(publicDomain, "mail.") {
|
||||
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
|
||||
}
|
||||
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
|
||||
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
|
||||
}
|
||||
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
|
||||
}
|
||||
|
||||
func validMailDomain(domain string) bool {
|
||||
domain = normalizeDomain(domain)
|
||||
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
|
||||
}
|
||||
|
||||
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1549,6 +1869,30 @@ func defaultFolderDefs() []struct{ name, role string } {
|
||||
}
|
||||
}
|
||||
|
||||
type defaultMailLabel struct {
|
||||
name string
|
||||
color string
|
||||
}
|
||||
|
||||
func defaultMailLabelDefs() []defaultMailLabel {
|
||||
return []defaultMailLabel{
|
||||
{name: "个人", color: "#10b981"},
|
||||
{name: "家人", color: "#ec4899"},
|
||||
{name: "朋友", color: "#06b6d4"},
|
||||
{name: "工作", color: "#3b82f6"},
|
||||
{name: "重要", color: "#f59e0b"},
|
||||
}
|
||||
}
|
||||
|
||||
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
|
||||
for _, label := range defaultMailLabelDefs() {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -1578,8 +1922,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
||||
if localPart == "" {
|
||||
return "", errors.New("invalid local part")
|
||||
}
|
||||
if quotaMB <= 0 {
|
||||
quotaMB = 1024
|
||||
if quotaMB < 0 {
|
||||
return "", errors.New("quotaMb must be zero or greater")
|
||||
}
|
||||
var ownerRole string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ownerRole == "admin" {
|
||||
quotaMB = 0
|
||||
}
|
||||
if status == "" {
|
||||
status = "active"
|
||||
@@ -1606,9 +1957,23 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
|
||||
SET quota_mb=0, updated_at=?
|
||||
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
cfg := a.config()
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||
@@ -1616,13 +1981,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
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: cfg.AdminEmail,
|
||||
From: "system@lanqin.local",
|
||||
From: systemAddress,
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Time: now,
|
||||
@@ -1633,9 +2009,9 @@ 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{cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
|
||||
+1204
-50
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,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(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
|
||||
@@ -107,8 +109,8 @@ 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
|
||||
}
|
||||
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
@@ -138,8 +171,14 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,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 {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
return
|
||||
@@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -156,38 +207,6 @@ 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.config().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})
|
||||
}
|
||||
|
||||
@@ -205,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"`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
||||
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
|
||||
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
|
||||
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
|
||||
}))
|
||||
defer telegramServer.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = telegramServer.URL
|
||||
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
|
||||
t.Fatalf("unexpected groups: %+v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != int64(len("encrypted backup")) {
|
||||
t.Fatalf("upload size = %d", size)
|
||||
}
|
||||
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||
}
|
||||
var metadata struct {
|
||||
Name string `json:"name"`
|
||||
Parents []string `json:"parents"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||
t.Fatalf("metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||
var updates []int64
|
||||
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||
updates = append(updates, uploaded)
|
||||
}}
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil || string(raw) != "encrypted backup" {
|
||||
t.Fatalf("read = %q, %v", raw, err)
|
||||
}
|
||||
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||
t.Fatalf("progress updates = %v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||
if !strings.Contains(message, test.want) {
|
||||
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||
size := int64(googleDriveUploadChunkSize + 3)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = file.Close()
|
||||
var ranges []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
if len(ranges) == 1 {
|
||||
w.WriteHeader(http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
if !a.startBackupTransfer("googleDrive", path) {
|
||||
t.Fatal("failed to start transfer")
|
||||
}
|
||||
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRanges := []string{
|
||||
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||
}
|
||||
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||
}
|
||||
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||
if transfer == nil || transfer.Uploaded != size {
|
||||
t.Fatalf("transfer = %+v", transfer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
|
||||
})
|
||||
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
|
||||
t.Fatal("backup password encryption succeeded without a deployment secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordValidation(t *testing.T) {
|
||||
for _, valid := range []string{"12345678", "Restore Password 123!"} {
|
||||
if !validBackupPassword(valid) {
|
||||
t.Errorf("valid password rejected: %q", valid)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
|
||||
if validBackupPassword(invalid) {
|
||||
t.Errorf("invalid password accepted: %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordHint(t *testing.T) {
|
||||
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||
t.Fatalf("password hint = %q", got)
|
||||
}
|
||||
if got := backupPasswordHint("ab"); got != "ab" {
|
||||
t.Fatalf("two-character password hint = %q", got)
|
||||
}
|
||||
if got := backupPasswordHint(""); got != "" {
|
||||
t.Fatalf("empty password hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "A23456789Z" {
|
||||
t.Fatalf("saved password = %q, %v", password, err)
|
||||
}
|
||||
schedule, err := a.loadBackupSchedule(context.Background())
|
||||
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for key, value := range map[string]string{
|
||||
"backupScheduleEnabled": "true",
|
||||
"backupScheduleDays": "30",
|
||||
"backupTelegramMode": "custom",
|
||||
"backupTelegramChatId": "-1001234567890",
|
||||
"backupGoogleFolderName": "Existing Backups",
|
||||
} {
|
||||
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("password update code=%d body=%v", code, response)
|
||||
}
|
||||
if response["passwordHint"] != "N••••••••••9" {
|
||||
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "NewSharedPassword9" {
|
||||
t.Fatalf("saved password = %q, %v", password, err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"backupScheduleEnabled": "true",
|
||||
"backupScheduleDays": "30",
|
||||
"backupTelegramMode": "custom",
|
||||
"backupTelegramChatId": "-1001234567890",
|
||||
"backupGoogleFolderName": "Existing Backups",
|
||||
} {
|
||||
var got string
|
||||
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deployDir := filepath.Join(dir, "deploy")
|
||||
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var response map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, response)
|
||||
}
|
||||
response = nil
|
||||
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||
}
|
||||
password, err := a.savedBackupPassword(context.Background())
|
||||
if err != nil || password != "SharedBackupPassword9" {
|
||||
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
a.backupMu.Lock()
|
||||
status := a.backupJob.Status
|
||||
a.backupMu.Unlock()
|
||||
if status != "running" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("manual backup did not finish before timeout")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicServerIPValidation(t *testing.T) {
|
||||
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||
if !isPublicIP(net.ParseIP(value)) {
|
||||
t.Errorf("public IP rejected: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
|
||||
if isPublicIP(net.ParseIP(value)) {
|
||||
t.Errorf("non-public IP accepted: %s", value)
|
||||
}
|
||||
}
|
||||
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
|
||||
t.Fatalf("literal public IP = %q", got)
|
||||
}
|
||||
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
|
||||
t.Fatalf("literal private IP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRuntimeBackupEnv(t *testing.T) {
|
||||
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
|
||||
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
|
||||
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
|
||||
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
|
||||
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := writeRuntimeBackupEnv(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := string(raw)
|
||||
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
|
||||
if !strings.Contains(contents, expected) {
|
||||
t.Errorf("backup environment missing %q: %s", expected, contents)
|
||||
}
|
||||
}
|
||||
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
|
||||
if strings.Contains(contents, excluded) {
|
||||
t.Fatalf("backup environment included excluded value %q", excluded)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||
})
|
||||
stopTestWorkers(a)
|
||||
if !a.backupAssetsAvailable() {
|
||||
t.Fatal("bundled compose did not enable complete backups")
|
||||
}
|
||||
if err := os.Remove(compose); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.backupAssetsAvailable() {
|
||||
t.Fatal("missing bundled compose incorrectly enabled complete backups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||
})
|
||||
|
||||
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
|
||||
if err != nil || ciphertext == "BackupPassword123!" {
|
||||
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
|
||||
}
|
||||
plain, err := a.decryptBackupPassword(ciphertext)
|
||||
if err != nil || plain != "BackupPassword123!" {
|
||||
t.Fatalf("password decryption = %q, %v", plain, err)
|
||||
}
|
||||
if !validTelegramPrivateChatID("-1001234567890") {
|
||||
t.Fatal("private Telegram group chat ID was rejected")
|
||||
}
|
||||
|
||||
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
|
||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := a.backupTelegramReport(context.Background(), path, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
|
||||
if !strings.Contains(report, expected) {
|
||||
t.Errorf("report missing %q: %s", expected, report)
|
||||
}
|
||||
}
|
||||
if strings.Contains(report, "newszxcn.com(管理员)") {
|
||||
t.Fatal("domain list incorrectly contains account role")
|
||||
}
|
||||
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
|
||||
t.Fatal("report leaked a password")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
MailDomain string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
@@ -51,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
|
||||
@@ -60,6 +67,8 @@ type Config struct {
|
||||
ReleaseAPIURL string
|
||||
UpdateServiceURL string
|
||||
UpdateServiceToken string
|
||||
BackupSourceDir string
|
||||
BackupDir string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@@ -72,7 +81,8 @@ func LoadConfig() Config {
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
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"),
|
||||
@@ -108,6 +118,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", ""),
|
||||
@@ -117,6 +133,8 @@ func LoadConfig() Config {
|
||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
|
||||
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
m.CreatedAt = parseTime(created)
|
||||
items = append(items, m)
|
||||
}
|
||||
markPrimaryMailboxes(items)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
ORDER BY CASE
|
||||
WHEN lower(f.name)='inbox' THEN 1000
|
||||
WHEN lower(f.name)='sent' THEN 5000
|
||||
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -264,13 +267,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 +284,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 +340,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 +423,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 +631,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
|
||||
return
|
||||
}
|
||||
folder := r.URL.Query().Get("folder")
|
||||
@@ -982,6 +1217,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -995,6 +1231,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -1006,12 +1243,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = rcptMailbox.ID
|
||||
copyMsg.FolderID = inboxID
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
||||
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
|
||||
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2007,15 +2248,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
|
||||
@@ -2311,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
|
||||
return nil
|
||||
}
|
||||
var quotaMB int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
|
||||
var userID string
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan("aMB, &userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if quotaMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
var used int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaBytes := quotaMB * 1024 * 1024
|
||||
@@ -2382,7 +2692,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
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 +2709,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
}
|
||||
|
||||
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
|
||||
FROM mail_labels l
|
||||
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE mb.user_id=? AND mb.status='active'
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, userID)
|
||||
GROUP BY lower(l.name)
|
||||
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2425,7 +2735,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
||||
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 +2766,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
|
||||
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
|
||||
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
|
||||
ORDER BY lower(l.name)`, args...)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2474,6 +2784,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func mailLabelOrderSQL(alias string) string {
|
||||
return mailLabelNameOrderSQL(alias + `.name`)
|
||||
}
|
||||
|
||||
func mailLabelNameOrderSQL(expression string) string {
|
||||
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
|
||||
}
|
||||
|
||||
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
|
||||
name = normalizeLabelName(name)
|
||||
if name == "" {
|
||||
@@ -2517,6 +2835,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
|
||||
var name string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if len([]rune(name)) > 32 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translatedHTMLResult := make(chan string, 1)
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
go func() {
|
||||
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||
translatedHTMLResult <- translatedHTML
|
||||
}()
|
||||
} else {
|
||||
translatedHTMLResult <- ""
|
||||
}
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
translatedHTML := ""
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||
}
|
||||
translatedHTML := <-translatedHTMLResult
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translatedHTMLResult := make(chan string, 1)
|
||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||
go func() {
|
||||
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||
translatedHTMLResult <- translatedHTML
|
||||
}()
|
||||
} else {
|
||||
translatedHTMLResult <- ""
|
||||
}
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
translatedHTML := ""
|
||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||
}
|
||||
translatedHTML := <-translatedHTMLResult
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
|
||||
}
|
||||
|
||||
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
|
||||
|
||||
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
|
||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type translationJob struct {
|
||||
node *html.Node
|
||||
original string
|
||||
text string
|
||||
}
|
||||
remaining := maxChars
|
||||
var translateNode func(*html.Node) error
|
||||
translateNode = func(n *html.Node) error {
|
||||
jobs := make([]translationJob, 0)
|
||||
var collect func(*html.Node)
|
||||
collect = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||
limited, _ := truncateRunes(text, remaining)
|
||||
remaining -= utf8.RuneCountInString(limited)
|
||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
||||
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := translateNode(c); err != nil {
|
||||
return err
|
||||
}
|
||||
collect(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if err := translateNode(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
collect(n)
|
||||
}
|
||||
results := make([]string, len(jobs))
|
||||
jobIndexes := make(chan int)
|
||||
errCh := make(chan error, 1)
|
||||
workers := min(4, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for index := range jobIndexes {
|
||||
translated, _, translateErr := translator(ctx, jobs[index].text, target)
|
||||
if translateErr != nil {
|
||||
select {
|
||||
case errCh <- translateErr:
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
results[index] = translated
|
||||
}
|
||||
}()
|
||||
}
|
||||
for index := range jobs {
|
||||
jobIndexes <- index
|
||||
}
|
||||
close(jobIndexes)
|
||||
wg.Wait()
|
||||
select {
|
||||
case translateErr := <-errCh:
|
||||
return "", translateErr
|
||||
default:
|
||||
}
|
||||
for index, job := range jobs {
|
||||
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, n := range nodes {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||
raw := []any{
|
||||
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
|
||||
translator := func(_ context.Context, text, target string) (string, string, error) {
|
||||
return strings.ToUpper(text) + "-" + target, "en", nil
|
||||
}
|
||||
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
|
||||
t.Fatalf("translated HTML = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<pre>keep me</pre>`) {
|
||||
t.Fatalf("code block was translated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
got, truncated := truncateRunes("你好world", 4)
|
||||
if got != "你好wo" || !truncated {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var mailboxes struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
if !mailboxes.Items[0].Primary {
|
||||
t.Fatal("administrator mailbox should be marked as primary")
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
|
||||
t.Fatalf("delete primary mailbox code=%d", code)
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
var me map[string]any
|
||||
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("account was not preserved code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/ianaindex"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type maildirMailbox struct {
|
||||
@@ -292,7 +293,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err == nil {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
@@ -333,6 +337,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
if strings.TrimSpace(msg.RecipientAddr) == "" {
|
||||
msg.RecipientAddr = mb.Address
|
||||
}
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
@@ -367,6 +374,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
||||
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
||||
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
||||
if a.shouldNotifyTelegramMessage(ctx, id) {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -585,6 +595,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
|
||||
return address
|
||||
}
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
address = normalizeEmail(address)
|
||||
if strings.HasSuffix(address, "@"+domain) {
|
||||
@@ -609,11 +622,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
if len(to) == 0 {
|
||||
to = []string{fallbackTo}
|
||||
}
|
||||
recipientAddr := originalMailRecipient(m.Header)
|
||||
sentAt := parseMailDate(m.Header.Get("Date"))
|
||||
parsed := &parsedMail{}
|
||||
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
||||
return storedMessage{}, nil, err
|
||||
}
|
||||
if looksLikeHTMLDocument(parsed.Text) {
|
||||
if strings.TrimSpace(parsed.HTML) == "" {
|
||||
parsed.HTML = parsed.Text
|
||||
}
|
||||
parsed.Text = telegramHTMLToText(parsed.Text)
|
||||
}
|
||||
bodyHTML := a.policy.Sanitize(parsed.HTML)
|
||||
bodyText := parsed.Text
|
||||
if strings.TrimSpace(bodyText) == "" {
|
||||
@@ -629,6 +649,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
RecipientAddr: recipientAddr,
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
@@ -644,6 +665,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
func originalMailRecipient(header netmail.Header) string {
|
||||
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
|
||||
value := strings.TrimSpace(header.Get(key))
|
||||
if key == "Original-Recipient" {
|
||||
if _, suffix, ok := strings.Cut(value, ";"); ok {
|
||||
value = strings.TrimSpace(suffix)
|
||||
}
|
||||
}
|
||||
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
|
||||
return address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
|
||||
contentType := header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
@@ -682,6 +718,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
|
||||
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
|
||||
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 == "" {
|
||||
@@ -777,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
|
||||
if charset == "utf-8" || charset == "us-ascii" {
|
||||
return input, nil
|
||||
}
|
||||
// GB2312 is commonly used as a label for GBK-compatible mail content.
|
||||
// ianaindex does not consistently resolve these real-world aliases.
|
||||
switch charset {
|
||||
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
|
||||
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
|
||||
case "gb18030":
|
||||
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
|
||||
}
|
||||
enc, err := ianaindex.IANA.Encoding(charset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
|
||||
|
||||
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
if req.QuotaMB < 0 {
|
||||
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
respondMailboxOwnerError(w, err)
|
||||
return
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
|
||||
var ownerPasswordHash, ownerRole string
|
||||
var ownerStorageQuotaMB int
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||
return
|
||||
}
|
||||
quotaMB := req.QuotaMB
|
||||
if quotaMB == 0 {
|
||||
quotaMB = ownerStorageQuotaMB
|
||||
}
|
||||
if ownerRole == "admin" {
|
||||
quotaMB = 0
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
displayName = current.DisplayName
|
||||
}
|
||||
quotaMB := req.QuotaMB
|
||||
if quotaMB <= 0 {
|
||||
if quotaMB < 0 {
|
||||
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||
return
|
||||
}
|
||||
if quotaMB == 0 {
|
||||
quotaMB = current.QuotaMB
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
if userID == "" {
|
||||
userID = current.UserID
|
||||
}
|
||||
if current.Primary && userID != current.UserID {
|
||||
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||
return
|
||||
}
|
||||
if current.Primary && status != current.Status {
|
||||
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
||||
respondMailboxOwnerError(w, err)
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
var ownerRole, ownerPasswordHash string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||
return
|
||||
}
|
||||
if ownerRole == "admin" {
|
||||
quotaMB = 0
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||
return
|
||||
@@ -318,26 +355,17 @@ 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")
|
||||
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
} else {
|
||||
badRequest(w, err)
|
||||
}
|
||||
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 +381,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
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})
|
||||
@@ -826,7 +854,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
|
||||
}
|
||||
@@ -838,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
|
||||
@@ -142,12 +142,13 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var existingSystem int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
||||
var existingName, existingDescription string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
if intBool(existingSystem) && id != PermissionGroupRegular {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if id == PermissionGroupRegular {
|
||||
name = existingName
|
||||
req.Description = existingDescription
|
||||
}
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
|
||||
@@ -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,
|
||||
@@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
return u != nil && u.Role == "admin"
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
|
||||
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
||||
return
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
|
||||
quotaMB := defaultUserStorageQuotaMB
|
||||
if user.Role == "admin" {
|
||||
quotaMB = 0
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
||||
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
appliedCount := int64(0)
|
||||
if req.ApplyToExisting && enabled {
|
||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
if req.ApplyToExisting {
|
||||
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||
return
|
||||
}
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||
item, err := scanRule(row)
|
||||
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
} else {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||
continue
|
||||
}
|
||||
if rule.StopProcessing {
|
||||
break
|
||||
}
|
||||
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
parts = append(parts, filename)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
if !validRuleConditionOperator(field, operator) {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func validRuleConditionOperator(field, operator string) bool {
|
||||
switch field {
|
||||
case "size":
|
||||
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||
case "date":
|
||||
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||
default:
|
||||
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := normalizeFolderNameForUser(action.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderID string
|
||||
if isSystemFolderName(target) {
|
||||
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||
} else {
|
||||
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "star":
|
||||
starred := true
|
||||
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
||||
return err
|
||||
}
|
||||
|
||||
func ruleTargetFolder(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "inbox":
|
||||
return "Inbox"
|
||||
case "archive":
|
||||
return "Archive"
|
||||
case "spam":
|
||||
return "Spam"
|
||||
case "trash":
|
||||
return "Trash"
|
||||
default:
|
||||
return "Archive"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||
args := []any{userID}
|
||||
where := `mb.user_id=?`
|
||||
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -138,6 +139,20 @@ func (a *App) Router() http.Handler {
|
||||
r.Use(a.requireAdminAccess)
|
||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||
r.Get("/admin/backups", a.handleListBackups)
|
||||
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
|
||||
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
|
||||
r.Post("/admin/backups", a.handleCreateBackup)
|
||||
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
|
||||
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
|
||||
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
|
||||
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
|
||||
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
|
||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||
@@ -170,6 +185,9 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(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)
|
||||
@@ -344,10 +362,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
|
||||
|
||||
@@ -40,6 +40,12 @@ type SystemSettings struct {
|
||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type systemSettingsUpdate struct {
|
||||
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
|
||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotToken string `json:"telegramBotToken"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
@@ -127,6 +139,8 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
var req systemSettingsUpdate
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -204,8 +218,32 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||
return
|
||||
}
|
||||
next.TelegramMailEnabled = req.TelegramMailEnabled
|
||||
if strings.TrimSpace(req.TelegramBotToken) != "" {
|
||||
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
|
||||
}
|
||||
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
|
||||
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
|
||||
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
|
||||
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
|
||||
if next.TelegramMailEnabled {
|
||||
if next.TelegramBotToken == "" {
|
||||
badRequest(w, errors.New("Telegram Bot Token 未设置"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
|
||||
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
|
||||
return
|
||||
}
|
||||
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
|
||||
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||
previous := a.config()
|
||||
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
|
||||
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||
return
|
||||
}
|
||||
@@ -318,6 +356,12 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
TelegramMailEnabled: cfg.TelegramMailEnabled,
|
||||
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
|
||||
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
|
||||
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +446,18 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg.ExternalIMAPOutlookClientID = value
|
||||
case "externalImapOutlookClientSecret":
|
||||
cfg.ExternalIMAPOutlookClientSecret = value
|
||||
case "telegramMailEnabled":
|
||||
cfg.TelegramMailEnabled = value == "true"
|
||||
case "telegramBotToken":
|
||||
cfg.TelegramBotToken = value
|
||||
case "telegramPrivateChatId":
|
||||
cfg.TelegramPrivateChatID = value
|
||||
case "telegramBodyMode":
|
||||
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
|
||||
case "telegramMailboxIds":
|
||||
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
|
||||
case "telegramIncludeUnregistered":
|
||||
cfg.TelegramIncludeUnregistered = value == "true"
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -411,7 +467,7 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
|
||||
values := map[string]string{
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
@@ -443,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
||||
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
|
||||
"telegramBotToken": cfg.TelegramBotToken,
|
||||
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
|
||||
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
|
||||
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
@@ -456,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if clearPendingTelegram {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
|
||||
type sentMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ReplyMarkup map[string]any `json:"reply_markup"`
|
||||
}
|
||||
var sent []sentMessage
|
||||
var pairingCode atomic.Value
|
||||
pairingCode.Store("")
|
||||
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/bottest-token/getMe":
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
|
||||
case "/bottest-token/getUpdates":
|
||||
code, _ := pairingCode.Load().(string)
|
||||
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
|
||||
case "/bottest-token/sendMessage":
|
||||
var message sentMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
t.Fatalf("decode Telegram message: %v", err)
|
||||
}
|
||||
sent = append(sent, message)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer telegramServer.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = telegramServer.URL
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
payload["telegramBotToken"] = "test-token"
|
||||
payload["telegramPrivateChatId"] = "123456789"
|
||||
payload["telegramBodyMode"] = "full"
|
||||
var adminMailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["telegramMailboxIds"] = []string{adminMailboxID}
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
|
||||
}
|
||||
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
|
||||
t.Fatalf("unexpected Telegram settings: %+v", settings)
|
||||
}
|
||||
if a.config().TelegramBotToken != "test-token" {
|
||||
t.Fatal("Telegram token was not persisted in runtime config")
|
||||
}
|
||||
|
||||
var pairing struct {
|
||||
Code string `json:"code"`
|
||||
DeepLink string `json:"deepLink"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
|
||||
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
|
||||
}
|
||||
pairingCode.Store(pairing.Code)
|
||||
var discovered map[string]string
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
|
||||
t.Fatalf("discover chat code=%d response=%v", code, discovered)
|
||||
}
|
||||
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
|
||||
t.Fatalf("unexpected discovered chat: %v", discovered)
|
||||
}
|
||||
var testResult map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
|
||||
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
|
||||
}
|
||||
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
|
||||
t.Fatalf("unexpected Telegram test message: %+v", sent)
|
||||
}
|
||||
|
||||
sent = nil
|
||||
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
|
||||
MailboxID: adminMailboxID,
|
||||
RecipientAddr: "admin@example.com",
|
||||
Subject: "账单 <已生成>",
|
||||
From: "billing@example.net",
|
||||
FromName: "Billing & Support",
|
||||
ReceivedAt: receivedAt,
|
||||
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
|
||||
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatalf("process Telegram mail queue: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
|
||||
}
|
||||
text := sent[0].Text
|
||||
for _, expected := range []string{"新邮件通知", "Billing & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "<VIP> & 续费信息"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
|
||||
}
|
||||
}
|
||||
if sent[0].ReplyMarkup == nil {
|
||||
t.Fatal("Telegram OTP copy button was not included")
|
||||
}
|
||||
var delivered, storedPayload string
|
||||
var telegramMessageID int64
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
|
||||
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
|
||||
}
|
||||
if storedPayload != "{}" || telegramMessageID != 8 {
|
||||
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
|
||||
}
|
||||
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
|
||||
disablePayload := systemSettingsPayload(settings)
|
||||
disablePayload["telegramMailEnabled"] = false
|
||||
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("disable Telegram settings code=%d", code)
|
||||
}
|
||||
var pending int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
|
||||
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
var body map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
|
||||
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = serverURL
|
||||
const token = "123456:secret-token-value"
|
||||
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected Telegram network request to fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
|
||||
t.Fatalf("Telegram error exposed Bot Token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
|
||||
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
|
||||
cleaned := stripTelegramQuotedContent(body)
|
||||
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
|
||||
t.Fatalf("unexpected OTP %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
|
||||
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: strings.Repeat("R&D <team@example.com> ", 30),
|
||||
Recipient: "admin@example.com",
|
||||
Subject: strings.Repeat("超长主题 & <test> ", 50),
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: strings.Repeat("正文内容 & <重要> ", 1000),
|
||||
BodyMode: "full",
|
||||
OTP: "846981",
|
||||
AttachmentNames: []string{
|
||||
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
|
||||
},
|
||||
AttachmentCount: 12,
|
||||
})
|
||||
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
|
||||
t.Fatalf("Telegram HTML exceeds budget: %d", got)
|
||||
}
|
||||
if !strings.Contains(message.HTML, "&") || !strings.Contains(message.HTML, "<") || !strings.Contains(message.HTML, "<code>846981</code>") {
|
||||
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup == nil {
|
||||
t.Fatal("copy_text markup missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIQiyiOTPDetection(t *testing.T) {
|
||||
subject := "825534 是您的动态安全验证码"
|
||||
body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved"
|
||||
otp := detectTelegramOTP(subject, body)
|
||||
if otp != "825534" {
|
||||
t.Fatalf("iQiyi OTP not detected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp})
|
||||
if !strings.Contains(message.HTML, "<code>825534</code>") || telegramCopyMarkup(message.OTP) == nil {
|
||||
t.Fatalf("iQiyi OTP section or copy button missing: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
|
||||
body := `---------- Forwarded message ---------
|
||||
Date: 2026年8月6日周四 17:59
|
||||
Subject: 登录验证码 (https://www.gate.com)
|
||||
|
||||
Gate 检测到您的账号正试图从此 IP 获得登录验证码:
|
||||
IP: 87.83.105.229
|
||||
如为您本人登录,请输入如下验证码完成操作:
|
||||
311665
|
||||
如非本人操作,请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
|
||||
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
|
||||
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
|
||||
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
|
||||
})
|
||||
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
|
||||
t.Fatalf("normal URL was not linkified: %s", message.HTML)
|
||||
}
|
||||
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
|
||||
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
|
||||
}
|
||||
if strings.Contains(message.HTML, "<a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
|
||||
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
|
||||
}
|
||||
markup := telegramCopyMarkup(message.OTP)
|
||||
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
|
||||
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
|
||||
t.Fatalf("copy OTP button missing: %#v", markup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
|
||||
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
|
||||
text := telegramMessageBody(storedMessage{BodyText: pseudo})
|
||||
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
|
||||
t.Fatalf("pseudo HTML was not cleaned: %q", text)
|
||||
}
|
||||
|
||||
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
|
||||
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
retryAfter time.Duration
|
||||
}{
|
||||
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
|
||||
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
|
||||
var apiErr *telegramAPIError
|
||||
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
|
||||
t.Fatalf("unexpected Telegram error: %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
cfg.TelegramMailboxIDs = mailboxID
|
||||
})
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
|
||||
var count int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.RecipientAddr != "admin@lanqin.local" {
|
||||
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
|
||||
subject := "验证 Apple 账户电子邮件地址"
|
||||
body := "你的 Apple 验证码是 978534"
|
||||
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
|
||||
"To: admin@example.com\r\n" +
|
||||
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
|
||||
"Content-Type: text/plain; charset=gb2312\r\n" +
|
||||
"Content-Transfer-Encoding: base64\r\n\r\n" +
|
||||
base64.StdEncoding.EncodeToString(encodedBody))
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Subject != subject {
|
||||
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
|
||||
}
|
||||
if msg.BodyText != body {
|
||||
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
|
||||
return
|
||||
}
|
||||
if _, exists := payload["parse_mode"]; exists {
|
||||
t.Fatal("plain-text fallback still included parse_mode")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
|
||||
if err != nil || messageID != 99 || calls.Load() != 2 {
|
||||
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
})
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var badAttempts int
|
||||
var delivered string
|
||||
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
|
||||
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ type User struct {
|
||||
|
||||
type AdminUser struct {
|
||||
User
|
||||
MailboxCount int `json:"mailboxCount"`
|
||||
Mailboxes []string `json:"mailboxes"`
|
||||
MailboxCount int `json:"mailboxCount"`
|
||||
Mailboxes []string `json:"mailboxes"`
|
||||
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
@@ -71,6 +72,7 @@ type Mailbox struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Status string `json:"status"`
|
||||
Primary bool `json:"primary"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@@ -88,6 +90,7 @@ type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
|
||||
@@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) {
|
||||
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))
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.4.12",
|
||||
"dompurify": "3.4.13",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Mail } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
|
||||
<Mail className="size-6 stroke-[1.8]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -26,12 +26,12 @@ export function ConfirmDialog({
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||
<DialogFooter>
|
||||
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -28,14 +28,15 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "overview", label: "仪表盘", icon: <LayoutDashboard />, 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"] },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||
]
|
||||
|
||||
@@ -57,7 +58,7 @@ function ProtectedContent() {
|
||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
|
||||
|
||||
if (isMailRoute || isProfileRoute) {
|
||||
return <Outlet />
|
||||
@@ -72,9 +73,7 @@ function ProtectedContent() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<BrandMark className="size-8 rounded-md [&>svg]:size-5" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
@@ -108,17 +107,14 @@ 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" ? "管理员" : "普通用户"}
|
||||
</Badge>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="p-2">
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||
<LogOut className="h-3.5 w-3.5" />退出登录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -8,8 +8,9 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -519,7 +519,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[active=true]:hover:bg-[hsl(var(--sidebar-active))] data-[active=true]:hover:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -621,7 +621,7 @@ const SidebarMenuAction = React.forwardRef<
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))] md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -639,7 +639,7 @@ const SidebarMenuBadge = React.forwardRef<
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
|
||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
"group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
default: "border-border/80 bg-popover text-popover-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
|
||||
props.onClick?.(event)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
className={cn("break-words text-sm font-semibold leading-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -14,11 +14,11 @@ export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastProvider duration={5000} swipeDirection="right">
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
|
||||
@@ -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("请求超时")
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
const TOAST_LIMIT = 3
|
||||
const TOAST_REMOVE_DELAY = 1000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
|
||||
+91
-23
@@ -5,33 +5,41 @@
|
||||
@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%;
|
||||
--action-primary: 0 0% 12%;
|
||||
--action-primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 12%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 36%;
|
||||
--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%;
|
||||
--sidebar-active: 207 100% 92%;
|
||||
--sidebar-active-foreground: 202 100% 30%;
|
||||
--mail-selected: 210 100% 96%;
|
||||
--compose-send: 217 89% 43%;
|
||||
--compose-send-hover: 216 94% 32%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
@@ -60,10 +68,12 @@
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--action-primary: 240 4% 24%;
|
||||
--action-primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--muted-foreground: 240 5% 72%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
@@ -79,13 +89,28 @@
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-active: 210 48% 24%;
|
||||
--sidebar-active-foreground: 210 100% 88%;
|
||||
--mail-selected: 210 38% 20%;
|
||||
--compose-send: 217 89% 52%;
|
||||
--compose-send-hover: 214 94% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.compose-send-button.compose-send-button {
|
||||
background: hsl(var(--compose-send));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compose-send-button.compose-send-button:hover {
|
||||
background: hsl(var(--compose-send-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mail-shell-grid {
|
||||
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 {
|
||||
@@ -105,6 +130,11 @@
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.mail-selected-row {
|
||||
background-color: hsl(var(--mail-selected));
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mail-content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -139,12 +169,50 @@
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] {
|
||||
background: hsl(var(--sidebar-accent));
|
||||
background: hsl(var(--sidebar-accent) / 0.82);
|
||||
color: hsl(var(--sidebar-accent-foreground));
|
||||
box-shadow: inset 2px 0 0 hsl(var(--foreground));
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.admin-page [data-slot="card"] {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="card-header"] {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="card-content"] {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="card-title"] {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="table-header"] {
|
||||
background: hsl(var(--muted) / 0.48);
|
||||
}
|
||||
|
||||
.admin-page [data-slot="table-head"] {
|
||||
height: 2.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="table-cell"] {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-page [data-slot="table-row"]:hover {
|
||||
background: hsl(var(--muted) / 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,16 @@ export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
|
||||
export type AdminOverview = {
|
||||
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
|
||||
aliases: number; messages: number; unreadMessages: number; storageBytes: number
|
||||
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: number
|
||||
}
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailMessage = {
|
||||
@@ -202,6 +206,12 @@ export type SystemUpdateResult = {
|
||||
targetVersion: string
|
||||
message: string
|
||||
}
|
||||
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
||||
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
||||
export type BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
|
||||
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
||||
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
||||
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus; transfers: BackupTransfer[] }
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
@@ -233,10 +243,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 }[] }
|
||||
|
||||
+56
-24
@@ -1,9 +1,20 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, BackupList, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
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: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||
@@ -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; 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"),
|
||||
@@ -202,10 +211,25 @@ export const api = {
|
||||
},
|
||||
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
||||
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
||||
backups: () => request<BackupList>("/api/admin/backups"),
|
||||
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
|
||||
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }),
|
||||
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
|
||||
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
|
||||
disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }),
|
||||
verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }),
|
||||
sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }),
|
||||
sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }),
|
||||
deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
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 +245,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 +284,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 +328,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,8 +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: "Loading mailboxes..." },
|
||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
||||
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function safeReturnPath(value: unknown, fallback = "/"): string {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
|
||||
return value
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { LanguageDomSync } from "@/lib/language"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { ProtectedLayout } from "@/components/protected-layout"
|
||||
import { AdminOnly } from "@/components/admin-only"
|
||||
import "./index.css"
|
||||
@@ -15,6 +16,8 @@ const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ d
|
||||
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
||||
|
||||
applyTheme(getInitialTheme())
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
|
||||
+1035
-397
File diff suppressed because it is too large
Load Diff
@@ -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,13 @@ 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"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
|
||||
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 +25,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,11 +37,15 @@ 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">
|
||||
<div className="mb-7 flex items-center justify-center gap-3 text-center">
|
||||
<BrandMark className="size-11 [&>svg]:size-7" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
@@ -49,8 +57,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 +67,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 +90,6 @@ export function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
+692
-240
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user