Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dbc33b0dc | |||
| 48a1d53133 | |||
| ff5578368a | |||
| fc3a3462cf | |||
| 9a572e0100 | |||
| c92140c9cc | |||
| 2fd37bf635 | |||
| 3d3a251af2 | |||
| 6ad9164be3 | |||
| ce98978ebd | |||
| ee38990ea1 |
@@ -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)
|
||||||
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
|||||||
|
|
||||||
## 更新与回滚
|
## 更新与回滚
|
||||||
|
|
||||||
|
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
|
||||||
|
|
||||||
### 后台页面更新
|
### 后台页面更新
|
||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
|
||||||
ORDER BY 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`)
|
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||||
@@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
var created, mailboxCSV string
|
||||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
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) {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
@@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
|
||||||
|
var primaryDomainID string
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
|
||||||
|
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load account domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
permissionGroupIDs := req.PermissionGroupIDs
|
permissionGroupIDs := req.PermissionGroupIDs
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
permissionGroupIDs = nil
|
permissionGroupIDs = nil
|
||||||
@@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled *bool `json:"disabled"`
|
Disabled *bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB *int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
mailboxLimitOverride = nil
|
||||||
}
|
}
|
||||||
|
var storageQuotaMB int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.StorageQuotaMB != nil {
|
||||||
|
storageQuotaMB = *req.StorageQuotaMB
|
||||||
|
}
|
||||||
|
if storageQuotaMB < 100 {
|
||||||
|
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -311,8 +344,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
|
||||||
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||||
return
|
return
|
||||||
@@ -409,11 +442,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "user not found")
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
return
|
return
|
||||||
} else if a.isDefaultAdminUser(target) {
|
} else if target.Role == "admin" {
|
||||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
badRequest(w, errors.New("administrator accounts cannot be deleted"))
|
||||||
return
|
|
||||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
|
||||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||||
@@ -553,6 +583,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +594,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Role string `json:"role"`
|
|
||||||
OwnerLoginName string `json:"ownerLoginName"`
|
OwnerLoginName string `json:"ownerLoginName"`
|
||||||
OwnerEmail string `json:"ownerEmail"`
|
OwnerEmail string `json:"ownerEmail"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasMinimumPasswordLength(req.Password) {
|
userID := strings.TrimSpace(req.UserID)
|
||||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
if req.QuotaMB < 0 {
|
||||||
return
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
}
|
|
||||||
role := req.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "user"
|
|
||||||
}
|
|
||||||
if role != "user" && role != "admin" {
|
|
||||||
badRequest(w, errors.New("invalid role"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if role == "admin" {
|
|
||||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
|
||||||
userID := strings.TrimSpace(req.UserID)
|
|
||||||
displayName := req.DisplayName
|
displayName := req.DisplayName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = address
|
displayName = address
|
||||||
}
|
}
|
||||||
|
var disabled, ownerStorageQuotaMB int
|
||||||
|
var passwordHash, ownerRole string
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
var disabled int
|
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||||
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(disabled) {
|
} else {
|
||||||
badRequest(w, errors.New("owner user is disabled"))
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ownerEmailInput := req.OwnerEmail
|
ownerEmailInput := req.OwnerEmail
|
||||||
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||||
ownerEmailInput = req.OwnerLoginName
|
ownerEmailInput = req.OwnerLoginName
|
||||||
@@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if hashErr != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID = newID("usr")
|
userID = newID("usr")
|
||||||
ownerDisplayName := displayName
|
passwordHash = string(hash)
|
||||||
if !strings.EqualFold(ownerEmail, address) {
|
ownerRole = "user"
|
||||||
ownerDisplayName = ownerEmail
|
ownerStorageQuotaMB = defaultUserStorageQuotaMB
|
||||||
}
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
|
||||||
if err != nil {
|
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if intBool(disabled) {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
quotaMB := req.QuotaMB
|
||||||
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
|
if quotaMB == 0 {
|
||||||
|
quotaMB = ownerStorageQuotaMB
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||||
|
return
|
||||||
|
}
|
||||||
m, err := a.mailboxByID(r.Context(), mailboxID)
|
m, err := a.mailboxByID(r.Context(), mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||||
@@ -699,8 +726,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("displayName is required"))
|
badRequest(w, errors.New("displayName is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.QuotaMB <= 0 {
|
if req.QuotaMB < 0 {
|
||||||
req.QuotaMB = 1024
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid status"))
|
badRequest(w, errors.New("invalid status"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
existingMailbox, err := a.mailboxByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existingMailbox.Primary && status != existingMailbox.Status {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
userID := strings.TrimSpace(req.UserID)
|
userID := strings.TrimSpace(req.UserID)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
badRequest(w, errors.New("userId is required"))
|
badRequest(w, errors.New("userId is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existingMailbox.Primary && userID != existingMailbox.UserID {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
var disabled int
|
var disabled int
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
var ownerRole, ownerPasswordHash string
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("owner user is disabled"))
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
if ownerRole == "admin" {
|
||||||
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
req.QuotaMB = 0
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||||
|
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
return
|
||||||
@@ -749,6 +794,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
|
} else {
|
||||||
|
badRequest(w, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||||
@@ -1118,15 +1171,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||||
WHERE u.id=?
|
WHERE u.id=?
|
||||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
|
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
|
||||||
var item AdminUser
|
var item AdminUser
|
||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
var created, mailboxCSV string
|
||||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.Disabled = intBool(disabled)
|
item.Disabled = intBool(disabled)
|
||||||
@@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
|
if err := a.markMailboxPrimary(ctx, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markPrimaryMailboxes(items []Mailbox) {
|
||||||
|
primaryByUser := make(map[string]int)
|
||||||
|
for i := range items {
|
||||||
|
candidate, ok := primaryByUser[items[i].UserID]
|
||||||
|
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
|
||||||
|
primaryByUser[items[i].UserID] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, index := range primaryByUser {
|
||||||
|
items[index].Primary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
|
||||||
|
var primaryID string
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
|
||||||
|
WHERE mb.user_id=?
|
||||||
|
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailbox.Primary = mailbox.ID == primaryID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
|
||||||
|
mailbox, err := a.mailboxByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mailbox.Primary {
|
||||||
|
return errors.New("用户默认邮箱不能删除")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
|
|||||||
@@ -38,8 +38,16 @@ type App struct {
|
|||||||
telegramPairMu sync.Mutex
|
telegramPairMu sync.Mutex
|
||||||
telegramPairs map[string]telegramPairing
|
telegramPairs map[string]telegramPairing
|
||||||
telegramDeliveryMu sync.Mutex
|
telegramDeliveryMu sync.Mutex
|
||||||
|
backupMu sync.Mutex
|
||||||
|
backupJob *backupJob
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultUserStorageQuotaMB = 100
|
||||||
|
defaultAdminStorageQuotaMB = 1024
|
||||||
|
minimumStorageQuotaMB = 100
|
||||||
|
)
|
||||||
|
|
||||||
func (a *App) config() Config {
|
func (a *App) config() Config {
|
||||||
a.cfgMu.RLock()
|
a.cfgMu.RLock()
|
||||||
defer a.cfgMu.RUnlock()
|
defer a.cfgMu.RUnlock()
|
||||||
@@ -97,6 +105,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -120,6 +132,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||||
|
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +181,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
mailbox_limit_override INTEGER,
|
mailbox_limit_override INTEGER,
|
||||||
|
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
@@ -317,6 +331,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL DEFAULT 'folder',
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -672,6 +687,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateUserStorageQuota(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -690,6 +708,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1331,6 +1352,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasColumn := false
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notnull int
|
||||||
|
var dflt any
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name == "storage_quota_mb" {
|
||||||
|
hasColumn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasColumn {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1509,8 +1563,8 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
userID := newID("usr")
|
userID := newID("usr")
|
||||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
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, now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||||
@@ -1531,7 +1585,7 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create mailbox for admin
|
// Create mailbox for admin
|
||||||
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
|
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1652,7 +1706,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
|
|||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
|
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1867,8 +1921,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
if localPart == "" {
|
if localPart == "" {
|
||||||
return "", errors.New("invalid local part")
|
return "", errors.New("invalid local part")
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB < 0 {
|
||||||
quotaMB = 1024
|
return "", errors.New("quotaMb must be zero or greater")
|
||||||
|
}
|
||||||
|
var ownerRole string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
}
|
}
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = "active"
|
status = "active"
|
||||||
@@ -1901,6 +1962,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
|
||||||
|
SET quota_mb=0, updated_at=?
|
||||||
|
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||||
cfg := a.config()
|
cfg := a.config()
|
||||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||||
|
|||||||
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
|
|||||||
return mailbox
|
return mailbox
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, login)
|
||||||
|
}
|
||||||
|
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
|
||||||
|
adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB {
|
||||||
|
t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB)
|
||||||
|
}
|
||||||
|
domainID := mustDefaultDomainID(t, a)
|
||||||
|
|
||||||
|
var secondary Mailbox
|
||||||
|
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
|
||||||
|
"domainId": domainID,
|
||||||
|
"localPart": "admin-secondary",
|
||||||
|
"userId": adminUser.ID,
|
||||||
|
}, &secondary); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create admin secondary mailbox code=%d", code)
|
||||||
|
}
|
||||||
|
if secondary.QuotaMB != 0 {
|
||||||
|
t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryHash, secondaryHash string
|
||||||
|
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if primaryHash != secondaryHash {
|
||||||
|
t.Fatal("admin secondary mailbox did not inherit the owner password")
|
||||||
|
}
|
||||||
|
|
||||||
|
var regular AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "owner@lanqin.local",
|
||||||
|
"displayName": "Owner",
|
||||||
|
"password": "OwnerPassword123!",
|
||||||
|
"role": "user",
|
||||||
|
}, ®ular); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create regular owner code=%d", code)
|
||||||
|
}
|
||||||
|
if regular.MailboxCount != 1 {
|
||||||
|
t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount)
|
||||||
|
}
|
||||||
|
if regular.StorageQuotaMB != defaultUserStorageQuotaMB {
|
||||||
|
t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB)
|
||||||
|
}
|
||||||
|
if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil {
|
||||||
|
t.Fatalf("new account primary mailbox missing: %v", err)
|
||||||
|
}
|
||||||
|
var primaryStatusErr map[string]any
|
||||||
|
primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{
|
||||||
|
"userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled",
|
||||||
|
}, &primaryStatusErr); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr)
|
||||||
|
}
|
||||||
|
var regularMailbox Mailbox
|
||||||
|
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
|
||||||
|
"domainId": domainID,
|
||||||
|
"localPart": "owner-secondary",
|
||||||
|
"userId": regular.ID,
|
||||||
|
}, ®ularMailbox); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create regular secondary mailbox code=%d", code)
|
||||||
|
}
|
||||||
|
if regularMailbox.QuotaMB != defaultUserStorageQuotaMB {
|
||||||
|
t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB)
|
||||||
|
}
|
||||||
|
var userHash, mailboxHash string
|
||||||
|
if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if userHash != mailboxHash {
|
||||||
|
t.Fatal("regular secondary mailbox did not inherit the owner password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, login)
|
||||||
|
}
|
||||||
|
adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
|
||||||
|
var errBody map[string]any
|
||||||
|
if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("administrator account delete code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
|
||||||
|
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99,
|
||||||
|
}, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
var updatedAdmin AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
|
||||||
|
"email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100,
|
||||||
|
}, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 {
|
||||||
|
t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
var regular AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!",
|
||||||
|
}, ®ular); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create regular user code=%d user=%+v", code, regular)
|
||||||
|
}
|
||||||
|
primary, err := a.mailboxByAddress(context.Background(), regular.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID})
|
||||||
|
if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK {
|
||||||
|
t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
|
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
|
||||||
return createTestAPITokenWithScopes(t, client, name, nil)
|
return createTestAPITokenWithScopes(t, client, name, nil)
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1191,191 @@ func TestMailRulesForwardingAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMailRulesExactSenderCustomFolderAndStopProcessing(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := mustDefaultDomainID(t, a)
|
||||||
|
sender := createTestMailbox(t, admin, domainID, "rule-exact-sender", "Sender With Name", "Password123!", nil)
|
||||||
|
recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil)
|
||||||
|
|
||||||
|
rcpt := &testClient{t: t, server: ts}
|
||||||
|
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("recipient login=%d", code)
|
||||||
|
}
|
||||||
|
var bad map[string]any
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}},
|
||||||
|
"actions": []map[string]string{{"type": "archive"}},
|
||||||
|
}, &bad); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule := func(name string, action map[string]string, stop bool) {
|
||||||
|
t.Helper()
|
||||||
|
var rule MailRule
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"name": name,
|
||||||
|
"conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}},
|
||||||
|
"actions": []map[string]string{action},
|
||||||
|
"stopProcessing": stop,
|
||||||
|
}, &rule); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createRule("fallback archive", map[string]string{"type": "archive"}, false)
|
||||||
|
createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true)
|
||||||
|
|
||||||
|
senderClient := &testClient{t: t, server: ts}
|
||||||
|
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("sender login=%d", code)
|
||||||
|
}
|
||||||
|
var sent MailMessage
|
||||||
|
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "Netflix code", "text": "123456"}, &sent); code != http.StatusCreated {
|
||||||
|
t.Fatalf("send code=%d sent=%+v", code, sent)
|
||||||
|
}
|
||||||
|
var custom struct {
|
||||||
|
Items []MailMessage `json:"items"`
|
||||||
|
}
|
||||||
|
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 {
|
||||||
|
t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items)
|
||||||
|
}
|
||||||
|
var archived struct {
|
||||||
|
Items []MailMessage `json:"items"`
|
||||||
|
}
|
||||||
|
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
|
||||||
|
t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items)
|
||||||
|
}
|
||||||
|
var icon string
|
||||||
|
if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" {
|
||||||
|
t.Fatalf("rule-created folder icon=%q err=%v", icon, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderIconForName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requested string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "Netflix 验证码", requested: "auto", want: "netflix"},
|
||||||
|
{name: "ChatGPT 通知", want: "chatgpt"},
|
||||||
|
{name: "OpenAI 账单", want: "chatgpt"},
|
||||||
|
{name: "项目归档", want: "briefcase"},
|
||||||
|
{name: "其他", want: "folder"},
|
||||||
|
{name: "Netflix", requested: "heart", want: "heart"},
|
||||||
|
{name: "Netflix", requested: "unknown", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},
|
||||||
|
{name: "Egypt archive", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+"/"+tt.requested, func(t *testing.T) {
|
||||||
|
if got := folderIconForName(tt.name, tt.requested); got != tt.want {
|
||||||
|
t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := &testClient{t: t, server: httptest.NewServer(a.Router())}
|
||||||
|
defer admin.server.Close()
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := createTestDomain(t, admin, "manual-icon.test")
|
||||||
|
mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil)
|
||||||
|
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil {
|
||||||
|
t.Fatalf("create custom folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil {
|
||||||
|
t.Fatalf("reuse custom folder: %v", err)
|
||||||
|
}
|
||||||
|
var icon string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" {
|
||||||
|
t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailRuleApplyExistingWhenDisabledExcludesSent(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := mustDefaultDomainID(t, a)
|
||||||
|
sender := createTestMailbox(t, admin, domainID, "rule-existing-sender", "Existing Sender", "Password123!", nil)
|
||||||
|
recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil)
|
||||||
|
subject := "same inbound and sent subject"
|
||||||
|
|
||||||
|
senderClient := &testClient{t: t, server: ts}
|
||||||
|
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("sender login=%d", code)
|
||||||
|
}
|
||||||
|
var incomingSend MailMessage
|
||||||
|
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated {
|
||||||
|
t.Fatalf("incoming send code=%d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rcpt := &testClient{t: t, server: ts}
|
||||||
|
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("recipient login=%d", code)
|
||||||
|
}
|
||||||
|
var outgoing MailMessage
|
||||||
|
if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated {
|
||||||
|
t.Fatalf("outgoing send code=%d", code)
|
||||||
|
}
|
||||||
|
var rule MailRule
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"name": "existing disabled",
|
||||||
|
"conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}},
|
||||||
|
"actions": []map[string]string{{"type": "star"}},
|
||||||
|
"applyToExisting": true,
|
||||||
|
"enabled": false,
|
||||||
|
}, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled {
|
||||||
|
t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule)
|
||||||
|
}
|
||||||
|
var inboundStarred, sentStarred int
|
||||||
|
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inboundStarred != 1 || sentStarred != 0 {
|
||||||
|
t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) {
|
||||||
|
msg := ruleMessage{AttachmentNames: "notes.txt"}
|
||||||
|
if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) {
|
||||||
|
t.Fatal("attachment condition must not match MIME type or unrelated extension")
|
||||||
|
}
|
||||||
|
if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) {
|
||||||
|
t.Fatal("attachment condition should match filename")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMailRulesMailboxIsolation(t *testing.T) {
|
func TestMailRulesMailboxIsolation(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
@@ -1389,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
|
|||||||
if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
|
if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
|
||||||
t.Fatalf("register code=%d user=%+v", code, registered.User)
|
t.Fatalf("register code=%d user=%+v", code, registered.User)
|
||||||
}
|
}
|
||||||
|
var storageQuotaMB int
|
||||||
|
if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if storageQuotaMB != defaultUserStorageQuotaMB {
|
||||||
|
t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB)
|
||||||
|
}
|
||||||
var me struct {
|
var me struct {
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
}
|
}
|
||||||
@@ -2293,7 +2626,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var custom MailFolder
|
var custom MailFolder
|
||||||
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
|
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" {
|
||||||
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
|
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
|
||||||
}
|
}
|
||||||
var folders struct {
|
var folders struct {
|
||||||
@@ -2302,6 +2635,15 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
|||||||
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
|
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
|
||||||
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
||||||
}
|
}
|
||||||
|
foundIcon := ""
|
||||||
|
for _, folder := range folders.Items {
|
||||||
|
if folder.Name == "客户归档" {
|
||||||
|
foundIcon = folder.Icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundIcon != "netflix" {
|
||||||
|
t.Fatalf("folder icon=%q, want netflix", foundIcon)
|
||||||
|
}
|
||||||
|
|
||||||
var sent MailMessage
|
var sent MailMessage
|
||||||
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
||||||
@@ -2983,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
|
|||||||
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
|
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
|
||||||
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
|
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
|
||||||
}
|
}
|
||||||
|
var owner AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!",
|
||||||
|
}, &owner); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner)
|
||||||
|
}
|
||||||
|
|
||||||
var mailbox Mailbox
|
var mailbox Mailbox
|
||||||
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
|
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
|
||||||
"domainId": domain.ID,
|
"domainId": domain.ID,
|
||||||
"localPart": "api-user",
|
"localPart": "api-user",
|
||||||
"displayName": "API User",
|
"displayName": "API User",
|
||||||
"password": "Password123!",
|
"password": "DifferentPassword123!",
|
||||||
"quotaMb": 256,
|
"quotaMb": 256,
|
||||||
|
"userId": owner.ID,
|
||||||
}, &mailbox); code != http.StatusCreated {
|
}, &mailbox); code != http.StatusCreated {
|
||||||
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
|
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
|
||||||
}
|
}
|
||||||
|
ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var protectedMailboxErr map[string]any
|
||||||
|
if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr)
|
||||||
|
}
|
||||||
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
|
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
|
||||||
t.Fatalf("mailbox=%+v", mailbox)
|
t.Fatalf("mailbox=%+v", mailbox)
|
||||||
}
|
}
|
||||||
|
var ownerPasswordHash, mailboxPasswordHash string
|
||||||
|
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mailboxPasswordHash != ownerPasswordHash {
|
||||||
|
t.Fatal("open api mailbox did not inherit the owner password")
|
||||||
|
}
|
||||||
var mailboxes struct {
|
var mailboxes struct {
|
||||||
Items []Mailbox `json:"items"`
|
Items []Mailbox `json:"items"`
|
||||||
}
|
}
|
||||||
@@ -3013,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
|
|||||||
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
|
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
|
||||||
t.Fatalf("updated mailbox=%+v", updated)
|
t.Fatalf("updated mailbox=%+v", updated)
|
||||||
}
|
}
|
||||||
|
if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 {
|
||||||
|
t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated)
|
||||||
|
}
|
||||||
var ok map[string]any
|
var ok map[string]any
|
||||||
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
|
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
|
||||||
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
|
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
|
||||||
@@ -5001,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
|||||||
}, &errBody); code != http.StatusForbidden {
|
}, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||||
}
|
}
|
||||||
var regularUpdateErr map[string]any
|
var updatedRegular PermissionGroup
|
||||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||||
"name": "Changed Regular",
|
"name": "Changed Regular",
|
||||||
"description": "Should not change",
|
"description": "Should not change",
|
||||||
"permissions": []string{PermissionAdminOverview},
|
"permissions": regularUserDefaultPermissions(),
|
||||||
}, ®ularUpdateErr); code != http.StatusForbidden {
|
"limits": defaultPermissionLimits(),
|
||||||
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
|
}, &updatedRegular); code != http.StatusOK {
|
||||||
|
t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular)
|
||||||
|
}
|
||||||
|
if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" {
|
||||||
|
t.Fatalf("regular system permission group identity changed: %+v", updatedRegular)
|
||||||
}
|
}
|
||||||
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||||
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
|
t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup)
|
||||||
}
|
}
|
||||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||||
@@ -6163,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, mb := defaultAdminUserAndMailbox(t, a)
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
clearMailboxMessagesForTest(t, a, mb.ID)
|
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||||
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
|
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||||
@@ -6239,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
|
|||||||
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
|
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
|
||||||
t.Fatalf("stats code=%d stats=%+v", code, stats)
|
t.Fatalf("stats code=%d stats=%+v", code, stats)
|
||||||
}
|
}
|
||||||
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
|
var aliceStorageQuotaMB int64
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
|
||||||
t.Fatalf("stats quota/attachment not populated: %+v", stats)
|
t.Fatalf("stats quota/attachment not populated: %+v", stats)
|
||||||
}
|
}
|
||||||
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
|
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
return
|
return
|
||||||
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
|
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"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 TestGoogleDriveUploadRequestUsesMultipartRelated(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, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||||
|
if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
|
||||||
|
t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(req.Body, params["boundary"])
|
||||||
|
metadataPart, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Parents []string `json:"parents"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(metadataPart).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)
|
||||||
|
}
|
||||||
|
filePart, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(filePart)
|
||||||
|
if err != nil || string(raw) != "encrypted backup" {
|
||||||
|
t.Fatalf("uploaded bytes = %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
|
||||||
|
})
|
||||||
|
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
|
||||||
|
t.Fatal("backup password encryption succeeded without a deployment secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordValidation(t *testing.T) {
|
||||||
|
for _, valid := range []string{"12345678", "Restore Password 123!"} {
|
||||||
|
if !validBackupPassword(valid) {
|
||||||
|
t.Errorf("valid password rejected: %q", valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
|
||||||
|
if validBackupPassword(invalid) {
|
||||||
|
t.Errorf("invalid password accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordHint(t *testing.T) {
|
||||||
|
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||||
|
t.Fatalf("password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint("ab"); got != "ab" {
|
||||||
|
t.Fatalf("two-character password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint(""); got != "" {
|
||||||
|
t.Fatalf("empty password hint = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "A23456789Z" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
schedule, err := a.loadBackupSchedule(context.Background())
|
||||||
|
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||||
|
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("password update code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
if response["passwordHint"] != "N••••••••••9" {
|
||||||
|
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "NewSharedPassword9" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
var got string
|
||||||
|
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||||
|
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
deployDir := filepath.Join(dir, "deploy")
|
||||||
|
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||||
|
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||||
|
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "SharedBackupPassword9" {
|
||||||
|
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicServerIPValidation(t *testing.T) {
|
||||||
|
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||||
|
if !isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("public IP rejected: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
|
||||||
|
if isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("non-public IP accepted: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("literal public IP = %q", got)
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
|
||||||
|
t.Fatalf("literal private IP = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRuntimeBackupEnv(t *testing.T) {
|
||||||
|
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
|
||||||
|
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
|
||||||
|
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
|
||||||
|
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
|
||||||
|
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeRuntimeBackupEnv(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents := string(raw)
|
||||||
|
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
|
||||||
|
if !strings.Contains(contents, expected) {
|
||||||
|
t.Errorf("backup environment missing %q: %s", expected, contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
|
||||||
|
if strings.Contains(contents, excluded) {
|
||||||
|
t.Fatalf("backup environment included excluded value %q", excluded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("bundled compose did not enable complete backups")
|
||||||
|
}
|
||||||
|
if err := os.Remove(compose); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("missing bundled compose incorrectly enabled complete backups")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
|
||||||
|
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
|
||||||
|
if err != nil || ciphertext == "BackupPassword123!" {
|
||||||
|
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
|
||||||
|
}
|
||||||
|
plain, err := a.decryptBackupPassword(ciphertext)
|
||||||
|
if err != nil || plain != "BackupPassword123!" {
|
||||||
|
t.Fatalf("password decryption = %q, %v", plain, err)
|
||||||
|
}
|
||||||
|
if !validTelegramPrivateChatID("-1001234567890") {
|
||||||
|
t.Fatal("private Telegram group chat ID was rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
|
||||||
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := a.backupTelegramReport(context.Background(), path, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
|
||||||
|
if !strings.Contains(report, expected) {
|
||||||
|
t.Errorf("report missing %q: %s", expected, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "newszxcn.com(管理员)") {
|
||||||
|
t.Fatal("domain list incorrectly contains account role")
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
|
||||||
|
t.Fatal("report leaked a password")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ type Config struct {
|
|||||||
ReleaseAPIURL string
|
ReleaseAPIURL string
|
||||||
UpdateServiceURL string
|
UpdateServiceURL string
|
||||||
UpdateServiceToken string
|
UpdateServiceToken string
|
||||||
|
BackupSourceDir string
|
||||||
|
BackupDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
@@ -131,6 +133,8 @@ func LoadConfig() Config {
|
|||||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||||
|
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
|
||||||
|
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateFolderIcons(ctx context.Context) error {
|
||||||
|
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
ORDER BY CASE
|
ORDER BY CASE
|
||||||
WHEN lower(f.name)='inbox' THEN 1000
|
WHEN lower(f.name)='inbox' THEN 1000
|
||||||
WHEN lower(f.name)='sent' THEN 5000
|
WHEN lower(f.name)='sent' THEN 5000
|
||||||
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||||
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
@@ -280,6 +284,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("system folder already exists"))
|
badRequest(w, errors.New("system folder already exists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
icon := folderIconForName(name, req.Icon)
|
||||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
user := currentUser(r)
|
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)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||||
@@ -308,12 +313,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, mailboxID := range mailboxIDs {
|
for _, mailboxID := range mailboxIDs {
|
||||||
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
|
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
|
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
@@ -321,7 +326,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
@@ -527,8 +532,88 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request,
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
|
||||||
return a.ensureFolder(ctx, mailboxID, name)
|
var existingID string
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
|
||||||
|
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
|
||||||
|
return existingID, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id, err := a.ensureFolder(ctx, mailboxID, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderIconForName(name, requested string) string {
|
||||||
|
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
|
||||||
|
return normalizeFolderIcon(icon)
|
||||||
|
}
|
||||||
|
value := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
for _, match := range []struct {
|
||||||
|
icon string
|
||||||
|
terms []string
|
||||||
|
}{
|
||||||
|
{"netflix", []string{"netflix", "奈飞", "网飞"}},
|
||||||
|
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
|
||||||
|
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
|
||||||
|
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
|
||||||
|
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
|
||||||
|
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
|
||||||
|
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
|
||||||
|
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
|
||||||
|
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
|
||||||
|
{"star", []string{"重要", "紧急", "important", "urgent"}},
|
||||||
|
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
|
||||||
|
{"bell", []string{"提醒", "通知", "remind", "notification"}},
|
||||||
|
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
|
||||||
|
} {
|
||||||
|
for _, term := range match.terms {
|
||||||
|
if folderNameContainsTerm(value, term) {
|
||||||
|
return match.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderNameContainsTerm(value, term string) bool {
|
||||||
|
if term != "gpt" {
|
||||||
|
return strings.Contains(value, term)
|
||||||
|
}
|
||||||
|
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == term {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFolderIcon(raw string) string {
|
||||||
|
icon := strings.TrimSpace(raw)
|
||||||
|
const customPrefix = "data:image/png;base64,"
|
||||||
|
if strings.HasPrefix(icon, customPrefix) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
|
||||||
|
config, configErr := png.DecodeConfig(bytes.NewReader(data))
|
||||||
|
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
|
||||||
|
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
icon = strings.ToLower(icon)
|
||||||
|
switch icon {
|
||||||
|
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
|
||||||
|
return icon
|
||||||
|
default:
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
||||||
@@ -2232,14 +2317,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &f, nil
|
return &f, nil
|
||||||
@@ -2535,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var quotaMB int64
|
var quotaMB int64
|
||||||
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
|
var userID string
|
||||||
|
if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan("aMB, &userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var used int64
|
var used int64
|
||||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
|
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
quotaBytes := quotaMB * 1024 * 1024
|
quotaBytes := quotaMB * 1024 * 1024
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
|||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
|||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||||
|
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
|
||||||
|
|
||||||
|
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
|
||||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
type translationJob struct {
|
||||||
|
node *html.Node
|
||||||
|
original string
|
||||||
|
text string
|
||||||
|
}
|
||||||
remaining := maxChars
|
remaining := maxChars
|
||||||
var translateNode func(*html.Node) error
|
jobs := make([]translationJob, 0)
|
||||||
translateNode = func(n *html.Node) error {
|
var collect func(*html.Node)
|
||||||
|
collect = func(n *html.Node) {
|
||||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
if n.Type == html.TextNode {
|
if n.Type == html.TextNode {
|
||||||
text := strings.TrimSpace(n.Data)
|
text := strings.TrimSpace(n.Data)
|
||||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||||
limited, _ := truncateRunes(text, remaining)
|
limited, _ := truncateRunes(text, remaining)
|
||||||
remaining -= utf8.RuneCountInString(limited)
|
remaining -= utf8.RuneCountInString(limited)
|
||||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
if err := translateNode(c); err != nil {
|
collect(c)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if err := translateNode(n); err != nil {
|
collect(n)
|
||||||
return "", err
|
}
|
||||||
}
|
results := make([]string, len(jobs))
|
||||||
|
jobIndexes := make(chan int)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
workers := min(4, len(jobs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for index := range jobIndexes {
|
||||||
|
translated, _, translateErr := translator(ctx, jobs[index].text, target)
|
||||||
|
if translateErr != nil {
|
||||||
|
select {
|
||||||
|
case errCh <- translateErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[index] = translated
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for index := range jobs {
|
||||||
|
jobIndexes <- index
|
||||||
|
}
|
||||||
|
close(jobIndexes)
|
||||||
|
wg.Wait()
|
||||||
|
select {
|
||||||
|
case translateErr := <-errCh:
|
||||||
|
return "", translateErr
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
for index, job := range jobs {
|
||||||
|
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
|
||||||
}
|
}
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||||
raw := []any{
|
raw := []any{
|
||||||
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
|
||||||
|
translator := func(_ context.Context, text, target string) (string, string, error) {
|
||||||
|
return strings.ToUpper(text) + "-" + target, "en", nil
|
||||||
|
}
|
||||||
|
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
|
||||||
|
t.Fatalf("translated HTML = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<pre>keep me</pre>`) {
|
||||||
|
t.Fatalf("code block was translated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTruncateRunes(t *testing.T) {
|
func TestTruncateRunes(t *testing.T) {
|
||||||
got, truncated := truncateRunes("你好world", 4)
|
got, truncated := truncateRunes("你好world", 4)
|
||||||
if got != "你好wo" || !truncated {
|
if got != "你好wo" || !truncated {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
|
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
|
|||||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
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))
|
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
|
||||||
}
|
}
|
||||||
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
|
if !mailboxes.Items[0].Primary {
|
||||||
t.Fatalf("delete final mailbox code=%d", code)
|
t.Fatal("administrator mailbox should be marked as primary")
|
||||||
}
|
}
|
||||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
|
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("delete primary mailbox code=%d", code)
|
||||||
|
}
|
||||||
|
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||||
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
||||||
}
|
}
|
||||||
var me map[string]any
|
var me map[string]any
|
||||||
|
|||||||
@@ -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"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.QuotaMB < 0 {
|
||||||
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
|
}
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "domain not found")
|
respondError(w, http.StatusNotFound, "domain not found")
|
||||||
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
|
var ownerPasswordHash, ownerRole string
|
||||||
|
var ownerStorageQuotaMB int
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quotaMB := req.QuotaMB
|
||||||
|
if quotaMB == 0 {
|
||||||
|
quotaMB = ownerStorageQuotaMB
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
displayName = current.DisplayName
|
displayName = current.DisplayName
|
||||||
}
|
}
|
||||||
quotaMB := req.QuotaMB
|
quotaMB := req.QuotaMB
|
||||||
if quotaMB <= 0 {
|
if quotaMB < 0 {
|
||||||
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quotaMB == 0 {
|
||||||
quotaMB = current.QuotaMB
|
quotaMB = current.QuotaMB
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = current.UserID
|
userID = current.UserID
|
||||||
}
|
}
|
||||||
|
if current.Primary && userID != current.UserID {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current.Primary && status != current.Status {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
var ownerRole, ownerPasswordHash string
|
||||||
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerRole == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||||
|
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
return
|
||||||
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||||
|
} else {
|
||||||
|
badRequest(w, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||||
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
|||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = email
|
displayName = email
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
var existingSystem int
|
var existingSystem int
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
var existingName, existingDescription string
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "permission group not found")
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(existingSystem) {
|
if intBool(existingSystem) && id != PermissionGroupRegular {
|
||||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if id == PermissionGroupRegular {
|
||||||
|
name = existingName
|
||||||
|
req.Description = existingDescription
|
||||||
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
badRequest(w, errors.New("name is required"))
|
badRequest(w, errors.New("name is required"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
|
quotaMB := defaultUserStorageQuotaMB
|
||||||
|
if user.Role == "admin" {
|
||||||
|
quotaMB = 0
|
||||||
|
}
|
||||||
|
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
||||||
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appliedCount := int64(0)
|
appliedCount := int64(0)
|
||||||
if req.ApplyToExisting && enabled {
|
if req.ApplyToExisting {
|
||||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||||
item, err := scanRule(row)
|
item, err := scanRule(row)
|
||||||
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
var quotaMB int64
|
||||||
var quotaMB int64
|
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil {
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
return
|
||||||
return
|
}
|
||||||
}
|
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
if stats.QuotaBytes > 0 {
|
||||||
if stats.QuotaBytes > 0 {
|
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var quotaMB int64
|
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
|
||||||
if stats.QuotaBytes > 0 {
|
|
||||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
|||||||
if !ruleMatches(rule, msg) {
|
if !ruleMatches(rule, msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rule.StopProcessing {
|
if rule.StopProcessing {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
|
|||||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||||
var msg ruleMessage
|
var msg ruleMessage
|
||||||
var toAddrs, ccAddrs, receivedAt string
|
var toAddrs, ccAddrs, receivedAt string
|
||||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ruleMessage{}, false
|
return ruleMessage{}, false
|
||||||
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
|||||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
parts = append(parts, filename, contentType)
|
parts = append(parts, filename)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
|||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "contains"
|
operator = "contains"
|
||||||
}
|
}
|
||||||
switch operator {
|
if !validRuleConditionOperator(field, operator) {
|
||||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
|
||||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
|
||||||
default:
|
|
||||||
return MailRuleCondition{}, false
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validRuleConditionOperator(field, operator string) bool {
|
||||||
|
switch field {
|
||||||
|
case "size":
|
||||||
|
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||||
|
case "date":
|
||||||
|
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||||
|
default:
|
||||||
|
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRuleMatchMode(matchMode string) string {
|
func normalizeRuleMatchMode(matchMode string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||||
case "any", "or":
|
case "any", "or":
|
||||||
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
|||||||
for _, action := range normalizeRuleActions(actions, "") {
|
for _, action := range normalizeRuleActions(actions, "") {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "archive":
|
case "archive":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "trash":
|
case "trash":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "move":
|
case "move":
|
||||||
target := ruleTargetFolder(action.Value)
|
target, err := normalizeFolderNameForUser(action.Value)
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
if err != nil {
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
var folderID string
|
||||||
|
if isSystemFolderName(target) {
|
||||||
|
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||||
|
} else {
|
||||||
|
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "star":
|
case "star":
|
||||||
starred := true
|
starred := true
|
||||||
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruleTargetFolder(value string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "inbox":
|
|
||||||
return "Inbox"
|
|
||||||
case "archive":
|
|
||||||
return "Archive"
|
|
||||||
case "spam":
|
|
||||||
return "Spam"
|
|
||||||
case "trash":
|
|
||||||
return "Trash"
|
|
||||||
default:
|
|
||||||
return "Archive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||||
args := []any{userID}
|
args := []any{userID}
|
||||||
where := `mb.user_id=?`
|
where := `mb.user_id=?`
|
||||||
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
|||||||
where += ` AND m.mailbox_id=?`
|
where += ` AND m.mailbox_id=?`
|
||||||
args = append(args, mailboxID)
|
args = append(args, mailboxID)
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ func (a *App) Router() http.Handler {
|
|||||||
r.Use(a.requireAdminAccess)
|
r.Use(a.requireAdminAccess)
|
||||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||||
|
r.Get("/admin/backups", a.handleListBackups)
|
||||||
|
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||||
|
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||||
|
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||||
|
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||||
|
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||||
|
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
|
||||||
|
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
|
||||||
|
r.Post("/admin/backups", a.handleCreateBackup)
|
||||||
|
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
|
||||||
|
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
|
||||||
|
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
|
||||||
|
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
|
||||||
|
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
|
||||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type telegramUpdate struct {
|
|||||||
Chat struct {
|
Chat struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
|
|||||||
|
|
||||||
func validTelegramPrivateChatID(value string) bool {
|
func validTelegramPrivateChatID(value string) bool {
|
||||||
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
return err == nil && id > 0
|
return err == nil && id != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
|
|||||||
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type telegramDiscoveredChat struct {
|
||||||
|
ChatID string `json:"chatId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) {
|
||||||
|
var updates []telegramUpdate
|
||||||
|
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
|
||||||
|
"limit": 100, "timeout": 0, "allowed_updates": []string{"message"},
|
||||||
|
}, &updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := make([]telegramDiscoveredChat, 0)
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
for i := len(updates) - 1; i >= 0; i-- {
|
||||||
|
message := updates[i].Message
|
||||||
|
if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(message.Text)
|
||||||
|
fields := strings.Fields(text)
|
||||||
|
matches := strings.EqualFold(text, pairingCode)
|
||||||
|
if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
|
||||||
|
matches = strings.EqualFold(fields[1], pairingCode)
|
||||||
|
}
|
||||||
|
if !matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[message.Chat.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[message.Chat.ID] = true
|
||||||
|
name := strings.TrimSpace(message.Chat.Title)
|
||||||
|
if name == "" {
|
||||||
|
name = "Telegram 群组"
|
||||||
|
}
|
||||||
|
found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTelegramPairingCode() (string, error) {
|
func newTelegramPairingCode() (string, error) {
|
||||||
raw := make([]byte, 6)
|
raw := make([]byte, 6)
|
||||||
if _, err := rand.Read(raw); err != nil {
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ type User struct {
|
|||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
User
|
User
|
||||||
MailboxCount int `json:"mailboxCount"`
|
MailboxCount int `json:"mailboxCount"`
|
||||||
Mailboxes []string `json:"mailboxes"`
|
Mailboxes []string `json:"mailboxes"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToken struct {
|
type APIToken struct {
|
||||||
@@ -71,6 +72,7 @@ type Mailbox struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,7 @@ type MailFolder struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export function ConfirmDialog({
|
|||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { AuthGuard } from "@/components/auth-guard"
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||||
import { hasAnyPermission } from "@/lib/permissions"
|
import { hasAnyPermission } from "@/lib/permissions"
|
||||||
@@ -36,6 +35,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
|
|||||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||||
|
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
|
||||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ function ProtectedContent() {
|
|||||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
|
||||||
|
|
||||||
if (isMailRoute || isProfileRoute) {
|
if (isMailRoute || isProfileRoute) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
@@ -110,9 +110,6 @@ function ProtectedContent() {
|
|||||||
<span className="truncate font-semibold">{user.displayName}</span>
|
<span className="truncate font-semibold">{user.displayName}</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
|
||||||
{user.role === "admin" ? "管理员" : "普通用户"}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const Card = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-header"
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-title"
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
))
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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"
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className
|
||||||
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-head"
|
||||||
className={cn(
|
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]",
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className
|
||||||
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className
|
||||||
|
|||||||
+40
-2
@@ -140,12 +140,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-sidebar="menu-button"][data-active="true"] {
|
[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));
|
color: hsl(var(--sidebar-accent-foreground));
|
||||||
|
box-shadow: inset 2px 0 0 hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sidebar="menu-button"][data-active="true"] svg {
|
[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,12 @@ export type PermissionGroupSummary = { id: string; name: string }
|
|||||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
export type 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 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 APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
|
||||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
|
||||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
export type 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 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 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 MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||||
export type MailMessage = {
|
export type MailMessage = {
|
||||||
@@ -202,6 +202,11 @@ export type SystemUpdateResult = {
|
|||||||
targetVersion: string
|
targetVersion: string
|
||||||
message: 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 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 }
|
||||||
export type SystemSettings = {
|
export type SystemSettings = {
|
||||||
publicHostname: string
|
publicHostname: string
|
||||||
publicBaseUrl: string
|
publicBaseUrl: string
|
||||||
|
|||||||
+18
-6
@@ -1,4 +1,4 @@
|
|||||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
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"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -172,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) }),
|
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"),
|
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||||
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
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; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { 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 }) }),
|
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" }),
|
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||||
@@ -181,7 +181,7 @@ export const api = {
|
|||||||
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
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" }),
|
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
|
||||||
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
||||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
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" }),
|
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
|
||||||
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
||||||
@@ -211,6 +211,18 @@ export const api = {
|
|||||||
},
|
},
|
||||||
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
||||||
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
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"),
|
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
@@ -233,9 +245,9 @@ export const api = {
|
|||||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
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 }) }),
|
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)}` : ""}`),
|
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)}` : ""
|
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 }[] }) => {
|
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||||
|
|||||||
+557
-184
File diff suppressed because it is too large
Load Diff
+269
-70
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
|||||||
import Placeholder from "@tiptap/extension-placeholder"
|
import Placeholder from "@tiptap/extension-placeholder"
|
||||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, X } from "lucide-react"
|
||||||
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
@@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,65 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
import { hasPermission } from "@/lib/permissions"
|
import { hasPermission } from "@/lib/permissions"
|
||||||
|
|
||||||
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
||||||
|
function NetflixFolderIcon({ className }: { className?: string }) {
|
||||||
|
return <span aria-hidden="true" className={cn("inline-flex items-center justify-center font-black text-red-600", className)}>N</span>
|
||||||
|
}
|
||||||
|
const customFolderIconOptions = [
|
||||||
|
{ key: "folder", label: "文件夹", icon: Folder },
|
||||||
|
{ key: "mail", label: "邮件", icon: Mail },
|
||||||
|
{ key: "briefcase", label: "工作", icon: Briefcase },
|
||||||
|
{ key: "users", label: "联系人", icon: Users },
|
||||||
|
{ key: "receipt", label: "账单", icon: Receipt },
|
||||||
|
{ key: "shopping", label: "购物", icon: ShoppingBag },
|
||||||
|
{ key: "plane", label: "旅行", icon: Plane },
|
||||||
|
{ key: "graduation", label: "学习", icon: GraduationCap },
|
||||||
|
{ key: "heart", label: "收藏", icon: Heart },
|
||||||
|
{ key: "star", label: "重要", icon: Star },
|
||||||
|
{ key: "bell", label: "提醒", icon: Bell },
|
||||||
|
{ key: "shield", label: "安全", icon: ShieldCheck },
|
||||||
|
{ key: "tag", label: "分类", icon: Tag },
|
||||||
|
{ key: "netflix", label: "Netflix", icon: NetflixFolderIcon },
|
||||||
|
{ key: "chatgpt", label: "ChatGPT", icon: Bot },
|
||||||
|
] as const
|
||||||
|
function suggestedFolderIcon(name: string) {
|
||||||
|
const value = name.trim().toLocaleLowerCase()
|
||||||
|
if (/netflix|奈飞|网飞/.test(value)) return "netflix"
|
||||||
|
if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt"
|
||||||
|
if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt"
|
||||||
|
if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping"
|
||||||
|
if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane"
|
||||||
|
if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation"
|
||||||
|
if (/联系人|团队|用户|contact|team|people/.test(value)) return "users"
|
||||||
|
if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase"
|
||||||
|
if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart"
|
||||||
|
if (/重要|紧急|important|urgent/.test(value)) return "star"
|
||||||
|
if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield"
|
||||||
|
if (/提醒|通知|remind|notification/.test(value)) return "bell"
|
||||||
|
if (/邮件|邮箱|mail|email/.test(value)) return "mail"
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareFolderIcon(file: File) {
|
||||||
|
if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片")
|
||||||
|
if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB")
|
||||||
|
const bitmap = await createImageBitmap(file)
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = 64
|
||||||
|
canvas.height = 64
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("无法处理该图片")
|
||||||
|
const scale = Math.min(64 / bitmap.width, 64 / bitmap.height)
|
||||||
|
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||||
|
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||||
|
context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height)
|
||||||
|
const result = canvas.toDataURL("image/png")
|
||||||
|
if (result.length > 44_000) throw new Error("处理后的图标过大")
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
const folderLabels: Record<string, string> = {
|
const folderLabels: Record<string, string> = {
|
||||||
Inbox: "收件箱",
|
Inbox: "收件箱",
|
||||||
Sent: "已发送",
|
Sent: "已发送",
|
||||||
@@ -143,6 +203,8 @@ export function MailPage() {
|
|||||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
|
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
|
||||||
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
||||||
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
||||||
|
const [foldersExpanded, setFoldersExpanded] = React.useState(true)
|
||||||
|
const [labelsExpanded, setLabelsExpanded] = React.useState(true)
|
||||||
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
||||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||||
const [language, setLanguage] = useLanguage()
|
const [language, setLanguage] = useLanguage()
|
||||||
@@ -425,7 +487,16 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
||||||
})
|
})
|
||||||
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
const del = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string; permanent: boolean }) => api.delete(id),
|
||||||
|
onSuccess: async (_, { permanent }) => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setPendingConfirm(null)
|
||||||
|
await refreshMailData()
|
||||||
|
toast({ title: permanent ? "邮件已永久删除" : "邮件已移入已删除" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
||||||
|
})
|
||||||
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
||||||
const cancelScheduledSend = useMutation({
|
const cancelScheduledSend = useMutation({
|
||||||
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
||||||
@@ -438,7 +509,7 @@ export function MailPage() {
|
|||||||
onSettled: () => setCancelingScheduledId(""),
|
onSettled: () => setCancelingScheduledId(""),
|
||||||
})
|
})
|
||||||
const createFolder = useMutation({
|
const createFolder = useMutation({
|
||||||
mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }),
|
mutationFn: ({ name, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }),
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||||
setFolderDialogOpen(false)
|
setFolderDialogOpen(false)
|
||||||
@@ -753,10 +824,19 @@ export function MailPage() {
|
|||||||
await Promise.all(ids.map((id) => api.star(id, starred)))
|
await Promise.all(ids.map((id) => api.star(id, starred)))
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
await Promise.all(ids.map((id) => api.delete(id)))
|
await Promise.all(ids.map((id) => api.delete(id)))
|
||||||
|
completionTitle = `已永久删除 ${ids.length} 封邮件`
|
||||||
} else {
|
} else {
|
||||||
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
|
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
|
||||||
const result = await api.bulkMove(ids, target)
|
const result = await api.bulkMove(ids, target)
|
||||||
completionTitle = result.ok ? result.message : "批量移动部分失败"
|
completionTitle = result.ok
|
||||||
|
? target === "Trash"
|
||||||
|
? `已将 ${result.moved} 封邮件移入已删除`
|
||||||
|
: target === "Spam"
|
||||||
|
? `已将 ${result.moved} 封邮件移入垃圾邮件`
|
||||||
|
: target === "Inbox"
|
||||||
|
? `已将 ${result.moved} 封邮件移回收件箱`
|
||||||
|
: `已归档 ${result.moved} 封邮件`
|
||||||
|
: "批量移动部分失败"
|
||||||
completionDescription = result.ok ? undefined : result.message
|
completionDescription = result.ok ? undefined : result.message
|
||||||
}
|
}
|
||||||
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
||||||
@@ -788,11 +868,14 @@ export function MailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function confirmDeleteMessage(message: MailMessage) {
|
function confirmDeleteMessage(message: MailMessage) {
|
||||||
|
const permanent = message.folder === "Trash"
|
||||||
setPendingConfirm({
|
setPendingConfirm({
|
||||||
title: "删除这封邮件?",
|
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
|
||||||
description: `邮件“${message.subject || "无主题"}”将被删除。`,
|
description: permanent
|
||||||
confirmText: "删除邮件",
|
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。`
|
||||||
onConfirm: () => del.mutate(message.id),
|
: `邮件“${message.subject || "无主题"}”将移入已删除。`,
|
||||||
|
confirmText: permanent ? "永久删除" : "移入已删除",
|
||||||
|
onConfirm: () => del.mutate({ id: message.id, permanent }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function openCompose(draft?: ComposeDraft) {
|
function openCompose(draft?: ComposeDraft) {
|
||||||
@@ -902,6 +985,14 @@ export function MailPage() {
|
|||||||
function closeSidebarContextMenu() {
|
function closeSidebarContextMenu() {
|
||||||
setSidebarContextMenu(null)
|
setSidebarContextMenu(null)
|
||||||
}
|
}
|
||||||
|
function runAfterClosingMobileSidebar(action: () => void) {
|
||||||
|
if (!isMobile) {
|
||||||
|
action()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mobileSidebarOpen) setMobileSidebarOpen(false)
|
||||||
|
window.setTimeout(action, 250)
|
||||||
|
}
|
||||||
function activateSidebarItem(item: MailMenuItem) {
|
function activateSidebarItem(item: MailMenuItem) {
|
||||||
if (item.type === "starred") openStarred()
|
if (item.type === "starred") openStarred()
|
||||||
else if (item.type === "scheduled") openScheduled()
|
else if (item.type === "scheduled") openScheduled()
|
||||||
@@ -1371,15 +1462,18 @@ export function MailPage() {
|
|||||||
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
|
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />文件夹</SidebarGroupLabel>
|
<Button type="button" variant="ghost" className="h-auto min-w-0 justify-start gap-1 p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground" aria-expanded={foldersExpanded} aria-controls="mail-sidebar-folders" onClick={() => setFoldersExpanded((value) => !value)}>
|
||||||
|
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", !foldersExpanded && "-rotate-90")} />
|
||||||
|
<span>文件夹</span>
|
||||||
|
</Button>
|
||||||
{canManageFolders && (
|
{canManageFolders && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => setFolderDialogOpen(true)}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarGroupContent>
|
{foldersExpanded && <SidebarGroupContent id="mail-sidebar-folders">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{customMailMenuItems.map((item) => (
|
{customMailMenuItems.map((item) => (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
@@ -1425,12 +1519,15 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>}
|
||||||
</SidebarGroup>}
|
</SidebarGroup>}
|
||||||
{(canReadMail || canManageLabels) && <SidebarGroup>
|
{(canReadMail || canManageLabels) && <SidebarGroup>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />标签</SidebarGroupLabel>
|
<Button type="button" variant="ghost" className="h-auto min-w-0 justify-start gap-1 p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground" aria-expanded={labelsExpanded} aria-controls="mail-sidebar-labels" onClick={() => setLabelsExpanded((value) => !value)}>
|
||||||
|
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", !labelsExpanded && "-rotate-90")} />
|
||||||
|
<span>标签</span>
|
||||||
|
</Button>
|
||||||
{canManageCurrentMailboxLabels && (
|
{canManageCurrentMailboxLabels && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
||||||
@@ -1445,7 +1542,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarGroupContent>
|
{labelsExpanded && <SidebarGroupContent id="mail-sidebar-labels">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{canReadMail && labelItems.map((label) => {
|
{canReadMail && labelItems.map((label) => {
|
||||||
const dotColor = labelDotColor(label)
|
const dotColor = labelDotColor(label)
|
||||||
@@ -1491,9 +1588,37 @@ export function MailPage() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{labels.isLoading && <FolderSkeleton />}
|
{labels.isLoading && <FolderSkeleton />}
|
||||||
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
|
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>}
|
||||||
</SidebarGroup>}
|
</SidebarGroup>}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarContextMenu
|
||||||
|
state={sidebarContextMenu}
|
||||||
|
canCreate={canManageFolders}
|
||||||
|
canReorder={canOrganizeCurrentMailbox}
|
||||||
|
canDelete={canManageFolders}
|
||||||
|
pending={reorderFolders.isPending || deleteFolder.isPending}
|
||||||
|
onClose={closeSidebarContextMenu}
|
||||||
|
onOpen={(item) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
activateSidebarItem(item)
|
||||||
|
}}
|
||||||
|
onRefresh={() => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
void refreshMailData()
|
||||||
|
}}
|
||||||
|
onCreateFolder={() => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))
|
||||||
|
}}
|
||||||
|
onMove={(item, action) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
moveSidebarFolder(item, action)
|
||||||
|
}}
|
||||||
|
onDelete={(item) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
runAfterClosingMobileSidebar(() => confirmDeleteFolder(item))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1566,7 +1691,9 @@ export function MailPage() {
|
|||||||
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
||||||
) : compactMailLayout ? (
|
) : compactMailLayout ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
|
key={`${mailView}:${mailView === "folder" ? folder : mailView === "label" ? selectedLabelId : selectedExternalAccountId}`}
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
|
currentFolder={mailView === "folder" ? folder : undefined}
|
||||||
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined}
|
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined}
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
total={allMessages.length}
|
total={allMessages.length}
|
||||||
@@ -1675,12 +1802,12 @@ export function MailPage() {
|
|||||||
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
|
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
|
||||||
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
|
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
|
||||||
<span className="shrink-0 text-[13px] text-muted-foreground">已选 {selectedCountOnPage} 封</span>
|
<span className="shrink-0 text-[13px] text-muted-foreground">已选 {selectedCountOnPage} 封</span>
|
||||||
<BulkActionToolbar pending={bulkPending} currentFolder={folder} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
|
<BulkActionToolbar pending={bulkPending} currentFolder={mailView === "folder" ? folder : undefined} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{mailMessagesLoading && <MessageSkeleton />}
|
{mailMessagesLoading && <MessageSkeleton />}
|
||||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
|
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => m.folder === "Trash" ? confirmDeleteMessage(m) : move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
|
||||||
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
||||||
{!mailMessagesLoading && hasMoreMessages && (
|
{!mailMessagesLoading && hasMoreMessages && (
|
||||||
<div className="border-b p-4 text-center">
|
<div className="border-b p-4 text-center">
|
||||||
@@ -1724,7 +1851,7 @@ export function MailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
||||||
))}
|
))}
|
||||||
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>}
|
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MessageMetaPanel
|
<MessageMetaPanel
|
||||||
@@ -1812,39 +1939,11 @@ export function MailPage() {
|
|||||||
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
|
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SidebarContextMenu
|
|
||||||
state={sidebarContextMenu}
|
|
||||||
canCreate={canManageFolders}
|
|
||||||
canReorder={canOrganizeCurrentMailbox}
|
|
||||||
canDelete={canManageFolders}
|
|
||||||
pending={reorderFolders.isPending || deleteFolder.isPending}
|
|
||||||
onClose={closeSidebarContextMenu}
|
|
||||||
onOpen={(item) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
activateSidebarItem(item)
|
|
||||||
}}
|
|
||||||
onRefresh={() => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
void refreshMailData()
|
|
||||||
}}
|
|
||||||
onCreateFolder={() => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
setFolderDialogOpen(true)
|
|
||||||
}}
|
|
||||||
onMove={(item, action) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
moveSidebarFolder(item, action)
|
|
||||||
}}
|
|
||||||
onDelete={(item) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
confirmDeleteFolder(item)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CreateFolderDialog
|
<CreateFolderDialog
|
||||||
open={folderDialogOpen}
|
open={folderDialogOpen}
|
||||||
pending={createFolder.isPending}
|
pending={createFolder.isPending}
|
||||||
onOpenChange={setFolderDialogOpen}
|
onOpenChange={setFolderDialogOpen}
|
||||||
onCreate={(name) => createFolder.mutate(name)}
|
onCreate={(payload) => createFolder.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingConfirm}
|
open={!!pendingConfirm}
|
||||||
@@ -1852,7 +1951,7 @@ export function MailPage() {
|
|||||||
description={pendingConfirm?.description}
|
description={pendingConfirm?.description}
|
||||||
confirmText={pendingConfirm?.confirmText || "确认"}
|
confirmText={pendingConfirm?.confirmText || "确认"}
|
||||||
destructive
|
destructive
|
||||||
pending={del.isPending || bulkPending}
|
pending={del.isPending || bulkPending || deleteFolder.isPending}
|
||||||
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
|
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
|
||||||
onConfirm={() => pendingConfirm?.onConfirm()}
|
onConfirm={() => pendingConfirm?.onConfirm()}
|
||||||
/>
|
/>
|
||||||
@@ -1862,7 +1961,7 @@ export function MailPage() {
|
|||||||
|
|
||||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
||||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), icon: "folder", sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||||
for (const item of folders) {
|
for (const item of folders) {
|
||||||
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
||||||
}
|
}
|
||||||
@@ -1872,7 +1971,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
|||||||
folderId: item.id,
|
folderId: item.id,
|
||||||
folderName: item.name,
|
folderName: item.name,
|
||||||
label: folderLabels[item.name] || item.name,
|
label: folderLabels[item.name] || item.name,
|
||||||
icon: isCustomMailFolder(item) ? <Folder className="h-4 w-4" /> : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
icon: isCustomMailFolder(item) ? customFolderIcon(item.icon) : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||||
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
||||||
custom: isCustomMailFolder(item),
|
custom: isCustomMailFolder(item),
|
||||||
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
||||||
@@ -1892,6 +1991,13 @@ function isCustomMailFolder(folder: Pick<MailFolder, "name" | "id">) {
|
|||||||
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") {
|
||||||
|
if (iconKey?.startsWith("data:image/png;base64,")) return <img src={iconKey} alt="" className={cn("shrink-0 object-contain", className)} />
|
||||||
|
const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0]
|
||||||
|
const Icon = option.icon
|
||||||
|
return <Icon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
||||||
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
||||||
}
|
}
|
||||||
@@ -2492,6 +2598,7 @@ type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "inbox" |
|
|||||||
|
|
||||||
function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) {
|
function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) {
|
||||||
const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
const permanentlyDelete = currentFolder === "Trash"
|
||||||
const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive"
|
const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive"
|
||||||
const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
|
const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
|
||||||
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
|
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
|
||||||
@@ -2513,14 +2620,14 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
|||||||
<DropdownMenuContent align="end" className="w-44">
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
{movableFolders.map((folder) => (
|
{movableFolders.map((folder) => (
|
||||||
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
||||||
{folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||||
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction("trash")} title="移入已删除" aria-label="移入已删除">
|
<Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction(permanentlyDelete ? "delete" : "trash")} title={permanentlyDelete ? "永久删除" : "移入已删除"} aria-label={permanentlyDelete ? "永久删除" : "移入已删除"}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2567,7 +2674,7 @@ function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending,
|
|||||||
</Button>
|
</Button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
|
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
|
||||||
<Plus className="h-4 w-4" />新建文件夹
|
新建文件夹
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{customFolder && (canReorder || canDelete) && (
|
{customFolder && (canReorder || canDelete) && (
|
||||||
@@ -2673,7 +2780,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
|||||||
<div className="max-h-44 overflow-y-auto">
|
<div className="max-h-44 overflow-y-auto">
|
||||||
{movableFolders.map((folder) => (
|
{movableFolders.map((folder) => (
|
||||||
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
||||||
{folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
||||||
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2702,7 +2809,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
|||||||
{canOrganize && (
|
{canOrganize && (
|
||||||
<>
|
<>
|
||||||
<div className="-mx-1 my-1 h-px bg-border" />
|
<div className="-mx-1 my-1 h-px bg-border" />
|
||||||
{item("删除", <Trash2 className="h-4 w-4" />, "delete", true)}
|
{item(message.folder === "Trash" ? "永久删除" : "移入已删除", <Trash2 className="h-4 w-4" />, "delete", true)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2718,12 +2825,21 @@ function contextMenuPosition(x: number, y: number) {
|
|||||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) {
|
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||||
const [name, setName] = React.useState("")
|
const [name, setName] = React.useState("")
|
||||||
|
const [icon, setIcon] = React.useState("auto")
|
||||||
|
const [uploadError, setUploadError] = React.useState("")
|
||||||
|
const iconInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) setName("")
|
if (open) {
|
||||||
|
setName("")
|
||||||
|
setIcon("auto")
|
||||||
|
setUploadError("")
|
||||||
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
|
const suggestedIcon = suggestedFolderIcon(trimmed)
|
||||||
|
const resolvedIcon = icon === "auto" ? suggestedIcon : icon
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
||||||
@@ -2734,13 +2850,51 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (trimmed) onCreate(trimmed)
|
if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="text-sm font-medium">图标</legend>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
<Button type="button" variant="outline" size="icon" className={cn("relative size-10 shadow-none", icon === "auto" && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon("auto")} aria-label={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} title={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} aria-pressed={icon === "auto"}>
|
||||||
|
{customFolderIcon(suggestedIcon)}
|
||||||
|
<Sparkles className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-background" />
|
||||||
|
</Button>
|
||||||
|
{customFolderIconOptions.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<Button key={option.key} type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon === option.key && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon(option.key)} aria-label={option.label} title={option.label} aria-pressed={icon === option.key}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Button type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon.startsWith("data:image/png;base64,") && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => iconInputRef.current?.click()} aria-label="上传自定义图标" title="上传自定义图标" aria-pressed={icon.startsWith("data:image/png;base64,")}>
|
||||||
|
{icon.startsWith("data:image/png;base64,") ? customFolderIcon(icon) : <Upload className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={iconInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ""
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
setUploadError("")
|
||||||
|
setIcon(await prepareFolderIcon(file))
|
||||||
|
} catch (error) {
|
||||||
|
setUploadError(error instanceof Error ? error.message : "无法处理该图片")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{uploadError && <p role="alert" className="text-xs text-destructive">{uploadError}</p>}
|
||||||
|
</fieldset>
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
||||||
@@ -2753,6 +2907,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
|||||||
|
|
||||||
function CompactMailView({
|
function CompactMailView({
|
||||||
title,
|
title,
|
||||||
|
currentFolder,
|
||||||
icon,
|
icon,
|
||||||
messages,
|
messages,
|
||||||
total,
|
total,
|
||||||
@@ -2796,6 +2951,7 @@ function CompactMailView({
|
|||||||
tools,
|
tools,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
currentFolder?: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
messages: MailMessage[]
|
messages: MailMessage[]
|
||||||
total: number
|
total: number
|
||||||
@@ -2887,7 +3043,7 @@ function CompactMailView({
|
|||||||
{selectedIds.length > 0 ? (
|
{selectedIds.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden text-xs text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
<span className="hidden text-xs text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
||||||
{canOrganize && <BulkActionToolbar pending={bulkPending} onAction={onBulkAction} />}
|
{canOrganize && <BulkActionToolbar pending={bulkPending} currentFolder={currentFolder} onAction={onBulkAction} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -2998,7 +3154,7 @@ function CompactMessageDetail({
|
|||||||
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />删除</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</DropdownMenuItem>}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -3018,7 +3174,7 @@ function CompactMessageDetail({
|
|||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />删除</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
||||||
@@ -3058,23 +3214,41 @@ function CompactMessageDetail({
|
|||||||
|
|
||||||
|
|
||||||
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
|
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
const [translatedText, setTranslatedText] = React.useState("")
|
const [translatedText, setTranslatedText] = React.useState("")
|
||||||
const [translatedHtml, setTranslatedHtml] = React.useState("")
|
const [translatedHtml, setTranslatedHtml] = React.useState("")
|
||||||
const [showTranslated, setShowTranslated] = React.useState(false)
|
const [showTranslated, setShowTranslated] = React.useState(false)
|
||||||
const [truncated, setTruncated] = React.useState(false)
|
const [truncated, setTruncated] = React.useState(false)
|
||||||
|
const [autoTranslate, setAutoTranslate] = React.useState(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false"
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const targetLanguage = normalizeTranslationLanguage(language)
|
const targetLanguage = normalizeTranslationLanguage(language)
|
||||||
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
||||||
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
|
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
|
||||||
|
const translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage])
|
||||||
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
||||||
|
const applyTranslation = React.useCallback((result: Awaited<ReturnType<typeof api.translateMessage>>, display = true) => {
|
||||||
|
setTranslatedText(result.translatedText)
|
||||||
|
setTranslatedHtml(result.translatedHtml || "")
|
||||||
|
setTruncated(result.truncated)
|
||||||
|
setShowTranslated(display)
|
||||||
|
}, [])
|
||||||
const translate = useMutation({
|
const translate = useMutation({
|
||||||
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!),
|
mutationFn: async ({ force = false }: { force?: boolean } = {}) => {
|
||||||
onSuccess: (result) => {
|
if (!force) {
|
||||||
setTranslatedText(result.translatedText)
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
setTranslatedHtml(result.translatedHtml || "")
|
if (cached) return cached
|
||||||
setTruncated(result.truncated)
|
}
|
||||||
setShowTranslated(true)
|
const result = message.externalAccountId ? await api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : await api.translateMessage(message.id, targetLanguage!)
|
||||||
|
qc.setQueryData(translationKey, result)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
onSuccess: (result) => applyTranslation(result),
|
||||||
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3084,8 +3258,30 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
|
|||||||
setShowTranslated(false)
|
setShowTranslated(false)
|
||||||
setTruncated(false)
|
setTruncated(false)
|
||||||
translate.reset()
|
translate.reset()
|
||||||
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
|
if (cached) applyTranslation(cached, autoTranslate)
|
||||||
}, [message.id, language])
|
}, [message.id, language])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!autoTranslate || !shouldShow || translate.isPending) return
|
||||||
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
|
if (cached) {
|
||||||
|
applyTranslation(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
translate.mutate({ force: false })
|
||||||
|
}, [autoTranslate, message.id, shouldShow, targetLanguage])
|
||||||
|
|
||||||
|
const changeAutoTranslate = (checked: boolean) => {
|
||||||
|
setAutoTranslate(checked)
|
||||||
|
if (!checked) setShowTranslated(false)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("newszxcn.mail.auto-translate", String(checked))
|
||||||
|
} catch {
|
||||||
|
// Keep the setting for this session when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(shouldShow || translatedText) && (
|
{(shouldShow || translatedText) && (
|
||||||
@@ -3096,8 +3292,12 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
|
|||||||
{truncated && <span className="ml-1">(内容较长,仅翻译前半部分)</span>}
|
{truncated && <span className="ml-1">(内容较长,仅翻译前半部分)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
<Switch checked={autoTranslate} onCheckedChange={changeAutoTranslate} aria-label="自动翻译邮件" />
|
||||||
|
自动翻译
|
||||||
|
</label>
|
||||||
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
|
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
|
||||||
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate()}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
|
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate({ force: !!translatedText })}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3265,7 +3465,6 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SidebarMenuButton className="text-muted-foreground" onClick={() => setEditingState(true)}>
|
<SidebarMenuButton className="text-muted-foreground" onClick={() => setEditingState(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>新建标签</span>
|
<span>新建标签</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
)
|
)
|
||||||
@@ -3654,7 +3853,7 @@ function MessageRow({
|
|||||||
<Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}>
|
||||||
<Archive className="h-3.5 w-3.5" />
|
<Archive className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label="移入已删除" title="移入已删除" className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={message.folder === "Trash" ? "永久删除" : "移入已删除"} title={message.folder === "Trash" ? "永久删除" : "移入已删除"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
|
||||||
|
|||||||
+105
-77
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|||||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||||
import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
|
import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
|
||||||
import { QRCodeSVG } from "qrcode.react"
|
import { QRCodeSVG } from "qrcode.react"
|
||||||
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailFolder, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||||
import { cn, formatBytes } from "@/lib/utils"
|
import { cn, formatBytes } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||||
@@ -23,7 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
@@ -693,8 +693,8 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
|
|||||||
<section className={cn("rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]", className)}>
|
<section className={cn("rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]", className)}>
|
||||||
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-[15px] font-semibold leading-6 text-foreground">{title}</h2>
|
<h2 className="break-words text-[15px] font-semibold leading-6 text-foreground [overflow-wrap:anywhere]">{title}</h2>
|
||||||
{subtitle && <p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>}
|
{subtitle && <p className="mt-0.5 break-words text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="w-full shrink-0 sm:w-auto sm:justify-end [&>a]:w-full [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto">{action}</div>}
|
{action && <div className="w-full shrink-0 sm:w-auto sm:justify-end [&>a]:w-full [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1626,7 +1626,7 @@ function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }:
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
|
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
|
||||||
{account ? "编辑" : <><Plus className="h-4 w-4" />添加外部邮箱</>}
|
{account ? "编辑" : "添加外部邮箱"}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
|
||||||
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
|
||||||
@@ -2221,16 +2221,13 @@ function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mai
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{showStats && <StatsSummary stats={stats} />}
|
{showStats && <StatsSummary stats={stats} />}
|
||||||
<Card>
|
<SettingsCard title="清理当前邮箱" subtitle={mailbox ? `当前邮箱:${mailbox.address}` : "请先选择邮箱"} contentClassName="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
|
||||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
|
||||||
<CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} />
|
<CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} />
|
||||||
<CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} />
|
<CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} />
|
||||||
<CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
|
<CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
|
||||||
</CardContent>
|
</SettingsCard>
|
||||||
</Card>
|
|
||||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2261,6 +2258,9 @@ const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移
|
|||||||
|
|
||||||
function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) {
|
function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||||
const [editingRule, setEditingRule] = React.useState<MailRule | null>(null)
|
const [editingRule, setEditingRule] = React.useState<MailRule | null>(null)
|
||||||
|
const [query, setQuery] = React.useState("")
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||||
|
const filteredItems = items.map((item, index) => ({ item, index })).filter(({ item }) => !normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery))
|
||||||
|
|
||||||
function setDialogOpen(next: boolean) {
|
function setDialogOpen(next: boolean) {
|
||||||
if (!next) setEditingRule(null)
|
if (!next) setEditingRule(null)
|
||||||
@@ -2269,12 +2269,18 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-stretch sm:justify-end">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||||
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
<div className="relative min-w-0">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称" aria-label="搜索规则" className="h-10 pl-9 pr-10" />
|
||||||
|
{query && <Button type="button" variant="ghost" size="icon" className="absolute right-1 top-1 size-8 text-muted-foreground" onClick={() => setQuery("")} aria-label="清除规则搜索" title="清除搜索"><X className="h-4 w-4" /></Button>}
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 shrink-0 rounded-md px-3 text-sm font-normal sm:px-4" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
{filteredItems.map(({ item, index }) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
||||||
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
||||||
|
{items.length > 0 && filteredItems.length === 0 && <EmptyState icon={<Search />} text="没有找到规则" description={`没有名称包含“${query.trim()}”的规则。`} className="min-h-[180px] border-solid bg-card" />}
|
||||||
</div>
|
</div>
|
||||||
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2292,7 +2298,10 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
const [stopProcessing, setStopProcessing] = React.useState(false)
|
const [stopProcessing, setStopProcessing] = React.useState(false)
|
||||||
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
|
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
|
||||||
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
|
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
|
||||||
|
const folderQueryMailboxId = selectedMailboxId || "all"
|
||||||
|
const folderQuery = useQuery({ queryKey: ["folders", "rule-dialog", folderQueryMailboxId], queryFn: () => api.folders(folderQueryMailboxId), enabled: open })
|
||||||
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
|
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
|
||||||
|
const availableFolders = folderQuery.data?.items || []
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -2336,37 +2345,39 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]">
|
<DialogContent className="!flex h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden border-0 p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)] sm:border">
|
||||||
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
|
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-16 text-left sm:px-8 sm:py-6 sm:pr-16">
|
||||||
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
||||||
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
<Field label="名称"><Input className="h-11 sm:h-9" value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
||||||
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<span>当新邮件到达时,满足以下</span>
|
<div>当新邮件到达时,满足以下</div>
|
||||||
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
|
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
|
||||||
<SelectTrigger className="h-9 w-[132px]"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 w-full md:h-9 md:w-[180px]"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="all">所有条件</SelectItem><SelectItem value="any">任一条件</SelectItem></SelectContent>
|
<SelectContent><SelectItem value="all">所有条件</SelectItem><SelectItem value="any">任一条件</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{conditions.map((condition, index) => (
|
{conditions.map((condition, index) => (
|
||||||
<div key={index} className="grid gap-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto]">
|
<div key={index} className="grid grid-cols-2 gap-2 rounded-md border p-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
<Input className="col-span-2 h-11 md:col-span-1 md:h-9" type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
<div className="col-span-2 flex justify-end gap-2 md:contents">
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2376,14 +2387,16 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<div className="text-sm">执行以下动作</div>
|
<div className="text-sm">执行以下动作</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{actions.map((action, index) => (
|
{actions.map((action, index) => (
|
||||||
<div key={index} className={cn("grid gap-3", action.type === "forward" ? "md:grid-cols-[180px_minmax(0,1fr)_auto]" : "md:grid-cols-[180px_minmax(0,1fr)_auto_auto]")}>
|
<div key={index} className="grid gap-2 rounded-md border p-3 md:grid-cols-[180px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||||
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
<div className="min-w-0"><RuleActionValue action={action} folders={availableFolders} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /></div>
|
||||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
<div className="flex justify-end gap-2 md:contents">
|
||||||
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2394,13 +2407,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
||||||
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
||||||
<Info className="h-4 w-4 text-muted-foreground" />
|
<Info className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
|
<DialogFooter className="shrink-0 gap-2 border-t px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:px-8 sm:py-5 [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:min-h-9 sm:[&>button]:w-auto">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -2410,7 +2423,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action: MailRuleAction; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
function RuleActionValue({ action, folders, labels, verifiedEmails, onChange }: { action: MailRuleAction; folders: MailFolder[]; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
||||||
if (action.type === "label") {
|
if (action.type === "label") {
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
return (
|
return (
|
||||||
@@ -2424,20 +2437,26 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
|
|||||||
}
|
}
|
||||||
if (action.type === "move") {
|
if (action.type === "move") {
|
||||||
const value = action.value || "Archive"
|
const value = action.value || "Archive"
|
||||||
|
const customFolders = ruleCustomFolders(folders, value)
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
|
<Select value={value} onValueChange={(next) => onChange({ value: next })}>
|
||||||
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue placeholder="选择文件夹" /></SelectTrigger>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectGroup>
|
||||||
|
<SelectLabel className="text-xs font-medium text-muted-foreground">系统文件夹</SelectLabel>
|
||||||
<SelectItem value="Inbox">收件箱</SelectItem>
|
<SelectItem value="Inbox">收件箱</SelectItem>
|
||||||
<SelectItem value="Archive">归档</SelectItem>
|
<SelectItem value="Archive">归档</SelectItem>
|
||||||
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
||||||
<SelectItem value="Trash">回收站</SelectItem>
|
<SelectItem value="Trash">已删除</SelectItem>
|
||||||
<SelectItem value="__custom">自定义文件夹</SelectItem>
|
</SelectGroup>
|
||||||
</SelectContent>
|
<SelectSeparator />
|
||||||
</Select>
|
<SelectGroup>
|
||||||
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
|
<SelectLabel className="text-xs font-medium text-muted-foreground">自定义文件夹</SelectLabel>
|
||||||
</div>
|
{customFolders.map((folder) => <SelectItem key={folder} value={folder}>{folder}</SelectItem>)}
|
||||||
|
{customFolders.length === 0 && <div className="px-2 py-2 text-sm text-muted-foreground">暂无自定义文件夹</div>}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (action.type === "forward") {
|
if (action.type === "forward") {
|
||||||
@@ -2446,6 +2465,14 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
|
|||||||
return <Input value="无需填写" readOnly />
|
return <Input value="无需填写" readOnly />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ruleCustomFolders(folders: MailFolder[], selectedValue: string) {
|
||||||
|
const names = folders
|
||||||
|
.map((folder) => folder.name.trim())
|
||||||
|
.filter((name) => name && !commonRuleFolders.some((systemName) => systemName.toLowerCase() === name.toLowerCase()) && !["Sent", "Drafts"].some((systemName) => systemName.toLowerCase() === name.toLowerCase()))
|
||||||
|
if (selectedValue && !commonRuleFolders.includes(selectedValue) && !names.some((name) => name.toLowerCase() === selectedValue.toLowerCase())) names.push(selectedValue)
|
||||||
|
return Array.from(new Set(names)).sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true, sensitivity: "base" }))
|
||||||
|
}
|
||||||
|
|
||||||
function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) {
|
function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) {
|
||||||
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
|
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
|
||||||
return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" />
|
return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" />
|
||||||
@@ -2458,34 +2485,34 @@ function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
|
|||||||
|
|
||||||
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
return <div className="flex min-w-0 items-start gap-3"><Checkbox id={id} className="mt-0.5" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-sm font-medium leading-5 sm:text-base sm:leading-6">{label}</Label></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
||||||
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
||||||
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
||||||
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
||||||
const moveDirection = index === 0 ? "down" : "up"
|
|
||||||
const canMove = count > 1
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
<div className="grid min-h-[110px] grid-cols-1 gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-2 sm:space-y-1">
|
||||||
<div className="flex min-w-0 items-center gap-2 leading-6">
|
<div className="flex min-w-0 items-start gap-2 leading-6 sm:items-center">
|
||||||
<h3 className="truncate text-base font-semibold text-foreground">{item.name}</h3>
|
<h3 className="min-w-0 flex-1 break-words text-base font-semibold text-foreground [overflow-wrap:anywhere] sm:truncate">{item.name}</h3>
|
||||||
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid text-sm leading-6 text-muted-foreground">
|
<div className="grid gap-1 text-sm leading-5 text-muted-foreground sm:gap-0 sm:leading-6">
|
||||||
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
<p className="flex min-w-0 items-start gap-1"><span className="shrink-0">适用:</span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{mailboxLabel}</span></p>
|
||||||
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
<p className="flex min-w-0 items-start gap-1"><span className="shrink-0">条件:</span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{conditionText}</span></p>
|
||||||
|
<p className="flex min-w-0 items-start gap-1"><span className="shrink-0">动作:</span><span className="min-w-0 break-words [overflow-wrap:anywhere] sm:truncate">{actionText}</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center justify-end gap-1 border-t pt-3 sm:border-t-0 sm:pt-0">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || !canMove} onClick={() => onMove(moveDirection)} aria-label={moveDirection === "up" ? "上移" : "下移"} title={moveDirection === "up" ? "上移" : "下移"}>{moveDirection === "up" ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</Button>
|
<Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><ChevronDown className="h-4 w-4" /></Button>
|
||||||
<span className="mx-1 h-6 w-px bg-border" />
|
<span className="mx-1 h-6 w-px bg-border" />
|
||||||
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
<Button type="button" variant="ghost" size="icon" className={cn("size-9 sm:size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-9 text-muted-foreground sm:size-7" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon" className="size-7 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive sm:size-7" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
|
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2556,17 +2583,18 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
|||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<SettingsCard
|
||||||
<div className="text-xs font-medium text-muted-foreground">共 {spamCount} 封垃圾邮件 · {items.length} 条发件人拦截规则</div>
|
title="拦截规则"
|
||||||
<Button type="button" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}><Plus className="h-4 w-4" />新增拦截</Button>
|
subtitle={`共 ${items.length} 条发件人规则 · 垃圾邮件 ${spamCount} 封`}
|
||||||
</div>
|
action={<Button type="button" onClick={() => setDialogOpen(true)}>新增拦截</Button>}
|
||||||
<SettingsCard title="被拦截邮件" subtitle="发件人命中拦截规则后会进入垃圾邮件,规则可随时移除。" contentClassName="space-y-2">
|
contentClassName="space-y-2"
|
||||||
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/40">
|
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/40">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
<div className="break-all text-sm font-semibold leading-5 text-foreground">{item.email}</div>
|
||||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
<div className="mt-1 break-words text-xs leading-5 text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2795,13 +2823,13 @@ function StatsSummary({ stats }: { stats?: MailStats }) {
|
|||||||
{ label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel, icon: <BarChart3 className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
|
{ label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel, icon: <BarChart3 className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<Card key={card.label} className="shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
<Card key={card.label} className="min-w-0 overflow-hidden shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex min-w-0 items-center gap-3 p-4">
|
||||||
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-xl font-semibold leading-6 text-foreground">{card.value}</div>
|
<div className="break-words text-lg font-semibold leading-6 text-foreground [overflow-wrap:anywhere]">{card.value}</div>
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">{card.label}</div>
|
<div className="mt-0.5 text-xs text-muted-foreground">{card.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -2811,7 +2839,7 @@ function StatsSummary({ stats }: { stats?: MailStats }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="h-auto justify-start p-4 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 rounded-lg bg-muted p-2">{icon}</div><div className="font-medium">{title}</div></Button> }
|
function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="min-h-[72px] w-full min-w-0 justify-start whitespace-normal px-4 py-3 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 shrink-0 rounded-md bg-muted p-2">{icon}</div><div className="min-w-0 break-words font-medium leading-5">{title}</div></Button> }
|
||||||
function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部邮箱</SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> }
|
function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部邮箱</SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> }
|
||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
|
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
|
||||||
function EmptyState({ text, description, icon, action, className }: { text: string; description?: string; icon?: React.ReactNode; action?: React.ReactNode; className?: string }) {
|
function EmptyState({ text, description, icon, action, className }: { text: string; description?: string; icon?: React.ReactNode; action?: React.ReactNode; className?: string }) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
ca-certificates tzdata sqlite3 supervisor nginx \
|
ca-certificates tzdata sqlite3 supervisor nginx \
|
||||||
postfix postfix-sqlite \
|
postfix postfix-sqlite \
|
||||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
|
||||||
rspamd
|
rspamd zstd openssl
|
||||||
|
|
||||||
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||||
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
||||||
@@ -43,6 +43,7 @@ COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
|||||||
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
|
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
|
||||||
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
|
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
|
||||||
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
|
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
|
||||||
|
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
|
||||||
|
|
||||||
COPY deploy/postfix/main.cf /etc/postfix/main.cf
|
COPY deploy/postfix/main.cf /etc/postfix/main.cf
|
||||||
COPY deploy/postfix/master.cf /etc/postfix/master.cf
|
COPY deploy/postfix/master.cf /etc/postfix/master.cf
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||||
|
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
|
||||||
EXPOSE 8080 465 587
|
EXPOSE 8080 465 587
|
||||||
CMD ["lanqin-api"]
|
CMD ["lanqin-api"]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
||||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||||
|
LANQIN_BACKUP_SOURCE_DIR: /backup-source
|
||||||
|
LANQIN_BACKUP_DIR: /backups
|
||||||
ports:
|
ports:
|
||||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||||
@@ -17,6 +19,9 @@ services:
|
|||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
- ./certs:/certs:ro
|
- ./certs:/certs:ro
|
||||||
|
- ./.env:/backup-source/.env:ro
|
||||||
|
- ./docker-compose.yml:/backup-source/docker-compose.yml:ro
|
||||||
|
- ./backups:/backups
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# NewSzxcn 完整备份与灾难恢复
|
||||||
|
|
||||||
|
## 后台创建并推送到 Telegram
|
||||||
|
|
||||||
|
1. 使用系统管理员登录 NewSzxcn 后台。
|
||||||
|
2. 在“系统设置 -> 通知”绑定 Telegram Bot Token 和私聊 Chat ID,并发送测试通知。
|
||||||
|
3. 打开“备份与恢复”。
|
||||||
|
4. 在“定时备份与 Telegram 推送”中选择每 3、5、7、30 天,或填写 1 至 365 天的自定义周期。
|
||||||
|
5. 备份 Chat ID 留空时沿用邮件通知私聊;也可以填写一个仅管理员可见的私有群组 Chat ID,将邮件通知与备份文件分开。Bot 必须已经加入该群组。
|
||||||
|
6. 填写服务器公网 IP。备份密码可以自己输入,也可以点击“生成 24 位”;必须另外保存到 1Password 等密码管理器。
|
||||||
|
7. 开启“自动创建并推送”,保存设置。
|
||||||
|
8. 首次配置建议点击“创建备份”,勾选“完成后发送到 Telegram”,确认机器人能收到说明消息和 `.tar.zst.enc` 加密附件。
|
||||||
|
|
||||||
|
Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名、管理员账号、普通用户账号、邮箱账号、文件大小、SHA-256 和恢复步骤。已有域名只列域名,不附加账号身份。
|
||||||
|
|
||||||
|
消息不会包含管理员密码、用户密码或备份密码。数据库只保存登录密码哈希,不能反向读取明文;恢复后账号继续使用原登录密码。备份密码与加密附件也不应保存在同一个 Telegram 会话中。
|
||||||
|
|
||||||
|
## 原服务器失联后的恢复
|
||||||
|
|
||||||
|
准备一台新的 Debian 或 Ubuntu 服务器。先把 Telegram 中的加密附件原样上传到新服务器的 `/root/` 目录,请不要解压、修改或固定填写某个示例文件名。备份日期和版本号每次可能不同。
|
||||||
|
|
||||||
|
确认文件已经上传后,首次执行官方脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。
|
||||||
|
|
||||||
|
在“尚未安装”菜单选择:
|
||||||
|
|
||||||
|
```text
|
||||||
|
==================================================
|
||||||
|
NewSzxcn Email 管理面板
|
||||||
|
==================================================
|
||||||
|
状态:尚未安装
|
||||||
|
--------------------------------------------------
|
||||||
|
1. 一键安装 NewSzxcn Email
|
||||||
|
2. 备份恢复
|
||||||
|
3. 退出
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
进入备份恢复菜单后,输入 `1` 选择“本地上传”。脚本会自动扫描 `/root/newszxcn-backup-*`:只有一份时直接选中;多份时按日期从新到旧显示为 `1、2、3` 等序号,输入对应序号,例如输入 `1` 恢复第 1 份。没有找到时才要求手动输入完整路径。选定后输入备份密码,脚本会检查压缩包路径、SQLite 完整性和必要目录,再启动服务。
|
||||||
|
|
||||||
|
恢复完成后:
|
||||||
|
|
||||||
|
1. 如果新服务器 IP 改变,更新邮件主机的 A/AAAA、邮件域名的 MX/SPF,以及服务商处的 PTR 记录。
|
||||||
|
2. 检查 DKIM 和 DMARC;DKIM 私钥已随备份恢复,但 DNS 仍应核对。
|
||||||
|
3. 检查 TLS 证书是否适用于当前主机名,必要时重新签发。
|
||||||
|
4. 登录网页并测试收信、发信、IMAP、POP3 和 SMTP Submission。
|
||||||
|
5. 打开“备份与恢复”,重新测试 Telegram 推送。
|
||||||
|
|
||||||
|
## 备份内容
|
||||||
|
|
||||||
|
完整备份包括 SQLite 数据库、附件、Maildir 原始邮件、DKIM 私钥、TLS 证书、`.env`、Compose 配置、版本清单和 SHA-256 校验文件。备份使用 Zstandard 压缩,并以 AES-256-CBC、PBKDF2 200000 次迭代和 SHA-256 加密。
|
||||||
|
|
||||||
|
Telegram 适合保存体积较小的应急副本,不应作为唯一备份位置。超过 Telegram 发送上限的文件请从后台下载,并保存到 Google 云端硬盘、另一台服务器、对象存储或离线磁盘。
|
||||||
|
|
||||||
|
## Google 云端硬盘
|
||||||
|
|
||||||
|
后台“备份与恢复”支持将同一份加密备份保存到 Google 云端硬盘。系统只申请 `drive.file` 权限,只能管理由 NewSzxcn 创建的文件,不会读取云端硬盘中的其他文件。
|
||||||
|
|
||||||
|
1. 在 Google Cloud Console 创建项目并启用 Google Drive API。
|
||||||
|
2. 配置 OAuth 同意屏幕,再创建“Web 应用”类型的 OAuth 客户端。
|
||||||
|
3. 授权重定向 URI 填写 `https://你的邮局域名/api/admin/backups/google-drive/callback`,必须与后台系统设置中的公开访问地址一致。
|
||||||
|
4. 在后台填写 OAuth 客户端 ID、客户端密钥和云端文件夹名称,先保存或直接点击“连接 Google”。
|
||||||
|
5. 在 Google 授权页面确认后会自动返回“备份与恢复”,状态显示“已连接”。
|
||||||
|
6. 可开启“用于定时备份”,也可在创建备份或已有备份菜单中单独上传。
|
||||||
|
|
||||||
|
OAuth 客户端密钥和刷新令牌会使用服务器内部密钥加密保存。Google 云端硬盘中只保存 `.tar.zst.enc` 加密备份,备份密码仍应单独保管。
|
||||||
+298
-3
@@ -28,6 +28,7 @@ NewSzxcn Email 管理命令
|
|||||||
|
|
||||||
menu 显示安装与运维菜单
|
menu 显示安装与运维菜单
|
||||||
install 首次安装;已有安装会先完整备份再重新安装
|
install 首次安装;已有安装会先完整备份再重新安装
|
||||||
|
restore 从完整备份目录或压缩包恢复到新服务器
|
||||||
update 备份数据库并更新到最新版
|
update 备份数据库并更新到最新版
|
||||||
repair 检查并修复现有安装
|
repair 检查并修复现有安装
|
||||||
status 查看容器与健康状态
|
status 查看容器与健康状态
|
||||||
@@ -161,6 +162,25 @@ ensure_cli_alias() {
|
|||||||
success "快捷命令已创建:输入 ns 可打开管理菜单。"
|
success "快捷命令已创建:输入 ns 可打开管理菜单。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_cli_command() {
|
||||||
|
local source_dir tmp
|
||||||
|
[[ -x "${CLI_PATH}" ]] && return 0
|
||||||
|
install -d -m 0755 "$(dirname "${CLI_PATH}")"
|
||||||
|
source_dir="$(script_dir || true)"
|
||||||
|
if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* && -f "${source_dir}/install.sh" ]]; then
|
||||||
|
install -m 0755 "${source_dir}/install.sh" "${CLI_PATH}"
|
||||||
|
else
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
if ! curl -fsSL "${RAW_BASE}/install.sh" -o "${tmp}" || ! bash -n "${tmp}"; then
|
||||||
|
rm -f "${tmp}"
|
||||||
|
warn "未能安装管理命令;完成安装后可重新运行官方脚本修复。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
install -m 0755 "${tmp}" "${CLI_PATH}"
|
||||||
|
rm -f "${tmp}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
refresh_assets() {
|
refresh_assets() {
|
||||||
stage_assets
|
stage_assets
|
||||||
apply_staged_assets
|
apply_staged_assets
|
||||||
@@ -1077,6 +1097,277 @@ do_install() {
|
|||||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_restore_source() {
|
||||||
|
local source="$1"
|
||||||
|
[[ -f "${source}/.env" ]] || { warn "备份缺少 .env。"; return 1; }
|
||||||
|
[[ -f "${source}/docker-compose.yml" ]] || { warn "备份缺少 docker-compose.yml。"; return 1; }
|
||||||
|
[[ -s "${source}/data/lanqin.db" ]] || { warn "备份缺少数据库 data/lanqin.db。"; return 1; }
|
||||||
|
[[ -d "${source}/mail" ]] || { warn "备份缺少 mail 邮件目录。"; return 1; }
|
||||||
|
[[ -d "${source}/dkim" ]] || { warn "备份缺少 dkim 密钥目录。"; return 1; }
|
||||||
|
[[ -d "${source}/certs" ]] || { warn "备份缺少 certs 证书目录。"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_restore_database() {
|
||||||
|
local database="$1" result
|
||||||
|
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
log "正在安装 SQLite 校验工具..."
|
||||||
|
install_packages sqlite3
|
||||||
|
fi
|
||||||
|
result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
|
||||||
|
[[ "${result}" == "ok" ]] || { warn "备份数据库完整性检查未通过。"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
locate_extracted_restore_root() {
|
||||||
|
local root="$1" candidate
|
||||||
|
if validate_restore_source "${root}" >/dev/null 2>&1; then
|
||||||
|
printf '%s' "${root}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
candidate="$(find "${root}" -mindepth 1 -maxdepth 2 -type f -name .env -print -quit 2>/dev/null || true)"
|
||||||
|
[[ -n "${candidate}" ]] || return 1
|
||||||
|
candidate="$(dirname "${candidate}")"
|
||||||
|
validate_restore_source "${candidate}" >/dev/null 2>&1 || return 1
|
||||||
|
printf '%s' "${candidate}"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_restore_menu() {
|
||||||
|
prompt_text '\n==================================================\n'
|
||||||
|
prompt_text ' NewSzxcn Email 备份恢复\n'
|
||||||
|
prompt_text '==================================================\n'
|
||||||
|
prompt_text '1. 本地上传\n'
|
||||||
|
prompt_text '2. 返回上一级\n'
|
||||||
|
prompt_text '==================================================\n'
|
||||||
|
prompt_text '请先将原始加密备份上传到新服务器的 /root/ 目录,不要解压。\n'
|
||||||
|
prompt_text '系统会自动检测 /root/ 目录中的 NewSzxcn 备份文件。\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restore_menu() {
|
||||||
|
local choice
|
||||||
|
render_restore_menu
|
||||||
|
choice="$(prompt_menu_choice "1" "2")" || return 1
|
||||||
|
case "${choice}" in
|
||||||
|
1) do_restore_backup ;;
|
||||||
|
2) success "已返回,未作任何修改。" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_restore_password() {
|
||||||
|
local password="${LANQIN_RESTORE_PASSWORD:-}"
|
||||||
|
if [[ -z "${password}" ]] && has_tty; then
|
||||||
|
read -r -s -p "备份密码: " password </dev/tty
|
||||||
|
printf '\n' >/dev/tty
|
||||||
|
fi
|
||||||
|
[[ -n "${password}" ]] || fail "加密备份必须提供备份密码。"
|
||||||
|
(( ${#password} >= 8 && ${#password} <= 1024 )) || fail "备份密码必须为 8 至 1024 个字符。"
|
||||||
|
[[ "${password}" != *$'\n'* && "${password}" != *$'\r'* ]] || fail "备份密码不能包含换行。"
|
||||||
|
printf '%s' "${password}"
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_restore_backups() {
|
||||||
|
local search_dir="${LANQIN_RESTORE_SEARCH_DIR:-/root}" path
|
||||||
|
local -a matches=()
|
||||||
|
[[ -d "${search_dir}" ]] || return 0
|
||||||
|
while IFS= read -r path; do
|
||||||
|
case "${path}" in
|
||||||
|
*.tar.zst.enc|*.tar.zst|*.tar.gz|*.tgz|*.tar) matches+=("${path}") ;;
|
||||||
|
esac
|
||||||
|
done < <(find "${search_dir}" -maxdepth 1 -type f -name 'newszxcn-backup-*' -print 2>/dev/null | LC_ALL=C sort -r)
|
||||||
|
(( ${#matches[@]} > 0 )) || return 0
|
||||||
|
printf '%s\n' "${matches[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_restore_source() {
|
||||||
|
local selection="${LANQIN_RESTORE_SELECTION:-}" path index
|
||||||
|
local -a backups=()
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -n "${path}" ]] && backups+=("${path}")
|
||||||
|
done < <(discover_restore_backups)
|
||||||
|
|
||||||
|
if (( ${#backups[@]} == 1 )); then
|
||||||
|
prompt_text "[检测] 已找到备份:${backups[0]}\n"
|
||||||
|
printf '%s' "${backups[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if (( ${#backups[@]} > 1 )); then
|
||||||
|
prompt_text "[检测] 在 /root/ 找到 ${#backups[@]} 份备份:\n"
|
||||||
|
for index in "${!backups[@]}"; do
|
||||||
|
prompt_text "$((index + 1)). ${backups[index]}\n"
|
||||||
|
done
|
||||||
|
prompt_text "$(( ${#backups[@]} + 1 )). 手动输入其他路径\n"
|
||||||
|
if [[ -z "${selection}" ]] && has_tty; then
|
||||||
|
read -r -p "请输入要恢复的备份序号 [1]: " selection </dev/tty
|
||||||
|
fi
|
||||||
|
selection="${selection:-1}"
|
||||||
|
if [[ "${selection}" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#backups[@]} )); then
|
||||||
|
prompt_text "[选择] 将使用第 ${selection} 份备份开始恢复。\n"
|
||||||
|
printf '%s' "${backups[selection-1]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "${selection}" == "$(( ${#backups[@]} + 1 ))" ]] || fail "备份序号无效。"
|
||||||
|
else
|
||||||
|
prompt_text "[提示] /root/ 目录没有检测到 NewSzxcn 备份,请手动输入路径。\n"
|
||||||
|
fi
|
||||||
|
if has_tty; then
|
||||||
|
read -r -p "备份文件完整路径: " path </dev/tty
|
||||||
|
else
|
||||||
|
path="${LANQIN_RESTORE_SOURCE:-}"
|
||||||
|
fi
|
||||||
|
printf '%s' "${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_has_unsafe_paths() {
|
||||||
|
awk '
|
||||||
|
BEGIN { bad=0 }
|
||||||
|
{
|
||||||
|
if (substr($0, 1, 1) == "/") bad=1
|
||||||
|
count=split($0, parts, "/")
|
||||||
|
for (i=1; i<=count; i++) if (parts[i] == "..") bad=1
|
||||||
|
}
|
||||||
|
END { exit bad ? 0 : 1 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_has_unsafe_types() {
|
||||||
|
awk '
|
||||||
|
BEGIN { bad=0 }
|
||||||
|
/^[[:space:]]*$/ { next }
|
||||||
|
{
|
||||||
|
type=substr($0, 1, 1)
|
||||||
|
if (type != "-" && type != "d") bad=1
|
||||||
|
}
|
||||||
|
END { exit bad ? 0 : 1 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_restore_archive() {
|
||||||
|
local source="$1" destination="$2" password decrypted
|
||||||
|
case "${source}" in
|
||||||
|
*.tar.zst.enc)
|
||||||
|
command -v openssl >/dev/null 2>&1 || install_packages openssl
|
||||||
|
command -v zstd >/dev/null 2>&1 || install_packages zstd
|
||||||
|
password="$(prompt_restore_password)"
|
||||||
|
decrypted="${destination}/backup.tar.zst"
|
||||||
|
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -in "${source}" -out "${decrypted}" -pass fd:3 3<<<"${password}" 2>/dev/null; then
|
||||||
|
fail "备份密码错误或加密备份已损坏。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${decrypted}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${decrypted}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
zstd -dc "${decrypted}" 2>/dev/null | tar -xf - -C "${destination}" \
|
||||||
|
|| fail "加密备份无法解压,请检查文件和密码。"
|
||||||
|
rm -f "${decrypted}"
|
||||||
|
;;
|
||||||
|
*.tar.zst)
|
||||||
|
command -v zstd >/dev/null 2>&1 || install_packages zstd
|
||||||
|
if zstd -dc "${source}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${source}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
zstd -dc "${source}" 2>/dev/null | tar -xf - -C "${destination}" \
|
||||||
|
|| fail "Zstandard 备份无法解压。"
|
||||||
|
;;
|
||||||
|
*.tar|*.tar.gz|*.tgz)
|
||||||
|
if tar -tf "${source}" | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if tar -tvf "${source}" | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
tar -xf "${source}" -C "${destination}" || fail "备份压缩包无法解压。"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "不支持的备份格式;请选择 .tar.zst.enc、.tar.zst、.tar.gz、.tgz 或 .tar。"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restore_backup() {
|
||||||
|
local source="${LANQIN_RESTORE_SOURCE:-}" extracted="" restore_root staging image_ref image nginx_backup=""
|
||||||
|
! installation_configured || fail "当前服务器已经存在安装配置;为防止覆盖运行数据,只能在空白新服务器执行完整恢复。"
|
||||||
|
[[ -n "${source}" ]] || source="$(select_restore_source)"
|
||||||
|
[[ -n "${source}" ]] || fail "请提供备份目录或备份压缩包路径。"
|
||||||
|
source="$(readlink -f "${source}" 2>/dev/null || true)"
|
||||||
|
[[ -e "${source}" ]] || fail "备份不存在:${source}"
|
||||||
|
|
||||||
|
if [[ -d "${source}" ]]; then
|
||||||
|
restore_root="${source}"
|
||||||
|
else
|
||||||
|
command -v tar >/dev/null 2>&1 || install_packages tar
|
||||||
|
extracted="$(mktemp -d)"
|
||||||
|
extract_restore_archive "${source}" "${extracted}"
|
||||||
|
restore_root="$(locate_extracted_restore_root "${extracted}" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${restore_root}" ]] || ! validate_restore_source "${restore_root}"; then
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
fail "这不是可恢复的 NewSzxcn 完整备份。"
|
||||||
|
fi
|
||||||
|
if ! validate_restore_database "${restore_root}/data/lanqin.db"; then
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
fail "备份数据库已损坏,未写入任何 NewSzxcn 数据。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
staging="${INSTALL_DIR}.restore-staging-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
[[ ! -e "${INSTALL_DIR}" || -z "$(find "${INSTALL_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] \
|
||||||
|
|| fail "${INSTALL_DIR} 已有文件,已取消恢复以免覆盖数据。"
|
||||||
|
rm -rf "${staging}"
|
||||||
|
install -d -m 0700 "${staging}"
|
||||||
|
cp -a "${restore_root}/." "${staging}/"
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
mv "${staging}" "${INSTALL_DIR}"
|
||||||
|
chmod 0600 "${INSTALL_DIR}/.env"
|
||||||
|
|
||||||
|
if [[ -f "${NGINX_CONFIG}" ]]; then
|
||||||
|
nginx_backup="$(mktemp)"
|
||||||
|
cp -a "${NGINX_CONFIG}" "${nginx_backup}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! (
|
||||||
|
refresh_assets
|
||||||
|
ensure_update_token
|
||||||
|
ensure_admin_email_config
|
||||||
|
configure_runtime_bindings
|
||||||
|
ensure_docker
|
||||||
|
configure_firewall
|
||||||
|
prepare_directories
|
||||||
|
log "正在拉取恢复所需的 NewSzxcn Email 镜像..."
|
||||||
|
compose pull
|
||||||
|
image_ref="$(env_value LANQIN_IMAGE || true)"
|
||||||
|
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
|
||||||
|
image="$(docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null || true)"
|
||||||
|
[[ -n "${image}" ]] || fail "无法检查恢复数据库:镜像不存在。"
|
||||||
|
sqlite_integrity_check "${INSTALL_DIR}/data/lanqin.db" "${image}" || fail "备份数据库完整性检查未通过,服务未启动。"
|
||||||
|
log "备份检查通过,正在启动服务..."
|
||||||
|
compose up -d --remove-orphans
|
||||||
|
wait_for_health 90 || fail "恢复后的服务未通过健康检查,请执行 newszxcn-email logs。"
|
||||||
|
configure_web_mode
|
||||||
|
); then
|
||||||
|
warn "恢复未完成,正在清理本次未成功的安装。"
|
||||||
|
compose down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
if [[ -n "${nginx_backup}" && -f "${nginx_backup}" ]]; then
|
||||||
|
cp -a "${nginx_backup}" "${NGINX_CONFIG}"
|
||||||
|
elif [[ -f "${NGINX_CONFIG}" ]]; then
|
||||||
|
rm -f "${NGINX_CONFIG}"
|
||||||
|
fi
|
||||||
|
rm -f "${nginx_backup}"
|
||||||
|
if nginx -t >/dev/null 2>&1; then
|
||||||
|
systemctl reload nginx >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fail "恢复失败,原始备份文件未修改;修复问题后可重新执行备份恢复。"
|
||||||
|
fi
|
||||||
|
rm -f "${nginx_backup}"
|
||||||
|
ensure_cli_alias
|
||||||
|
generate_guide >/dev/null || warn "数据已恢复,但配置指南生成失败,可稍后执行 newszxcn-email guide。"
|
||||||
|
success "备份恢复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||||
|
warn "如果服务器 IP 已更换,请更新 A、MX、SPF、PTR,并重新检查 TLS 证书。"
|
||||||
|
}
|
||||||
|
|
||||||
do_update() {
|
do_update() {
|
||||||
require_installation
|
require_installation
|
||||||
ensure_docker
|
ensure_docker
|
||||||
@@ -1503,7 +1794,8 @@ render_uninstalled_menu() {
|
|||||||
prompt_text '状态:尚未安装\n'
|
prompt_text '状态:尚未安装\n'
|
||||||
prompt_text '--------------------------------------------------\n'
|
prompt_text '--------------------------------------------------\n'
|
||||||
prompt_text '1. 一键安装 NewSzxcn Email\n'
|
prompt_text '1. 一键安装 NewSzxcn Email\n'
|
||||||
prompt_text '0. 退出\n'
|
prompt_text '2. 备份恢复\n'
|
||||||
|
prompt_text '3. 退出\n'
|
||||||
prompt_text '==================================================\n'
|
prompt_text '==================================================\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1541,10 +1833,11 @@ do_menu() {
|
|||||||
local default_choice="2" public_url="" choice status version
|
local default_choice="2" public_url="" choice status version
|
||||||
if ! installation_configured; then
|
if ! installation_configured; then
|
||||||
render_uninstalled_menu
|
render_uninstalled_menu
|
||||||
choice="$(prompt_menu_choice "1" "1")" || return 1
|
choice="$(prompt_menu_choice "1" "3")" || return 1
|
||||||
case "${choice}" in
|
case "${choice}" in
|
||||||
0) success "已退出,未作任何修改。" ;;
|
3) success "已退出,未作任何修改。" ;;
|
||||||
1) do_install ;;
|
1) do_install ;;
|
||||||
|
2) do_restore_menu ;;
|
||||||
esac
|
esac
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -1581,6 +1874,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${EUID}" -eq 0 ]]; then
|
if [[ "${EUID}" -eq 0 ]]; then
|
||||||
|
ensure_cli_command
|
||||||
ensure_cli_alias
|
ensure_cli_alias
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1588,6 +1882,7 @@ case "${COMMAND}" in
|
|||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
menu) require_root; require_curl; do_menu ;;
|
menu) require_root; require_curl; do_menu ;;
|
||||||
install) require_root; require_curl; do_install ;;
|
install) require_root; require_curl; do_install ;;
|
||||||
|
restore) require_root; require_curl; do_restore_menu ;;
|
||||||
update) require_root; require_curl; do_update ;;
|
update) require_root; require_curl; do_update ;;
|
||||||
repair) require_root; require_curl; do_repair_install ;;
|
repair) require_root; require_curl; do_repair_install ;;
|
||||||
status) require_root; require_curl; do_status ;;
|
status) require_root; require_curl; do_status ;;
|
||||||
|
|||||||
+152
-3
@@ -159,6 +159,8 @@ test_menu_rendering() (
|
|||||||
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
|
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
|
||||||
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
|
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
|
||||||
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
|
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
|
||||||
|
[[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing"
|
||||||
|
[[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing"
|
||||||
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
|
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
|
||||||
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
|
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
|
||||||
|
|
||||||
@@ -186,12 +188,15 @@ test_menu_dispatch() (
|
|||||||
mkdir -p "${INSTALL_DIR}"
|
mkdir -p "${INSTALL_DIR}"
|
||||||
prompt_text() { :; }
|
prompt_text() { :; }
|
||||||
do_install() { printf 'install\n' > "${action_file}"; }
|
do_install() { printf 'install\n' > "${action_file}"; }
|
||||||
|
do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; }
|
||||||
|
|
||||||
do_menu
|
do_menu
|
||||||
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
|
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
|
||||||
if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then
|
LANQIN_MENU_ACTION=2
|
||||||
fail_test "uninstalled menu accepted unavailable update action"
|
do_menu
|
||||||
fi
|
grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore"
|
||||||
|
LANQIN_MENU_ACTION=3
|
||||||
|
do_menu >/dev/null
|
||||||
|
|
||||||
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
|
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
|
||||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||||
@@ -430,6 +435,144 @@ test_cli_alias_safety() (
|
|||||||
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
|
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_restore_source_validation() (
|
||||||
|
local temp_dir
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs"
|
||||||
|
printf 'config\n' > "${temp_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${temp_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected"
|
||||||
|
validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected"
|
||||||
|
printf 'damaged\n' > "${temp_dir}/data/lanqin.db"
|
||||||
|
if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then
|
||||||
|
fail_test "damaged restore database accepted"
|
||||||
|
fi
|
||||||
|
rm -f "${temp_dir}/data/lanqin.db"
|
||||||
|
if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then
|
||||||
|
fail_test "restore source without database accepted"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_menu_rendering_and_dispatch() (
|
||||||
|
local output action_file LANQIN_MENU_ACTION=1
|
||||||
|
action_file="$(mktemp)"
|
||||||
|
prompt_text() { printf '%b' "$1"; }
|
||||||
|
output="$(render_restore_menu)"
|
||||||
|
[[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing"
|
||||||
|
[[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing"
|
||||||
|
[[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing"
|
||||||
|
[[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing"
|
||||||
|
prompt_text() { :; }
|
||||||
|
do_restore_backup() { printf 'restore\n' > "${action_file}"; }
|
||||||
|
do_restore_menu
|
||||||
|
grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload"
|
||||||
|
LANQIN_MENU_ACTION=2
|
||||||
|
do_restore_menu >/dev/null
|
||||||
|
unset LANQIN_MENU_ACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_backup_discovery() (
|
||||||
|
local temp_dir output selected
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
LANQIN_RESTORE_SEARCH_DIR="${temp_dir}"
|
||||||
|
touch "${temp_dir}/unrelated.tar.zst.enc"
|
||||||
|
output="$(discover_restore_backups)"
|
||||||
|
[[ -z "${output}" ]] || fail_test "unrelated archive was discovered"
|
||||||
|
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc"
|
||||||
|
selected="$(select_restore_source)"
|
||||||
|
assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup"
|
||||||
|
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc"
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc"
|
||||||
|
output="$(discover_restore_backups)"
|
||||||
|
assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering"
|
||||||
|
LANQIN_RESTORE_SELECTION=2
|
||||||
|
selected="$(select_restore_source)"
|
||||||
|
assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_encrypted_restore_archive() (
|
||||||
|
local temp_dir source_dir archive extracted password='RestorePassword123!'
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
source_dir="${temp_dir}/source/newszxcn-email"
|
||||||
|
archive="${temp_dir}/newszxcn-backup.tar.zst.enc"
|
||||||
|
extracted="${temp_dir}/extracted"
|
||||||
|
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}"
|
||||||
|
printf 'config\n' > "${source_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
zstd() {
|
||||||
|
if [[ "$*" == '-q -c' ]]; then
|
||||||
|
gzip -c
|
||||||
|
elif [[ "$1" == '-dc' ]]; then
|
||||||
|
gzip -dc "$2"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
|
||||||
|
LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}"
|
||||||
|
validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_failed_full_restore_cleans_partial_install() (
|
||||||
|
local temp_dir source_dir archive password='RestorePassword123!'
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
source_dir="${temp_dir}/source/newszxcn-backup"
|
||||||
|
archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc"
|
||||||
|
INSTALL_DIR="${temp_dir}/install"
|
||||||
|
NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf"
|
||||||
|
CERT_DIR="${temp_dir}/certs"
|
||||||
|
LANQIN_RESTORE_SOURCE="${archive}"
|
||||||
|
LANQIN_RESTORE_PASSWORD="${password}"
|
||||||
|
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")"
|
||||||
|
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
zstd() {
|
||||||
|
if [[ "$*" == '-q -c' ]]; then
|
||||||
|
gzip -c
|
||||||
|
elif [[ "$1" == '-dc' ]]; then
|
||||||
|
gzip -dc "$2"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
|
||||||
|
refresh_assets() { return 0; }
|
||||||
|
ensure_update_token() { return 0; }
|
||||||
|
ensure_admin_email_config() { return 0; }
|
||||||
|
configure_runtime_bindings() { return 0; }
|
||||||
|
ensure_docker() { return 0; }
|
||||||
|
configure_firewall() { return 0; }
|
||||||
|
prepare_directories() { return 0; }
|
||||||
|
compose() {
|
||||||
|
case "$1" in
|
||||||
|
pull) return 1 ;;
|
||||||
|
down) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (do_restore_backup >/dev/null 2>&1); then
|
||||||
|
fail_test "failed full restore unexpectedly succeeded"
|
||||||
|
fi
|
||||||
|
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation"
|
||||||
|
[[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_archive_path_validation() (
|
||||||
|
printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected"
|
||||||
|
printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted"
|
||||||
|
printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted"
|
||||||
|
printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected"
|
||||||
|
printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected"
|
||||||
|
printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted"
|
||||||
|
)
|
||||||
|
|
||||||
test_compose_runtime_image_pin() (
|
test_compose_runtime_image_pin() (
|
||||||
local temp_dir calls
|
local temp_dir calls
|
||||||
temp_dir="$(mktemp -d)"
|
temp_dir="$(mktemp -d)"
|
||||||
@@ -707,6 +850,12 @@ test_offline_database_backup
|
|||||||
test_guide_generation
|
test_guide_generation
|
||||||
test_acme_cron_detection
|
test_acme_cron_detection
|
||||||
test_cli_alias_safety
|
test_cli_alias_safety
|
||||||
|
test_restore_source_validation
|
||||||
|
test_restore_menu_rendering_and_dispatch
|
||||||
|
test_restore_backup_discovery
|
||||||
|
test_encrypted_restore_archive
|
||||||
|
test_failed_full_restore_cleans_partial_install
|
||||||
|
test_restore_archive_path_validation
|
||||||
test_compose_runtime_image_pin
|
test_compose_runtime_image_pin
|
||||||
test_update_snapshot_restore
|
test_update_snapshot_restore
|
||||||
test_snapshot_restores_absent_optional_files
|
test_snapshot_restores_absent_optional_files
|
||||||
|
|||||||
Reference in New Issue
Block a user