Compare commits

...

6 Commits

Author SHA1 Message Date
zxyszx c92140c9cc release: prepare v1.2.30
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 05:34:49 +08:00
zxyszx 2fd37bf635 release: prepare v1.2.29
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 04:25:17 +08:00
zxyszx 3d3a251af2 release: prepare v1.2.28
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-12 01:15:42 +08:00
zxyszx 6ad9164be3 release: prepare v1.2.27
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 23:40:55 +08:00
zxyszx ce98978ebd release: prepare v1.2.26
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-11 14:09:08 +08:00
zxyszx ee38990ea1 release: prepare v1.2.25
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 22:30:39 +08:00
29 changed files with 1502 additions and 504 deletions
+9
View File
@@ -0,0 +1,9 @@
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
+7
View File
@@ -0,0 +1,7 @@
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
+6
View File
@@ -0,0 +1,6 @@
- 修复收信规则选择“移动到自定义文件夹”后立即跳回归档的问题。
- 移除移动动作右侧显示 `Archive` 等英文内部值的旧输入框,避免系统值与中文选项重复出现。
- 将移动目标整合为一个文件夹选择器,系统文件夹统一显示中文名称。
- 文件夹选择器新增“系统文件夹”和“自定义文件夹”分组,自动列出当前邮箱或全部邮箱中已经创建的自定义文件夹。
- 选择 Netflix、amazon 等自定义文件夹后会直接保存真实文件夹名称,不再错误保存为归档。
- 已验证规则创建、列表摘要和后端数据均保留所选自定义文件夹。
+8
View File
@@ -0,0 +1,8 @@
- 收信规则页新增规则名称搜索,支持按字母或完整名称快速筛选,并提供清空搜索和空结果提示。
- 优化规则列表的桌面端与手机端布局,规则名称、适用邮箱、条件和动作可完整换行显示,操作按钮不再挤压内容。
- 调整新建规则工具栏与表单对齐,搜索框与规则列表等宽,“所有条件”选择器与下方条件字段保持同一左边界。
- 邮箱侧栏的“文件夹”和“标签”支持点击展开或收起,桌面端和手机侧栏保持一致交互。
- 降低所有对话框的背景遮罩深度,手机端和桌面端分别使用更轻的灰色层级,避免打开弹窗后页面过黑。
- 重排“被拦截邮件”页面,统一标题、统计和新增入口,长邮箱地址与拦截原因会自动换行,不再超出边框。
- 重排“邮件清理”页面的统计卡片和清理操作,修复窄屏文字截断、按钮挤压和横向溢出。
- 统一前后台文字按钮样式,移除“新建规则”、“新增拦截”、权限配置、账号、域名、邮箱和转发等命令前多余的加号图标。
+13
View File
@@ -0,0 +1,13 @@
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
+9
View File
@@ -0,0 +1,9 @@
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
+1 -1
View File
@@ -1 +1 @@
1.2.24
1.2.30
+148 -56
View File
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users")
return
}
@@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB int `json:"storageQuotaMb"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
storageQuotaMB := req.StorageQuotaMB
if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if storageQuotaMB == 0 {
storageQuotaMB = defaultUserStorageQuotaMB
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
@@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
localPart, domainName, _ := strings.Cut(primaryEmail, "@")
var primaryDomainID string
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
badRequest(w, err)
return
}
} else if !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "failed to load account domain")
return
}
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
@@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
StorageQuotaMB *int `json:"storageQuotaMb"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
var storageQuotaMB int
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load storage quota")
return
}
if req.StorageQuotaMB != nil {
storageQuotaMB = *req.StorageQuotaMB
}
if storageQuotaMB < 100 {
badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
@@ -311,8 +344,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
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=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用"))
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 {
respondError(w, http.StatusNotFound, "user not found")
return
} else if a.isDefaultAdminUser(target) {
badRequest(w, errors.New("default administrator cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
} else if target.Role == "admin" {
badRequest(w, errors.New("administrator accounts cannot be deleted"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
@@ -553,6 +583,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -563,7 +594,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
@@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
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, "管理员只能由安装流程创建")
userID := strings.TrimSpace(req.UserID)
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
@@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
now := a.now().UTC().Format(time.RFC3339Nano)
userID := strings.TrimSpace(req.UserID)
displayName := req.DisplayName
if displayName == "" {
displayName = address
}
var disabled, ownerStorageQuotaMB int
var passwordHash, ownerRole string
if userID != "" {
var disabled int
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
return
}
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
} else {
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
} else {
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
@@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
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) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if hashErr != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
passwordHash = string(hash)
ownerRole = "user"
ownerStorageQuotaMB = defaultUserStorageQuotaMB
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
@@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
if intBool(disabled) {
badRequest(w, errors.New("owner user is disabled"))
return
}
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
m, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -699,8 +726,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName is required"))
return
}
if req.QuotaMB <= 0 {
req.QuotaMB = 1024
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
status := strings.TrimSpace(req.Status)
if status == "" {
@@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid status"))
return
}
existingMailbox, err := a.mailboxByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if existingMailbox.Primary && status != existingMailbox.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
userID := strings.TrimSpace(req.UserID)
if userID == "" {
badRequest(w, errors.New("userId is required"))
return
}
if existingMailbox.Primary && userID != existingMailbox.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
var disabled int
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("owner user is disabled"))
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if ownerRole == "admin" {
req.QuotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -749,6 +794,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -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) {
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=?
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
var item AdminUser
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err
}
item.Disabled = intBool(disabled)
@@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
return nil, err
}
m.CreatedAt = parseTime(created)
if err := a.markMailboxPrimary(ctx, &m); err != nil {
return nil, err
}
return &m, nil
}
func markPrimaryMailboxes(items []Mailbox) {
primaryByUser := make(map[string]int)
for i := range items {
candidate, ok := primaryByUser[items[i].UserID]
if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
primaryByUser[items[i].UserID] = i
}
}
for _, index := range primaryByUser {
items[index].Primary = true
}
}
func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
var primaryID string
err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE mb.user_id=?
ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
if err != nil {
return err
}
mailbox.Primary = mailbox.ID == primaryID
return nil
}
func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
mailbox, err := a.mailboxByID(ctx, id)
if err != nil {
return err
}
if mailbox.Primary {
return errors.New("用户默认邮箱不能删除")
}
return nil
}
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
var m Mailbox
+75 -6
View File
@@ -40,6 +40,12 @@ type App struct {
telegramDeliveryMu sync.Mutex
}
const (
defaultUserStorageQuotaMB = 100
defaultAdminStorageQuotaMB = 1024
minimumStorageQuotaMB = 100
)
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
@@ -97,6 +103,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close()
return nil, err
@@ -168,6 +178,7 @@ func (a *App) migrate(ctx context.Context) error {
two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER,
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -317,6 +328,7 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'folder',
sort_order INTEGER NOT NULL DEFAULT 0,
uid_validity INTEGER NOT NULL DEFAULT 0,
uid_next INTEGER NOT NULL DEFAULT 1,
@@ -672,6 +684,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err
}
if err := a.migrateUserStorageQuota(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
@@ -690,6 +705,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateFolderSortOrder(ctx); err != nil {
return err
}
if err := a.migrateFolderIcons(ctx); err != nil {
return err
}
if err := a.migrateExternalIMAP(ctx); err != nil {
return err
}
@@ -1331,6 +1349,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
return err
}
func (a *App) migrateUserStorageQuota(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
return err
}
hasColumn := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "storage_quota_mb" {
hasColumn = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasColumn {
return nil
}
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
return err
}
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
@@ -1509,8 +1560,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
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)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1531,7 +1582,7 @@ func (a *App) seed(ctx context.Context) error {
}
// Create mailbox for admin
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
if err != nil {
return err
}
@@ -1652,7 +1703,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
if !errors.Is(err, sql.ErrNoRows) {
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 {
return err
}
@@ -1867,8 +1918,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
if localPart == "" {
return "", errors.New("invalid local part")
}
if quotaMB <= 0 {
quotaMB = 1024
if quotaMB < 0 {
return "", errors.New("quotaMb must be zero or greater")
}
var ownerRole string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
return "", err
}
if ownerRole == "admin" {
quotaMB = 0
}
if status == "" {
status = "active"
@@ -1901,6 +1959,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
return id, nil
}
func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
SET quota_mb=0, updated_at=?
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
return err
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
+387 -9
View File
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
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",
}, &regular); 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,
}, &regularMailbox); 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!",
}, &regular); 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 {
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) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1389,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "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 {
User User `json:"user"`
}
@@ -2293,7 +2626,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
}
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)
}
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, "客户归档") {
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
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 {
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
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
"domainId": domain.ID,
"localPart": "api-user",
"displayName": "API User",
"password": "Password123!",
"password": "DifferentPassword123!",
"quotaMb": 256,
"userId": owner.ID,
}, &mailbox); code != http.StatusCreated {
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 {
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 {
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" {
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
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)
@@ -5001,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden {
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{
"name": "Changed Regular",
"description": "Should not change",
"permissions": []string{PermissionAdminOverview},
}, &regularUpdateErr); code != http.StatusForbidden {
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
"permissions": regularUserDefaultPermissions(),
"limits": defaultPermissionLimits(),
}, &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)
if err != nil {
t.Fatal(err)
}
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 {
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()
user, mb := defaultAdminUserAndMailbox(t, a)
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)
}
_, 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 {
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)
}
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
+3 -3
View File
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
+4
View File
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
return nil
}
func (a *App) migrateFolderIcons(ctx context.Context) error {
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
+101 -15
View File
@@ -1,12 +1,14 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image/png"
"io"
"net/http"
"net/textproto"
@@ -85,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -98,12 +101,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
@@ -121,7 +124,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -132,7 +135,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
@@ -158,7 +161,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -266,6 +269,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := decodeJSON(r, &req); err != nil {
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"))
return
}
icon := folderIconForName(name, req.Icon)
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
@@ -308,12 +313,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
return
}
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")
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
}
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")
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
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})
}
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
return a.ensureFolder(ctx, mailboxID, name)
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
var existingID string
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
return existingID, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
id, err := a.ensureFolder(ctx, mailboxID, name)
if err != nil {
return "", err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
return id, err
}
func folderIconForName(name, requested string) string {
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
return normalizeFolderIcon(icon)
}
value := strings.ToLower(strings.TrimSpace(name))
for _, match := range []struct {
icon string
terms []string
}{
{"netflix", []string{"netflix", "奈飞", "网飞"}},
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
{"star", []string{"重要", "紧急", "important", "urgent"}},
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
{"bell", []string{"提醒", "通知", "remind", "notification"}},
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
} {
for _, term := range match.terms {
if folderNameContainsTerm(value, term) {
return match.icon
}
}
}
return "folder"
}
func folderNameContainsTerm(value, term string) bool {
if term != "gpt" {
return strings.Contains(value, term)
}
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == term {
return true
}
}
return false
}
func normalizeFolderIcon(raw string) string {
icon := strings.TrimSpace(raw)
const customPrefix = "data:image/png;base64,"
if strings.HasPrefix(icon, customPrefix) {
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
config, configErr := png.DecodeConfig(bytes.NewReader(data))
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
return icon
}
return "folder"
}
icon = strings.ToLower(icon)
switch icon {
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
return icon
default:
return "folder"
}
}
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
@@ -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) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
var f MailFolder
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
return nil, err
}
return &f, nil
@@ -2535,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
return nil
}
var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); 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(&quotaMB, &userID); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
+7 -4
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
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 {
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
}
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
t.Fatalf("delete final mailbox code=%d", code)
if !mailboxes.Items[0].Primary {
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))
}
var me map[string]any
+51 -6
View File
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
if req.QuotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
respondMailboxOwnerError(w, err)
return
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
var ownerPasswordHash, ownerRole string
var ownerStorageQuotaMB int
if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
quotaMB := req.QuotaMB
if quotaMB == 0 {
quotaMB = ownerStorageQuotaMB
}
if ownerRole == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
displayName = current.DisplayName
}
quotaMB := req.QuotaMB
if quotaMB <= 0 {
if quotaMB < 0 {
badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
if quotaMB == 0 {
quotaMB = current.QuotaMB
}
status := strings.TrimSpace(req.Status)
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
if userID == "" {
userID = current.UserID
}
if current.Primary && userID != current.UserID {
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
return
}
if current.Primary && status != current.Status {
badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
return
}
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
respondMailboxOwnerError(w, err)
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
var ownerRole, ownerPasswordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load owner user")
return
}
if ownerRole == "admin" {
quotaMB = 0
}
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
} else {
badRequest(w, err)
}
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
if displayName == "" {
displayName = email
}
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
return userID, err
}
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
var existingName, existingDescription string
if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if intBool(existingSystem) {
if intBool(existingSystem) && id != PermissionGroupRegular {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return
}
name := strings.TrimSpace(req.Name)
if id == PermissionGroupRegular {
name = existingName
req.Description = existingDescription
}
if name == "" {
badRequest(w, errors.New("name is required"))
return
+64 -59
View File
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
quotaMB := defaultUserStorageQuotaMB
if user.Role == "admin" {
quotaMB = 0
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
@@ -534,12 +538,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
return
}
appliedCount := int64(0)
if req.ApplyToExisting && enabled {
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
if req.ApplyToExisting {
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
return
}
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
item, err := scanRule(row)
@@ -896,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return
}
if mailboxID != "" && !isAllMailboxID(mailboxID) {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
@@ -1330,7 +1326,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
if !ruleMatches(rule, msg) {
continue
}
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
continue
}
if rule.StopProcessing {
break
}
@@ -1375,7 +1373,7 @@ type ruleMessage struct {
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
@@ -1407,7 +1405,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
parts = append(parts, filename)
}
return strings.Join(parts, " ")
}
@@ -1453,15 +1451,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
if operator == "" {
operator = "contains"
}
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
if !validRuleConditionOperator(field, operator) {
return MailRuleCondition{}, false
}
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func validRuleConditionOperator(field, operator string) bool {
switch field {
case "size":
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
case "date":
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
default:
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
}
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
@@ -1710,23 +1716,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
for _, action := range normalizeRuleActions(actions, "") {
switch action.Type {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "move":
target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
target, err := normalizeFolderNameForUser(action.Value)
if err != nil {
return err
}
var folderID string
if isSystemFolderName(target) {
folderID, err = a.ensureFolder(ctx, mailboxID, target)
} else {
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
}
if err != nil {
return err
}
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
case "star":
starred := true
@@ -1789,21 +1809,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
return err
}
func ruleTargetFolder(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "inbox":
return "Inbox"
case "archive":
return "Archive"
case "spam":
return "Spam"
case "trash":
return "Trash"
default:
return "Archive"
}
}
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
args := []any{userID}
where := `mb.user_id=?`
@@ -1811,7 +1816,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
if err != nil {
return 0, err
}
+5 -2
View File
@@ -21,8 +21,9 @@ type User struct {
type AdminUser struct {
User
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
StorageQuotaMB int `json:"storageQuotaMb"`
}
type APIToken struct {
@@ -71,6 +72,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
Primary bool `json:"primary"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -88,6 +90,7 @@ type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
+2 -2
View File
@@ -26,12 +26,12 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <div className="text-sm text-muted-foreground">{description}</div>}
<DialogFooter>
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelText}
</Button>
@@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
@@ -110,9 +109,6 @@ function ProtectedContent() {
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
+4 -1
View File
@@ -8,6 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card"
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
+1 -1
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:bg-black/45",
className
)}
{...props}
+4 -1
View File
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
>(({ className, ...props }, ref) => (
<tr
ref={ref}
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
+40 -2
View File
@@ -140,12 +140,50 @@
}
[data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent));
background: hsl(var(--sidebar-accent) / 0.82);
color: hsl(var(--sidebar-accent-foreground));
box-shadow: inset 2px 0 0 hsl(var(--foreground));
}
[data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground));
color: hsl(var(--foreground));
}
.admin-page [data-slot="card"] {
border-radius: 0.5rem;
box-shadow: none;
}
.admin-page [data-slot="card-header"] {
padding: 1.25rem;
}
.admin-page [data-slot="card-content"] {
padding: 0 1.25rem 1.25rem;
}
.admin-page [data-slot="card-title"] {
font-size: 0.9375rem;
line-height: 1.4;
}
.admin-page [data-slot="table-header"] {
background: hsl(var(--muted) / 0.48);
}
.admin-page [data-slot="table-head"] {
height: 2.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.75rem;
}
.admin-page [data-slot="table-cell"] {
padding: 0.75rem;
}
.admin-page [data-slot="table-row"]:hover {
background: hsl(var(--muted) / 0.32);
}
}
+3 -3
View File
@@ -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 User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type 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 Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
+5 -5
View File
@@ -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) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
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) }),
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) }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -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) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "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) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -233,9 +233,9 @@ export const api = {
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
},
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
+216 -182
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -32,10 +32,10 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
@@ -58,8 +58,12 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9
const defaultUserStorageQuotaMb = 100
const defaultAdminStorageQuotaMb = 1024
const superAdminPermissionGroupId = "pg_super_admin"
const regularUserPermissionGroupId = "pg_regular_user"
const defaultStorageQuotaMb = (role: AdminUser["role"]) => role === "admin" ? defaultAdminStorageQuotaMb : defaultUserStorageQuotaMb
const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
function compareAdminUsers(left: AdminUser, right: AdminUser) {
if (left.role === "admin" && right.role !== "admin") return -1
if (left.role !== "admin" && right.role === "admin") return 1
@@ -86,7 +90,7 @@ export function AdminPage() {
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview })
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
@@ -97,7 +101,7 @@ export function AdminPage() {
const mailboxItems = mailboxes.data?.items || []
const aliasItems = aliases.data?.items || []
const userItems = users.data?.items || []
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== "pg_super_admin" && group.id !== "pg_regular_user")
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId)
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
const rawSection = params.get("section") as Section | null
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
@@ -129,7 +133,7 @@ export function AdminPage() {
return (
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
@@ -144,7 +148,7 @@ export function AdminPage() {
)}
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} />}
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
{section === "domains" && <DomainsSection domains={domainItems} />}
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
@@ -175,7 +179,6 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
</Button>
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
</div>
</div>
</div>
@@ -252,7 +255,7 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
}
function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) {
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
const me = useMe()
const user = me.data?.user
const qc = useQueryClient()
@@ -279,7 +282,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
<div className="flex flex-wrap gap-2">
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} domains={domains} />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -317,8 +322,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</div>
<div className="mt-3 flex flex-wrap gap-2">
<RoleBadge user={user} />
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
<AccountStatus user={user} />
<span className="text-xs text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
<div className="mt-3"><UserMailboxCell user={user} /></div>
@@ -338,7 +343,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell><AccountStatus user={user} /></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow>
@@ -360,6 +365,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
const { toast } = useToast()
const [query, setQuery] = React.useState("")
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
const [viewing, setViewing] = React.useState<PermissionGroup | null>(null)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const canCreate = hasPermission(user, "admin.permission_groups.create")
const canUpdate = hasPermission(user, "admin.permission_groups.update")
@@ -378,14 +384,16 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
if (!keyword) return true
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
})
const isEditable = (group: PermissionGroup) => !group.system
const isEditable = (group: PermissionGroup) => !group.system || group.id === regularUserPermissionGroupId
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
{canCreate && <PermissionGroupDialog catalog={catalog} />}
<div className="flex flex-wrap gap-2">
{canCreate && <PermissionGroupDialog catalog={catalog} />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -400,26 +408,21 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{group.name}</div>
{group.system && <Badge variant="outline"></Badge>}
{!group.system && <Badge variant="secondary"></Badge>}
<Badge variant="outline">{group.userCount} </Badge>
<span className="text-xs text-muted-foreground">{group.system ? "系统配置" : "自定义配置"} · {group.userCount} </span>
</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
</div>
{(canUpdate || canDelete) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
disabled={!isDeletable(group) || !canDelete}
onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>}
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => setViewing(group)}></Button>
{((isEditable(group) && canUpdate) || (isDeletable(group) && canDelete)) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isEditable(group) && canUpdate && <DropdownMenuItem onSelect={() => setEditing(group)}>{group.id === regularUserPermissionGroupId ? "编辑普通用户权限" : "编辑权限配置"}</DropdownMenuItem>}
{isEditable(group) && canUpdate && isDeletable(group) && canDelete && <DropdownMenuSeparator />}
{isDeletable(group) && canDelete && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>}
</div>
</div>
<PermissionBadges permissions={group.permissions} catalog={catalog} />
<PermissionLimitBadges limits={group.limits} />
@@ -429,6 +432,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{filtered.length === 0 && <Empty text="暂无匹配的权限配置" />}
</CardContent>
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
{viewing && <PermissionGroupDetailsDialog group={viewing} catalog={catalog} open={!!viewing} onOpenChange={(open) => { if (!open) setViewing(null) }} />}
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card>
)
@@ -440,6 +444,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
const [internalOpen, setInternalOpen] = React.useState(false)
const dialogOpen = open ?? internalOpen
const setDialogOpen = onOpenChange ?? setInternalOpen
const fixedIdentity = group?.id === regularUserPermissionGroupId
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
@@ -469,18 +474,18 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
})
const trigger = group ? null : (
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4" /></Button>
<Button size="sm" className="self-start"></Button>
</DialogTrigger>
)
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger}
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group ? "编辑权限配置" : "创建权限配置"}</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{fixedIdentity ? "编辑普通用户权限" : group ? "编辑权限配置" : "创建权限配置"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2">
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" readOnly={fixedIdentity} />
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} readOnly={fixedIdentity} />
</div>
<PermissionLimitEditor value={limits} onChange={setLimits} />
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
@@ -491,7 +496,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
)
}
function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) {
function PermissionGroupDetailsDialog({ group, catalog, open, onOpenChange }: { group: PermissionGroup; catalog: PermissionInfo[]; open: boolean; onOpenChange: (open: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group.name}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground"> {group.permissions.length} {group.id === superAdminPermissionGroupId ? "管理员权限由账号身份决定,不能修改。" : ""}</p>
<PermissionLimitBadges limits={group.limits} />
<PermissionPicker catalog={catalog} value={group.permissions} onChange={() => undefined} readOnly />
</DialogContent>
</Dialog>
)
}
function PermissionPicker({ catalog, value, onChange, readOnly = false }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void; readOnly?: boolean }) {
const grouped = groupPermissionCatalog(catalog)
function toggle(permission: PermissionKey, checked: boolean) {
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
@@ -504,24 +522,25 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label></Label>
<Badge variant="outline">{value.length} </Badge>
<span className="text-xs tabular-nums text-muted-foreground"> {value.length} </span>
</div>
<div className="space-y-3">
{grouped.map(({ category, items }) => {
const allChecked = items.every((item) => value.includes(item.key))
const selectedCount = items.filter((item) => value.includes(item.key)).length
return (
<div key={category} className="rounded-lg border">
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
<label className="flex items-center gap-2 font-medium">
<Checkbox checked={allChecked} onCheckedChange={(next) => toggleCategory(items, next === true)} />
<label className={cn("flex items-center gap-2 font-medium", readOnly && "cursor-default")}>
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} checked={allChecked ? true : selectedCount > 0 ? "indeterminate" : false} onCheckedChange={(next) => toggleCategory(items, next === true)} />
{category}
</label>
<span className="text-xs text-muted-foreground">{items.filter((item) => value.includes(item.key)).length}/{items.length}</span>
<span className="text-xs text-muted-foreground">{selectedCount}/{items.length}</span>
</div>
<div className="grid gap-2 p-3 md:grid-cols-2">
{items.map((item) => (
<label key={item.key} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
<Checkbox checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
<label key={item.key} className={cn("flex min-h-16 items-start gap-3 rounded-md border px-3 py-2", readOnly && "cursor-default")}>
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
<span className="min-w-0">
<span className="block text-sm font-medium">{item.label}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
@@ -545,7 +564,7 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<Label></Label>
<Label>使</Label>
<span className="text-xs text-muted-foreground"> 0 </span>
</div>
<div className="grid gap-3 md:grid-cols-3">
@@ -581,29 +600,14 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground"></div>
return (
<div className="mt-3 flex flex-wrap gap-1.5">
{permissions.slice(0, 10).map((permission) => (
<Badge key={permission} variant="outline" className="font-normal">{labelByKey.get(permission) || permission}</Badge>
))}
{permissions.length > 10 && <Badge variant="secondary">+{permissions.length - 10}</Badge>}
</div>
)
const visible = permissions.slice(0, 5).map((permission) => labelByKey.get(permission) || permission)
return <div className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">{visible.join("、")}{permissions.length > visible.length ? `${permissions.length}` : ""}</div>
}
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
return (
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="font-normal"> {limitText(value.maxAttachmentMb, "MB")}</Badge>
<Badge variant="secondary" className="font-normal"> {limitText(value.maxMailboxCount, "个")}</Badge>
<Badge variant="secondary" className="font-normal">SMTP {limitText(value.smtpDailyLimit, "封")}</Badge>
<Badge variant="secondary" className="font-normal">SMTP {limitText(value.smtpMinuteLimit, "封")}</Badge>
<Badge variant="secondary" className="font-normal">IMAP {limitText(value.imapMinuteLimit, "次")}</Badge>
<Badge variant="secondary" className="font-normal">POP3 {limitText(value.pop3MinuteLimit, "次")}</Badge>
</div>
)
return <div className="mt-2 text-xs leading-5 text-muted-foreground"> {limitText(value.maxMailboxCount, "个")} · {limitText(value.maxAttachmentMb, "MB")} · SMTP {limitText(value.smtpDailyLimit, "封/日")} · IMAP {limitText(value.imapMinuteLimit, "次/分钟")} · POP3 {limitText(value.pop3MinuteLimit, "次/分钟")}</div>
}
function limitText(value: number, unit: string) {
@@ -651,8 +655,8 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={domain.status === "active" ? "default" : "secondary"}>{domain.status === "active" ? "启用" : "停用"}</Badge>
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} />
{canViewDNS && <DomainDNSDialog domain={domain} />}
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" /></Button>}
@@ -685,56 +689,103 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
const user = me.data?.user
const qc = useQueryClient()
const { toast } = useToast()
const [query, setQuery] = React.useState("")
const [expandedOwners, setExpandedOwners] = React.useState<string[]>([])
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const canCreate = hasPermission(user, "admin.mailboxes.create")
const canUpdate = hasPermission(user, "admin.mailboxes.update")
const canDelete = hasPermission(user, "admin.mailboxes.delete")
const update = useMutation({
mutationFn: ({ mailbox, status }: { mailbox: MailboxType; status: "active" | "disabled" }) => api.updateMailbox(mailbox.id, { userId: mailbox.userId, displayName: mailbox.displayName || mailbox.address, quotaMb: mailbox.quotaMb, status }),
onSuccess: (mailbox) => { invalidateAdmin(qc); toast({ title: mailbox.status === "active" ? "邮箱已启用" : "邮箱已停用" }) },
onError: (e) => toast({ title: "状态更新失败", description: e.message }),
})
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
const keyword = query.trim().toLowerCase()
const knownOwnerIDs = new Set(users.map((item) => item.id))
const compareMailboxes = (left: MailboxType, right: MailboxType) => {
if (left.primary !== right.primary) return left.primary ? -1 : 1
return left.address.localeCompare(right.address, "en", { sensitivity: "base" })
}
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort(compareMailboxes) })),
...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })),
]
.filter((group) => group.mailboxes.length > 0)
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
<div className="flex items-center gap-2">
<span className="text-sm tabular-nums text-muted-foreground">{mailboxes.length} </span>
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 md:hidden">
{mailboxes.map((mailbox) => (
<div key={mailbox.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{mailbox.address}</div>
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
</div>
<MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
<Badge variant="outline">{mailbox.quotaMb} MB</Badge>
<Badge variant="outline">{mailbox.displayName || "未命名"}</Badge>
</div>
</div>
))}
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
</div>
<div className="hidden md:block">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody>
{mailboxes.map((mailbox) => (
<TableRow key={mailbox.id}>
<TableCell className="font-medium">{mailbox.address}</TableCell>
<TableCell className="text-muted-foreground">{mailbox.userEmail || mailbox.userId}</TableCell>
<TableCell>{mailbox.displayName}</TableCell>
<TableCell>{mailbox.quotaMb} MB</TableCell>
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
<TableCell><MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="divide-y overflow-hidden rounded-md border">
<div className="hidden grid-cols-[1rem_minmax(0,1fr)_9rem_8rem] gap-3 bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground sm:grid">
<span aria-hidden="true" />
<span></span>
<span className="text-center"></span>
<span className="text-center"></span>
</div>
{mailboxGroups.map((group) => {
const ownerID = group.owner?.id || group.mailboxes[0].userId
const expanded = expandedOwners.includes(ownerID) || !!keyword
return (
<div key={ownerID}>
<Button type="button" variant="ghost" className="grid h-auto min-h-16 w-full grid-cols-[auto_minmax(0,1fr)_auto] gap-3 rounded-none px-4 py-3 text-left font-normal sm:grid-cols-[auto_minmax(0,1fr)_9rem_8rem]" onClick={() => toggleOwner(ownerID)} aria-expanded={expanded}>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", !expanded && "-rotate-90")} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{group.owner ? accountPrimaryEmail(group.owner) : group.mailboxes[0].userEmail || "未知账号"}</div>
<div className="truncate text-xs text-muted-foreground">{group.owner?.displayName || "账号信息不可用"}</div>
</div>
<span className="hidden items-center justify-center sm:flex">{group.owner ? <RoleBadge user={group.owner} /> : "-"}</span>
<span className="flex items-center justify-center gap-2 text-sm tabular-nums text-muted-foreground"><span>{group.mailboxes.length} </span><span className="hidden text-xs lg:inline"></span></span>
</Button>
{expanded && (
<div className="border-t bg-muted/20 px-4 py-2 sm:pl-11">
{group.mailboxes.map((mailbox) => (
<div key={mailbox.id} className="flex min-h-14 items-center gap-3 border-b py-2 last:border-b-0">
<Mail className="hidden h-4 w-4 shrink-0 text-muted-foreground sm:block" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{mailbox.address}</div>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{mailbox.displayName || "未命名"}</span>
{mailbox.primary && <span className="shrink-0"></span>}
</div>
</div>
<span className={cn("w-10 shrink-0 text-right text-xs font-medium", mailbox.status === "active" ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>{mailbox.status === "active" ? "启用" : "停用"}</span>
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{!mailbox.primary && (canUpdate || canDelete) && (
<DropdownMenu>
<DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "active" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "active" })}></DropdownMenuItem>}
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "disabled" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "disabled" })}></DropdownMenuItem>}
{canUpdate && canDelete && !mailbox.primary && <DropdownMenuSeparator />}
{canDelete && !mailbox.primary && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。此操作无法撤销。`, confirmText: "确认删除", onConfirm: () => remove.mutate(mailbox.id) })}></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
{mailboxes.length === 0 && <Empty text="暂无邮箱" />}
{mailboxGroups.length === 0 && <Empty text={query ? "没有匹配的账号或邮箱" : "暂无邮箱"} />}
</CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card>
@@ -772,7 +823,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
<StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" />
<Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
</div>
</div>
@@ -787,7 +838,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<TableCell className="font-medium">{alias.source}</TableCell>
<TableCell>{alias.destination}</TableCell>
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
<TableCell><StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" /></TableCell>
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
</TableRow>
))}
@@ -1855,7 +1906,8 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
}, [normalizedQuery, sortedMailboxes])
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
const quota = <div className="text-[11px] text-muted-foreground"> {user.mailboxCount}/{limit}</div>
const storage = formatBytes((user.storageQuotaMb || defaultStorageQuotaMb(user.role)) * 1024 * 1024)
const quota = <div className="text-[11px] text-muted-foreground"> {user.mailboxCount}/{limit} · {storage}</div>
async function copyMailbox(address: string) {
if (!address) return
await navigator.clipboard.writeText(address)
@@ -1872,7 +1924,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
title={selectedAddress}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} </span>}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -1898,7 +1949,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
onSelect={() => setSelectedAddress(mailbox)}
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
</DropdownMenuItem>
))}
@@ -1916,17 +1966,10 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
}
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
const groups = user.permissionGroups || []
if (groups.length === 0) return <span className="text-muted-foreground"></span>
return (
<div className="flex max-w-md flex-wrap gap-1">
{groups.map((group) => (
<Badge key={group.id} variant={group.id === "pg_super_admin" ? "default" : "secondary"} className="font-normal">
{group.name}
</Badge>
))}
</div>
)
const groups = (user.permissionGroups || []).filter((group) => group.id !== "pg_regular_user")
if (user.role === "admin") return <span className="text-sm text-muted-foreground"></span>
if (groups.length === 0) return <span className="text-muted-foreground"></span>
return <span className="line-clamp-2 text-sm text-muted-foreground">{groups.map((group) => group.name).join("、")}</span>
}
function assignableUserGroupIDs(user: AdminUser) {
@@ -1934,6 +1977,7 @@ function assignableUserGroupIDs(user: AdminUser) {
}
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
if (groups.length === 0) return null
function toggle(groupID: string, checked: boolean) {
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
}
@@ -1954,18 +1998,20 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
)
})}
</div>
{groups.length === 0 && <Empty text="暂无可分配自定义权限配置" />}
</div>
)
}
function RoleBadge({ user }: { user: AdminUser }) {
return (
<div className="flex flex-wrap gap-1">
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
{user.protected && <Badge variant="outline"></Badge>}
</div>
)
return <span className="text-sm font-medium">{user.role === "admin" ? "管理员" : "普通用户"}</span>
}
function AccountStatus({ user }: { user: AdminUser }) {
return <StatusText active={!user.disabled} activeLabel="正常" inactiveLabel="停用" />
}
function StatusText({ active, activeLabel, inactiveLabel }: { active: boolean; activeLabel: string; inactiveLabel: string }) {
return <span className="inline-flex items-center gap-2 whitespace-nowrap text-sm"><span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-600" : "bg-muted-foreground")} />{active ? activeLabel : inactiveLabel}</span>
}
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
@@ -1996,50 +2042,58 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
}
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
function CreateUserDialog({ permissionGroups, domains }: { permissionGroups: PermissionGroup[]; domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [status, setStatus] = React.useState("active")
const [domainId, setDomainId] = React.useState("")
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domainId, domains])
const create = useMutation({
mutationFn: (form: FormData) => api.createUser({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""),
role: "user",
disabled: status === "disabled",
mailboxLimitOverride: mailboxLimitFromForm(form),
}),
mutationFn: (form: FormData) => {
const domain = domains.find((item) => item.id === domainId)
if (!domain) throw new Error("请选择邮箱域名")
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
const email = `${localPart}@${domain.name}`
const inputPassword = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
if (inputPassword !== confirmPassword) throw new Error("两次输入的密码不一致")
const password = inputPassword || email
if (password.length < 6) throw new Error("密码至少需要 6 位")
return api.createUser({ email, displayName: String(form.get("displayName") || ""), password, role: "user", disabled: false, mailboxLimitOverride: mailboxLimitFromForm(form), storageQuotaMb: Number(form.get("storageQuotaMb") || defaultUserStorageQuotaMb), permissionGroupIds })
},
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" /></Button></DialogTrigger>
<DialogTrigger asChild><Button size="sm"></Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="email" label="主登录邮箱" type="email" autoComplete="off" placeholder="user@example.com" />
<Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={6} />
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
<Field name="localPart" label="邮箱前缀" placeholder="user" />
<Field name="displayName" label="显示名称" placeholder="例如:SZX" />
</div>
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field name="password" label="密码(留空则使用邮箱账号)" type="password" required={false} autoComplete="new-password" />
<Field name="confirmPassword" label="再次输入密码" type="password" required={false} autoComplete="new-password" />
</div>
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
<Field name="storageQuotaMb" label="共享存储容量(MB" type="number" min={100} defaultValue={String(defaultUserStorageQuotaMb)} />
</div>
<PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />
<DialogFooter><Button disabled={create.isPending || !domainId}>{create.isPending ? "创建中..." : "创建用户账号"}</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
const [open, setOpen] = React.useState(false)
if (!canUpdate && !onDelete) return null
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}></DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
}
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
if (!onToggle && !onDelete) return null
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
@@ -2063,6 +2117,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
role,
disabled: disabled === "disabled",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
storageQuotaMb: Number(form.get("storageQuotaMb") || user.storageQuotaMb || defaultStorageQuotaMb(user.role)),
permissionGroupIds: role === "user" ? permissionGroupIds : [],
}),
onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "账号已更新" }) },
@@ -2079,7 +2134,10 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
</div>
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
<Field name="storageQuotaMb" label="共享存储容量(MB" type="number" min={100} defaultValue={String(user.storageQuotaMb || defaultStorageQuotaMb(user.role))} />
</div>
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
</form>
@@ -2093,17 +2151,10 @@ function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; op
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="主登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
}
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status)
React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open])
const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "邮箱已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
}
function CreateDomainDialog() {
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false)
const mut = useMutation({ mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "域名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="name" label="域名" placeholder="example.com" /><DialogFooter><Button disabled={mut.isPending}></Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start"></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="name" label="域名" placeholder="example.com" /><DialogFooter><Button disabled={mut.isPending}></Button></DialogFooter></form></DialogContent></Dialog>
}
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
@@ -2111,7 +2162,6 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
const [ownerMode, setOwnerMode] = React.useState("new")
const [userId, setUserId] = React.useState("")
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
@@ -2121,38 +2171,22 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
mutationFn: (form: FormData) => api.createMailbox({
domainId,
localPart: String(form.get("localPart")),
displayName: String(form.get("displayName")),
password: String(form.get("password")),
quotaMb: Number(form.get("quotaMb") || 1024),
role: "user",
ownerEmail: String(form.get("ownerEmail") || ""),
userId: ownerMode === "existing" ? userId : "",
userId,
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger>
<DialogTrigger asChild><Button></Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
<Field name="displayName" label="显示名" placeholder="Alice" />
</div>
<SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[["new", "新建/按主登录邮箱匹配账号"], ["existing", "追加到已有账号"]]} />
{ownerMode === "existing" ? (
<SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} />
) : (
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
)}
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
<SelectField label="绑定用户账号" value={userId} onValueChange={setUserId} items={users.filter((item) => !item.disabled).sort(compareAdminUsers).map((item) => [item.id, accountPrimaryEmail(item)])} />
<p className="text-xs text-muted-foreground">使</p>
<DialogFooter><Button disabled={mut.isPending || !domainId || !userId}>{mut.isPending ? "创建中..." : "创建邮箱"}</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
@@ -2163,7 +2197,7 @@ function CreateAliasDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "转发已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start"></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
}
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
+217 -63
View File
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
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 { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -49,6 +49,65 @@ import { useToast } from "@/hooks/use-toast"
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" /> }
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> = {
Inbox: "收件箱",
Sent: "已发送",
@@ -143,6 +202,8 @@ export function MailPage() {
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
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 [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [language, setLanguage] = useLanguage()
@@ -425,7 +486,16 @@ export function MailPage() {
},
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 cancelScheduledSend = useMutation({
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
@@ -438,7 +508,7 @@ export function MailPage() {
onSettled: () => setCancelingScheduledId(""),
})
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) => {
qc.invalidateQueries({ queryKey: ["folders"] })
setFolderDialogOpen(false)
@@ -753,10 +823,19 @@ export function MailPage() {
await Promise.all(ids.map((id) => api.star(id, starred)))
} else if (action === "delete") {
await Promise.all(ids.map((id) => api.delete(id)))
completionTitle = `已永久删除 ${ids.length} 封邮件`
} else {
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
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
}
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
@@ -788,11 +867,14 @@ export function MailPage() {
}
}
function confirmDeleteMessage(message: MailMessage) {
const permanent = message.folder === "Trash"
setPendingConfirm({
title: "删除这封邮件?",
description: `邮件“${message.subject || "无主题"}”将被删除。`,
confirmText: "删除邮件",
onConfirm: () => del.mutate(message.id),
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
description: permanent
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。`
: `邮件“${message.subject || "无主题"}”将移入已删除。`,
confirmText: permanent ? "永久删除" : "移入已删除",
onConfirm: () => del.mutate({ id: message.id, permanent }),
})
}
function openCompose(draft?: ComposeDraft) {
@@ -902,6 +984,14 @@ export function MailPage() {
function closeSidebarContextMenu() {
setSidebarContextMenu(null)
}
function runAfterClosingMobileSidebar(action: () => void) {
if (!isMobile) {
action()
return
}
if (mobileSidebarOpen) setMobileSidebarOpen(false)
window.setTimeout(action, 250)
}
function activateSidebarItem(item: MailMenuItem) {
if (item.type === "starred") openStarred()
else if (item.type === "scheduled") openScheduled()
@@ -1371,15 +1461,18 @@ export function MailPage() {
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
{!sidebarCollapsed && (
<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 && (
<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" />
</Button>
)}
</div>
)}
<SidebarGroupContent>
{foldersExpanded && <SidebarGroupContent id="mail-sidebar-folders">
<SidebarMenu>
{customMailMenuItems.map((item) => (
<SidebarMenuItem
@@ -1425,12 +1518,15 @@ export function MailPage() {
/>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroupContent>}
</SidebarGroup>}
{(canReadMail || canManageLabels) && <SidebarGroup>
{!sidebarCollapsed && (
<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 && (
<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) }}>
@@ -1445,7 +1541,7 @@ export function MailPage() {
)}
</div>
)}
<SidebarGroupContent>
{labelsExpanded && <SidebarGroupContent id="mail-sidebar-labels">
<SidebarMenu>
{canReadMail && labelItems.map((label) => {
const dotColor = labelDotColor(label)
@@ -1491,9 +1587,37 @@ export function MailPage() {
</SidebarMenu>
{labels.isLoading && <FolderSkeleton />}
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
</SidebarGroupContent>
</SidebarGroupContent>}
</SidebarGroup>}
</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>
)
@@ -1566,7 +1690,9 @@ export function MailPage() {
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
) : compactMailLayout ? (
<CompactMailView
key={`${mailView}:${mailView === "folder" ? folder : mailView === "label" ? selectedLabelId : selectedExternalAccountId}`}
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}
messages={visibleMessages}
total={allMessages.length}
@@ -1675,12 +1801,12 @@ export function MailPage() {
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
<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>
<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>
)}
<ScrollArea className="min-h-0 flex-1">
{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 && hasMoreMessages && (
<div className="border-b p-4 text-center">
@@ -1724,7 +1850,7 @@ export function MailPage() {
) : (
<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>
<MessageMetaPanel
@@ -1812,39 +1938,11 @@ export function MailPage() {
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
open={folderDialogOpen}
pending={createFolder.isPending}
onOpenChange={setFolderDialogOpen}
onCreate={(name) => createFolder.mutate(name)}
onCreate={(payload) => createFolder.mutate(payload)}
/>
<ConfirmDialog
open={!!pendingConfirm}
@@ -1852,7 +1950,7 @@ export function MailPage() {
description={pendingConfirm?.description}
confirmText={pendingConfirm?.confirmText || "确认"}
destructive
pending={del.isPending || bulkPending}
pending={del.isPending || bulkPending || deleteFolder.isPending}
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
onConfirm={() => pendingConfirm?.onConfirm()}
/>
@@ -1862,7 +1960,7 @@ export function MailPage() {
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 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) {
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
}
@@ -1872,7 +1970,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
folderId: item.id,
folderName: 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,
custom: isCustomMailFolder(item),
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
@@ -1892,6 +1990,13 @@ function isCustomMailFolder(folder: Pick<MailFolder, "name" | "id">) {
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) {
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
}
@@ -2492,6 +2597,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 }) {
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 archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
@@ -2513,14 +2619,14 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
<DropdownMenuContent align="end" className="w-44">
{movableFolders.map((folder) => (
<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>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</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" />
</Button>
</div>
@@ -2567,7 +2673,7 @@ function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending,
</Button>
{canCreate && (
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</Button>
)}
{customFolder && (canReorder || canDelete) && (
@@ -2673,7 +2779,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
<div className="max-h-44 overflow-y-auto">
{movableFolders.map((folder) => (
<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>
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
</Button>
@@ -2702,7 +2808,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
{canOrganize && (
<>
<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>
@@ -2718,12 +2824,21 @@ function contextMenuPosition(x: number, y: number) {
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 [icon, setIcon] = React.useState("auto")
const [uploadError, setUploadError] = React.useState("")
const iconInputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (open) setName("")
if (open) {
setName("")
setIcon("auto")
setUploadError("")
}
}, [open])
const trimmed = name.trim()
const suggestedIcon = suggestedFolderIcon(trimmed)
const resolvedIcon = icon === "auto" ? suggestedIcon : icon
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
@@ -2734,13 +2849,51 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (trimmed) onCreate(trimmed)
if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon })
}}
>
<div className="space-y-2">
<Label htmlFor="new-folder-name"></Label>
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
</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">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
@@ -2753,6 +2906,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
function CompactMailView({
title,
currentFolder,
icon,
messages,
total,
@@ -2796,6 +2950,7 @@ function CompactMailView({
tools,
}: {
title: string
currentFolder?: string
icon?: React.ReactNode
messages: MailMessage[]
total: number
@@ -2887,7 +3042,7 @@ function CompactMailView({
{selectedIds.length > 0 ? (
<>
<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">
@@ -2998,7 +3153,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={() => 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>
</DropdownMenu>
)}
@@ -3018,7 +3173,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={() => 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 className="flex items-center gap-2">
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}></Button>
@@ -3265,7 +3420,6 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange
}
return (
<SidebarMenuButton className="text-muted-foreground" onClick={() => setEditingState(true)}>
<Plus className="h-4 w-4" />
<span></span>
</SidebarMenuButton>
)
@@ -3654,7 +3808,7 @@ function MessageRow({
<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" />
</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" />
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
+105 -77
View File
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
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 { 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 { applyTheme, getInitialTheme } from "@/lib/theme"
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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
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 { ScrollArea } from "@/components/ui/scroll-area"
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)}>
<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">
<h2 className="text-[15px] font-semibold leading-6 text-foreground">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>}
<h2 className="break-words text-[15px] font-semibold leading-6 text-foreground [overflow-wrap:anywhere]">{title}</h2>
{subtitle && <p className="mt-0.5 break-words text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">{subtitle}</p>}
</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>
@@ -1626,7 +1626,7 @@ function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }:
return (
<Dialog open={open} onOpenChange={setOpen}>
<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>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
@@ -2221,16 +2221,13 @@ function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mai
})
}
return (
<div className="space-y-6">
<div className="space-y-4">
{showStats && <StatsSummary stats={stats} />}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<SettingsCard title="清理当前邮箱" subtitle={mailbox ? `当前邮箱:${mailbox.address}` : "请先选择邮箱"} contentClassName="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<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={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
</CardContent>
</Card>
</SettingsCard>
<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>
)
@@ -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 }) {
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) {
if (!next) setEditingRule(null)
@@ -2269,12 +2269,18 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
return (
<div className="space-y-4">
<div className="flex justify-stretch sm:justify-end">
<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="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<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 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 && filteredItems.length === 0 && <EmptyState icon={<Search />} text="没有找到规则" description={`没有名称包含“${query.trim()}”的规则。`} className="min-h-[180px] border-solid bg-card" />}
</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)} />
</div>
@@ -2292,7 +2298,10 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
const [stopProcessing, setStopProcessing] = React.useState(false)
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
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 availableFolders = folderQuery.data?.items || []
React.useEffect(() => {
if (!open) return
@@ -2336,37 +2345,39 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
return (
<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)]">
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
<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="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>
</DialogHeader>
<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">
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
<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 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>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span></span>
<div className="space-y-2 text-sm">
<div></div>
<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>
</Select>
</div>
<div className="space-y-3">
{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 })}>
<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>
</Select>
<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>
</Select>
<Input 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>
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
<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)} />
<div className="col-span-2 flex justify-end gap-2 md:contents">
<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>
@@ -2376,14 +2387,16 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<div className="text-sm"></div>
<div className="space-y-3">
{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: "" })}>
<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>
</Select>
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
<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>
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
<div className="min-w-0"><RuleActionValue action={action} folders={availableFolders} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /></div>
<div className="flex justify-end gap-2 md:contents">
<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>
@@ -2394,13 +2407,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<div className="space-y-4">
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} 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="终止规则:命中此规则后不再应用其他规则" />
<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>
<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 disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
</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 (labels.length > 0) {
return (
@@ -2424,20 +2437,26 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
}
if (action.type === "move") {
const value = action.value || "Archive"
const customFolders = ruleCustomFolders(folders, value)
return (
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<Select value={value} onValueChange={(next) => onChange({ value: next })}>
<SelectTrigger className="h-11 md:h-9"><SelectValue placeholder="选择文件夹" /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="text-xs font-medium text-muted-foreground"></SelectLabel>
<SelectItem value="Inbox"></SelectItem>
<SelectItem value="Archive"></SelectItem>
<SelectItem value="Spam"></SelectItem>
<SelectItem value="Trash"></SelectItem>
<SelectItem value="__custom"></SelectItem>
</SelectContent>
</Select>
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
</div>
<SelectItem value="Trash"></SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="text-xs font-medium text-muted-foreground"></SelectLabel>
{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") {
@@ -2446,6 +2465,14 @@ function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action:
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 }) {
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
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 }) {
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 conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join("") || "无动作"
const moveDirection = index === 0 ? "down" : "up"
const canMove = count > 1
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="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-2 leading-6">
<h3 className="truncate text-base font-semibold text-foreground">{item.name}</h3>
<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-2 sm:space-y-1">
<div className="flex min-w-0 items-start gap-2 leading-6 sm:items-center">
<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>
</div>
<div className="grid text-sm leading-6 text-muted-foreground">
<p className="truncate"><span className="text-muted-foreground"></span> {conditionText}</p>
<p className="truncate"><span className="text-muted-foreground"></span> {actionText}</p>
<div className="grid gap-1 text-sm leading-5 text-muted-foreground sm:gap-0 sm:leading-6">
<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="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 className="flex shrink-0 items-center gap-1">
<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>
<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-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" />
<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="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-7 text-muted-foreground" 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 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-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-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-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>
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
</div>
@@ -2556,17 +2583,18 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
setDialogOpen(false)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs font-medium text-muted-foreground"> {spamCount} · {items.length} </div>
<Button type="button" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}><Plus className="h-4 w-4" /></Button>
</div>
<SettingsCard title="被拦截邮件" subtitle="发件人命中拦截规则后会进入垃圾邮件,规则可随时移除。" contentClassName="space-y-2">
<div>
<SettingsCard
title="拦截规则"
subtitle={`${items.length} 条发件人规则 · 垃圾邮件 ${spamCount}`}
action={<Button type="button" onClick={() => setDialogOpen(true)}></Button>}
contentClassName="space-y-2"
>
{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="truncate text-sm font-semibold 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="break-all text-sm font-semibold leading-5 text-foreground">{item.email}</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>
<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>
@@ -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" },
]
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) => (
<Card key={card.label} className="shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<CardContent className="flex items-center gap-3 p-4">
<Card key={card.label} className="min-w-0 overflow-hidden shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<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="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>
</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 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 }) {