From 86773c64ca5509b66f8a6284118d1a325c22bcce Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:55:36 +0800 Subject: [PATCH] feat: unify email identity and administrator security --- .github/release-notes/v1.2.14.md | 35 + README.md | 8 +- README.zh-CN.md | 6 +- VERSION | 2 +- apps/api/internal/app/admin_handlers.go | 81 ++- apps/api/internal/app/app.go | 352 +++++++--- apps/api/internal/app/app_test.go | 625 ++++++++++++++++-- apps/api/internal/app/auth_handlers.go | 113 ++-- apps/api/internal/app/config.go | 4 +- apps/api/internal/app/mail_handlers.go | 68 ++ apps/api/internal/app/open_api_handlers.go | 2 +- .../internal/app/permission_group_handlers.go | 4 +- apps/api/internal/app/permissions.go | 23 +- apps/api/internal/app/router_auth.go | 6 +- apps/api/internal/app/turnstile.go | 6 +- apps/api/internal/app/two_factor.go | 105 ++- apps/api/internal/app/util.go | 15 + apps/web/src/components/protected-layout.tsx | 4 +- apps/web/src/index.css | 3 +- apps/web/src/lib/api-types.ts | 2 + apps/web/src/lib/api.ts | 11 +- apps/web/src/pages/admin.tsx | 161 +++-- apps/web/src/pages/login.tsx | 10 +- apps/web/src/pages/mail.tsx | 20 +- apps/web/src/pages/profile.tsx | 50 +- apps/web/src/pages/register.tsx | 40 +- deploy/.env.example | 10 +- deploy/README.md | 7 +- docs/GUIDE.md | 11 +- docs/ISSUE_LEDGER.md | 176 +++++ install.sh | 183 ++++- tests/install_test.sh | 75 ++- 32 files changed, 1816 insertions(+), 402 deletions(-) create mode 100644 .github/release-notes/v1.2.14.md create mode 100644 docs/ISSUE_LEDGER.md diff --git a/.github/release-notes/v1.2.14.md b/.github/release-notes/v1.2.14.md new file mode 100644 index 0000000..6cb6085 --- /dev/null +++ b/.github/release-notes/v1.2.14.md @@ -0,0 +1,35 @@ +## 本次更新 + +### 统一邮箱登录与注册 + +- 前台和后台统一使用主登录邮箱与密码登录,不再接受旧用户名登录。 +- 自助注册会一次性创建账号和邮箱,并要求填写显示名称、邮箱前缀、域名和密码确认。 +- 只有主登录邮箱可以登录网页,账号下的其他邮箱不会成为额外登录入口。 +- 网页修改或管理员重置密码时,会同步更新该账号全部邮箱的 SMTP/IMAP 密码。 + +### 加强管理员与安全管理 + +- 系统只保留一名安装时创建的管理员,后台和 API 均不能新增或提升第二名管理员。 +- 内置“管理员”和“普通用户”权限配置不可修改或删除,自定义权限配置仍可正常创建和分配。 +- 双因素认证新增恢复码,服务器菜单支持重置管理员密码和管理员 2FA。 +- 保留 Cloudflare Turnstile 登录与注册防刷验证。 + +### 修复邮件批量移动与界面布局 + +- “全部邮箱”模式可以将跨邮箱选中的邮件批量移动到各邮箱对应的自定义文件夹。 +- 固定邮箱选择器、复制按钮和展开菜单宽度,切换具体邮箱时不再缩窄。 +- 邮箱页与设置页统一侧栏宽度,点击设置时分隔线和内容区域不再左右跳动。 + +### 改进安装与升级兼容 + +- 新安装默认创建 `admin@邮箱域名` 管理员邮箱,并支持自定义管理员邮箱前缀。 +- 旧数据库升级时自动保留现有管理员、邮箱、密码和双因素认证数据,不会生成错误的占位域名账号。 +- 管理员在后台修改主登录邮箱后,服务重启不会恢复成安装时的旧邮箱。 +- 安装指南、环境变量示例和管理员恢复命令已同步更新。 + +### 验证 + +- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查和生产构建。 +- 已通过安装脚本测试、ShellCheck,以及桌面端和移动端浏览器验证。 + +**完整更新日志**:[v1.2.13...v1.2.14](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.13...v1.2.14) diff --git a/README.md b/README.md index afbff00..8f124d1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后 | Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | | 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | | 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | -| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 | +| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 | | 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | | 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | | 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | @@ -35,8 +35,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install. bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) ``` -脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员 -账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 +脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员 +邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 `/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会 校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。 @@ -44,7 +44,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i - 安装或检查 Docker Engine 与 Docker Compose v2 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置 -- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 +- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 创建 `/opt/newszxcn-email` 持久化目录 diff --git a/README.zh-CN.md b/README.zh-CN.md index 1654970..82d5fa2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后 | Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | | 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | | 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | -| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 | +| 管理后台 | 账号、权限配置、域名、邮箱、转发、全部邮件、发送队列、系统设置 | | 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | | 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | | 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | @@ -33,8 +33,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i 脚本会自动完成: - 安装或检查 Docker Engine 与 Docker Compose v2 -- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 -- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 +- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则 +- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 创建 `/opt/newszxcn-email` 持久化目录 diff --git a/VERSION b/VERSION index 0b1f1ed..fd9d1a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.13 +1.2.14 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index d41fc5c..8753a20 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -52,7 +52,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at - ORDER BY u.created_at DESC`) + ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list users") return @@ -108,20 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } actor := currentUser(r) - var loginName string - var err error - if strings.TrimSpace(req.LoginName) != "" { - loginName, err = cleanUsername(req.LoginName) - } else { - loginName, err = cleanLoginName(req.Email) + emailInput := req.Email + if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") { + emailInput = req.LoginName } + primaryEmail, err := cleanPrimaryEmail(emailInput) if err != nil { badRequest(w, err) return } displayName := strings.TrimSpace(req.DisplayName) if displayName == "" { - displayName = loginName + badRequest(w, errors.New("displayName is required")) + return } role := strings.TrimSpace(req.Role) if role == "" { @@ -131,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("invalid role")) return } - if role == "admin" && (actor == nil || actor.Role != "admin") { - respondError(w, http.StatusForbidden, "only administrators can create administrator users") + if role == "admin" { + respondError(w, http.StatusForbidden, "管理员只能由安装流程创建") return } mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride) @@ -161,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { + VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { badRequest(w, err) return } @@ -190,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { current := currentUser(r) var req struct { LoginName string `json:"loginName"` + Email string `json:"email"` DisplayName string `json:"displayName"` Role string `json:"role"` Disabled *bool `json:"disabled"` @@ -218,16 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "user not found") return } - requestedLoginName := strings.TrimSpace(req.LoginName) + if existing.Role == "admin" && role != "admin" { + badRequest(w, errors.New("唯一管理员不能降级")) + return + } + if existing.Role != "admin" && role == "admin" { + respondError(w, http.StatusForbidden, "管理员只能由安装流程创建") + return + } + emailInput := req.Email + if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") { + emailInput = req.LoginName + } + primaryEmail := existing.Email loginName := existing.LoginName - if requestedLoginName != "" { - loginName, err = cleanUsername(requestedLoginName) + if strings.TrimSpace(emailInput) != "" { + primaryEmail, err = cleanPrimaryEmail(emailInput) if err != nil { badRequest(w, err) return } + loginName = primaryEmail } - if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) { + if current == nil || (current.Role != "admin" && existing.Role == "admin") { respondError(w, http.StatusForbidden, "only administrators can modify administrator users") return } @@ -239,6 +252,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("default administrator must remain an active super administrator")) return } + if existing.Role == "admin" && disabled { + badRequest(w, errors.New("唯一管理员不能停用")) + return + } mailboxLimitOverride := existing.MailboxLimitOverride if req.MailboxLimitOverride != nil { mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride) @@ -294,14 +311,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - emailIdentity := existing.Email - if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) { - emailIdentity = loginName - } if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, - loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { - badRequest(w, errors.New("登录名已被使用")) + badRequest(w, errors.New("主登录邮箱已被使用")) return } respondError(w, http.StatusInternalServerError, "failed to update user") @@ -317,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to update user") return } + if existing.Role == "admin" { + a.updateConfig(func(cfg *Config) { + cfg.AdminEmail = primaryEmail + }) + } user, err := a.adminUserByID(r.Context(), id) if err != nil { respondError(w, http.StatusNotFound, "user not found") @@ -575,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { return } if role == "admin" { - current := currentUser(r) - if current == nil || current.Role != "admin" { - respondError(w, http.StatusForbidden, "only administrators can create administrator users") - return - } + respondError(w, http.StatusForbidden, "管理员只能由安装流程创建") + return } domain, err := a.domainByID(r.Context(), req.DomainID) @@ -617,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { return } } else { - ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address) + ownerEmailInput := req.OwnerEmail + if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") { + ownerEmailInput = req.OwnerLoginName + } + ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address)) if err != nil { badRequest(w, err) return } - err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID) + err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID) if errors.Is(err, sql.ErrNoRows) { passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { @@ -631,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { } userID = newID("usr") ownerDisplayName := displayName - if !strings.EqualFold(ownerLoginName, address) { - ownerDisplayName = ownerLoginName + if !strings.EqualFold(ownerEmail, address) { + ownerDisplayName = ownerEmail } _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now) + VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now) if err != nil { badRequest(w, err) return diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index b7206e4..a3fcead 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -33,6 +33,7 @@ type App struct { workerWG sync.WaitGroup maildirHealth *maildirSyncHealthTracker externalIMAP externalIMAPClientFactory + turnstileURL string } func (a *App) config() Config { @@ -92,6 +93,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { db.Close() return nil, err } + if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil { + db.Close() + return nil, err + } workerCtx, cancel := context.WithCancel(context.Background()) a.workerCancel = cancel a.startWorker(func() { a.scheduledSendWorker(workerCtx) }) @@ -185,6 +190,14 @@ func (a *App) migrate(ctx context.Context) error { expires_at TEXT NOT NULL, created_at TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS two_factor_recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(user_id, code_hash) + )`, `CREATE TABLE IF NOT EXISTS api_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -939,81 +952,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error { return err } -// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed() -// that implicitly created an admin mailbox with display_name "LanQin Admin". -// Current seed() creates mailboxes with display_name = admin email, so this migration -// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema. +// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes. +// Administrators now use a real mailbox as their primary login address, so old +// bootstrap mailboxes must be preserved and normalized by the admin identity +// migration instead of deleted. func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error { - adminEmail := normalizeEmail(a.config().AdminEmail) - if adminEmail == "" || !strings.Contains(adminEmail, "@") { - return nil - } - rows, err := a.db.QueryContext(ctx, ` - SELECT mb.id, mb.domain_id - FROM mailboxes mb - JOIN users u ON u.id=mb.user_id - WHERE mb.address=? - AND mb.display_name='LanQin Admin' - AND u.email=? - AND u.role='admin'`, adminEmail, adminEmail) - if err != nil { - return err - } - type legacyMailbox struct { - id string - domainID string - } - items := []legacyMailbox{} - for rows.Next() { - var item legacyMailbox - if err := rows.Scan(&item.id, &item.domainID); err != nil { - rows.Close() - return err - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - for _, item := range items { - messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id) - if err != nil { - return err - } - messageIDs := []string{} - for messageRows.Next() { - var messageID string - if err := messageRows.Scan(&messageID); err != nil { - messageRows.Close() - return err - } - messageIDs = append(messageIDs, messageID) - } - if err := messageRows.Err(); err != nil { - messageRows.Close() - return err - } - if err := messageRows.Close(); err != nil { - return err - } - for _, messageID := range messageIDs { - a.deleteMessage(ctx, messageID) - } - if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil { - return err - } - if _, err := a.db.ExecContext(ctx, ` - DELETE FROM domains - WHERE id=? - AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id) - AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil { - return err - } - } return nil } @@ -1412,7 +1355,11 @@ func (a *App) seed(ctx context.Context) error { return err } if count > 0 { - return a.ensureConfiguredAdminSuperAdmin(ctx) + return a.migrateConfiguredAdministratorIdentity(ctx) + } + adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail) + if err != nil { + return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation") } adminPassword := cfg.AdminPassword @@ -1430,25 +1377,8 @@ func (a *App) seed(ctx context.Context) error { } now := a.now().UTC().Format(time.RFC3339Nano) userID := newID("usr") - if strings.TrimSpace(cfg.AdminUsername) != "" { - adminUsername, err := cleanUsername(cfg.AdminUsername) - if err != nil { - return fmt.Errorf("invalid admin username: %w", err) - } - if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { - return err - } - a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername) - return nil - } - adminEmail := normalizeEmail(cfg.AdminEmail) - if adminEmail == "" || !strings.Contains(adminEmail, "@") { - return errors.New("invalid admin email") - } - adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0]) if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { + VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { return err } a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) @@ -1483,18 +1413,221 @@ func (a *App) seed(ctx context.Context) error { } func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error { + return a.migrateConfiguredAdministratorIdentity(ctx) +} + +func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error { cfg := a.config() - if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") { - _, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`, - a.now().UTC().Format(time.RFC3339Nano), adminUsername) + type adminUser struct { + ID string `json:"id"` + LoginName string `json:"loginName,omitempty"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt string `json:"createdAt"` + } + rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`) + if err != nil { return err } - adminEmail := normalizeEmail(cfg.AdminEmail) - if adminEmail == "" || !strings.Contains(adminEmail, "@") { - return nil + admins := []adminUser{} + for rows.Next() { + var item adminUser + if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err != nil { + rows.Close() + return err + } + admins = append(admins, item) } - _, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`, - a.now().UTC().Format(time.RFC3339Nano), adminEmail) + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(admins) == 0 { + if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" { + row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail) + var item adminUser + if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil { + admins = append(admins, item) + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + } + if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" { + adminUsername := normalizeLoginName(cfg.AdminUsername) + row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername) + var item adminUser + if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil { + admins = append(admins, item) + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + if len(admins) == 0 { + return errors.New("no administrator user found for identity migration") + } + keeper := admins[0] + adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email) + if err != nil { + return err + } + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var conflictID string + if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil { + return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID) + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + now := a.now().UTC().Format(time.RFC3339Nano) + demoted := []adminUser{} + for _, admin := range admins[1:] { + if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil { + return err + } + admin.PasswordHash = "" + demoted = append(demoted, admin) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`, + adminEmail, adminEmail, now, keeper.ID); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return fmt.Errorf("admin identity migration conflict: %w", err) + } + return err + } + parts := strings.SplitN(adminEmail, "@", 2) + localPart := parts[0] + domainName := normalizeDomain(parts[1]) + var domainID string + if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + domainID, err = a.createDomainTx(ctx, tx, domainName) + if err != nil { + return err + } + } + mailboxCreated := false + var mailboxID, mailboxUserID string + if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active") + if err != nil { + return err + } + mailboxCreated = true + } else if mailboxUserID != keeper.ID { + return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID) + } + result := map[string]any{ + "adminUserId": keeper.ID, + "adminEmail": adminEmail, + "emailSource": emailSource, + "previousEmail": keeper.Email, + "demotedAdmins": demoted, + "mailboxId": mailboxID, + "mailboxCreated": mailboxCreated, + "migratedAt": now, + } + raw, _ := json.Marshal(result) + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + a.updateConfig(func(current *Config) { + current.AdminEmail = adminEmail + if current.MailDomain == "" { + current.MailDomain = domainName + } + }) + a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated) + return nil +} + +func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) { + // Once initialized, the database identity is authoritative. This keeps an + // administrator email changed in the UI from reverting to the installer value. + if email, err := cleanPrimaryEmail(existingEmail); err == nil { + return email, "existing_admin_email", nil + } + if strings.TrimSpace(cfg.AdminEmail) != "" { + email, err := cleanPrimaryEmail(cfg.AdminEmail) + if err != nil { + return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err) + } + return email, "configured_admin_email", nil + } + + preferredLocalPart := normalizeLocalPart(cfg.AdminUsername) + if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") { + preferredLocalPart = normalizeLocalPart(loginName) + } + if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") { + preferredLocalPart = "admin" + } + rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart) + if err != nil { + return "", "", err + } + for rows.Next() { + var address string + if err := rows.Scan(&address); err != nil { + rows.Close() + return "", "", err + } + if email, err := cleanPrimaryEmail(address); err == nil { + rows.Close() + return email, "existing_admin_mailbox", nil + } + } + if err := rows.Err(); err != nil { + rows.Close() + return "", "", err + } + if err := rows.Close(); err != nil { + return "", "", err + } + + if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) { + return preferredLocalPart + "@" + domain, "configured_mail_domain", nil + } + var onlyDomain string + var domainCount int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil { + return "", "", err + } + if domainCount == 1 && validMailDomain(onlyDomain) { + return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil + } + publicDomain := normalizeDomain(cfg.PublicHostname) + if strings.HasPrefix(publicDomain, "mail.") { + publicDomain = strings.TrimPrefix(publicDomain, "mail.") + } + if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") { + return preferredLocalPart + "@" + publicDomain, "public_hostname", nil + } + return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating") +} + +func validMailDomain(domain string) bool { + domain = normalizeDomain(domain) + return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :") +} + +func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error { + _, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`) return err } @@ -1616,13 +1749,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error { return err } now := a.now().UTC() + systemDomain := normalizeDomain(cfg.MailDomain) + if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") { + systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1]) + } + if systemDomain == "" { + systemDomain = normalizeDomain(cfg.PublicHostname) + } + if systemDomain == "" { + systemDomain = "lanqin.local" + } + systemAddress := "system@" + systemDomain subject := "欢迎使用 NewSzxcn 邮箱" bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。" bodyHTML := "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

" if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil { rendered := renderMailTemplate(tpl, templateRenderData{ To: cfg.AdminEmail, - From: "system@lanqin.local", + From: systemAddress, PublicHostname: cfg.PublicHostname, PublicBaseURL: cfg.PublicBaseURL, Time: now, @@ -1633,9 +1777,9 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error { MailboxID: mailboxID, FolderID: folderID, MessageUID: newID("uid"), - MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")), + MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain), Subject: subject, - From: "system@lanqin.local", + From: systemAddress, FromName: "NewSzxcn 邮箱", To: []string{cfg.AdminEmail}, SentAt: now, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 3668c83..210e884 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -413,6 +413,20 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per return group } +func setRegularPermissionGroupForTest(t *testing.T, a *App, permissions []string, limits PermissionLimits) PermissionGroup { + t.Helper() + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := a.db.ExecContext(context.Background(), `UPDATE permission_groups SET permissions_json=?, limits_json=?, updated_at=? WHERE id=?`, + encodePermissions(permissions), encodePermissionLimits(limits), now, PermissionGroupRegular); err != nil { + t.Fatalf("set regular permission group fixture: %v", err) + } + group, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular) + if err != nil { + t.Fatalf("load regular permission group fixture: %v", err) + } + return *group +} + func systemSettingsPayload(settings SystemSettings) map[string]any { return map[string]any{ "publicHostname": settings.PublicHostname, @@ -1279,7 +1293,7 @@ func TestPermissionGroupMailLimits(t *testing.T) { 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 body=%v", code, login) } - updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1}) + setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1}) domainID := mustDefaultDomainID(t, a) sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil) @@ -1333,7 +1347,7 @@ func TestPermissionGroupMailLimits(t *testing.T) { } } -func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { +func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) defer ts.Close() @@ -1345,32 +1359,92 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { } a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true }) + domainID := mustDefaultDomainID(t, a) + registration := map[string]string{ + "email": "newuser@lanqin.local", + "displayName": "New User", + "password": "Password123!", + "domainId": domainID, + "localPart": "newuser", + } var registered struct { User User `json:"user"` } - if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" { + if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" { t.Fatalf("register code=%d user=%+v", code, registered.User) } var me struct { User User `json:"user"` } - if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" { + if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@lanqin.local" { t.Fatalf("me code=%d user=%+v", code, me.User) } var mine struct { Items []Mailbox `json:"items"` } - if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 { + if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "newuser@lanqin.local" { t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items) } another := &testClient{t: t, server: ts} - if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK { + if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@lanqin.local", "password": "Password123!"}, &out); code != http.StatusOK { t.Fatalf("login registered user code=%d body=%v", code, out) } } -func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) { +func TestTurnstileRetainedForLoginAndRegistration(t *testing.T) { + a := newTestApp(t) + verifyCalls := 0 + verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + verifyCalls++ + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("secret") != "secret-key" || r.Form.Get("response") == "" { + t.Fatalf("turnstile form secret=%q response=%q", r.Form.Get("secret"), r.Form.Get("response")) + } + respondJSON(w, http.StatusOK, map[string]any{"success": r.Form.Get("response") == "valid-token"}) + })) + defer verifyServer.Close() + a.turnstileURL = verifyServer.URL + a.updateConfig(func(cfg *Config) { + cfg.OpenRegistration = true + cfg.TurnstileEnabled = true + cfg.TurnstileSiteKey = "site-key" + cfg.TurnstileSecretKey = "secret-key" + }) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + client := &testClient{t: t, server: ts} + + var public PublicSettings + if code := client.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.TurnstileEnabled || public.TurnstileSiteKey != "site-key" { + t.Fatalf("public turnstile settings code=%d settings=%+v", code, public) + } + if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized { + t.Fatalf("login without turnstile code=%d", code) + } + var login map[string]any + if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!", "turnstileToken": "valid-token"}, &login); code != http.StatusOK { + t.Fatalf("login with turnstile code=%d body=%v", code, login) + } + domainID := mustDefaultDomainID(t, a) + registerClient := &testClient{t: t, server: ts} + registerPayload := map[string]string{"email": "turnstile-user@lanqin.local", "displayName": "Turnstile User", "password": "Password123!", "domainId": domainID, "localPart": "turnstile-user"} + if code := registerClient.do("POST", "/api/auth/register", registerPayload, nil); code != http.StatusUnauthorized { + t.Fatalf("register without turnstile code=%d", code) + } + registerPayload["turnstileToken"] = "valid-token" + var registered map[string]any + if code := registerClient.do("POST", "/api/auth/register", registerPayload, ®istered); code != http.StatusCreated { + t.Fatalf("register with turnstile code=%d body=%v", code, registered) + } + if verifyCalls != 2 { + t.Fatalf("turnstile verifier calls=%d, want 2", verifyCalls) + } +} + +func TestLegacyBootstrapMailboxMigrationKeepsAdminMailbox(t *testing.T) { dir := t.TempDir() cfg := Config{ Addr: ":0", @@ -1413,15 +1487,15 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 { t.Fatalf("admin user count=%d err=%v", count, err) } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 { - t.Fatalf("legacy mailbox count=%d err=%v", count, err) + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 { + t.Fatalf("admin mailbox count=%d err=%v", count, err) } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 { - t.Fatalf("legacy domain count=%d err=%v", count, err) + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 1 { + t.Fatalf("admin domain count=%d err=%v", count, err) } } -func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { +func TestConfiguredAdminEmailCreatesMailboxAndRejectsUsernameLogin(t *testing.T) { dir := t.TempDir() cfg := Config{ Addr: ":0", @@ -1430,6 +1504,7 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { CookieName: "lanqin_test", SessionTTLHours: 24, AdminUsername: "admin", + AdminEmail: "root@example.test", AdminPassword: "ChangeMe123!", PublicHostname: "mail.example.test", PublicBaseURL: "http://localhost:5173", @@ -1444,8 +1519,15 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil { t.Fatal(err) } - if domains != 0 || mailboxes != 0 { - t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes) + if domains != 1 || mailboxes != 1 { + t.Fatalf("admin email bootstrap domains=%d mailboxes=%d", domains, mailboxes) + } + var welcomeFrom, welcomeMessageID string + if err := a.db.QueryRow(`SELECT from_addr,message_id FROM messages ORDER BY created_at LIMIT 1`).Scan(&welcomeFrom, &welcomeMessageID); err != nil { + t.Fatal(err) + } + if welcomeFrom != "system@example.test" || !strings.HasSuffix(welcomeMessageID, "@example.test>") { + t.Fatalf("welcome message retained placeholder domain: from=%q messageId=%q", welcomeFrom, welcomeMessageID) } ts := httptest.NewServer(a.Router()) @@ -1454,33 +1536,339 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { var login struct { User User `json:"user"` } - if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK { - t.Fatalf("username login code=%d", code) + if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized { + t.Fatalf("legacy username login code=%d", code) + } + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin email login code=%d", code) } if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{ - "loginName": "rootadmin", + "email": "root@example.test", "displayName": "Administrator", "role": "admin", "disabled": false, }, nil); code != http.StatusOK { - t.Fatalf("rename administrator code=%d", code) + t.Fatalf("admin display update code=%d", code) } if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{ - "loginName": "root@example.test", + "email": "not-an-email", "displayName": "Administrator", "role": "admin", "disabled": false, }, nil); code != http.StatusBadRequest { - t.Fatalf("email-shaped login name code=%d", code) + t.Fatalf("invalid primary email update code=%d", code) + } +} + +func TestAdministratorPrimaryEmailPersistsAcrossRestart(t *testing.T) { + dir := t.TempDir() + cfg := Config{ + Addr: ":0", + DBPath: filepath.Join(dir, "lanqin.db"), + DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", + SessionTTLHours: 24, + AdminEmail: "root@example.test", + AdminPassword: "ChangeMe123!", + PublicHostname: "mail.example.test", + PublicBaseURL: "http://localhost:5173", + AllowInsecureHTTP: true, + } + a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(a.Router()) + admin := &testClient{t: t, server: ts} + var login struct { + User User `json:"user"` + } + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin login code=%d", code) + } + if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{ + "email": "owner@example.test", + "displayName": "Administrator", + "role": "admin", + "disabled": false, + }, nil); code != http.StatusOK { + t.Fatalf("admin email update code=%d", code) + } + if a.config().AdminEmail != "owner@example.test" { + t.Fatalf("runtime admin email=%q", a.config().AdminEmail) + } + ts.Close() + if err := a.Close(); err != nil { + t.Fatal(err) } - oldLogin := &testClient{t: t, server: ts} - if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized { - t.Fatalf("old username login code=%d", code) + restarted, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) } - newLogin := &testClient{t: t, server: ts} - if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK { - t.Fatalf("renamed username login code=%d", code) + t.Cleanup(func() { _ = restarted.Close() }) + var email, loginName string + if err := restarted.db.QueryRow(`SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil { + t.Fatal(err) + } + if email != "owner@example.test" || loginName != "owner@example.test" || restarted.config().AdminEmail != "owner@example.test" { + t.Fatalf("administrator identity reverted after restart: email=%q login=%q config=%q", email, loginName, restarted.config().AdminEmail) + } +} + +func TestLegacyAdminIdentityMigrationKeepsEarliestAdminAndRecordsResult(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + a.updateConfig(func(cfg *Config) { + cfg.AdminUsername = "admin" + cfg.AdminEmail = "admin@example.test" + }) + keeperHash, err := bcrypt.GenerateFromPassword([]byte("OriginalPass123!"), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + demotedHash, err := bcrypt.GenerateFromPassword([]byte("OtherPass123!"), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_users_single_admin`); err != nil { + t.Fatal(err) + } + now := a.now().UTC() + if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin', password_hash=?, two_factor_secret='legacy-secret', two_factor_enabled=1, created_at=?, updated_at=? WHERE role='admin'`, + string(keeperHash), now.Add(-2*time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES('usr_second_admin','second','second@example.test','Second Admin','admin',?,0,?,?)`, string(demotedHash), now.Add(-time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + + if err := a.migrateConfiguredAdministratorIdentity(ctx); err != nil { + t.Fatal(err) + } + if err := a.enforceSingleAdministratorIndex(ctx); err != nil { + t.Fatal(err) + } + + var adminCount int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil || adminCount != 1 { + t.Fatalf("admin count=%d err=%v", adminCount, err) + } + var email, loginName, passwordHash, twoFactorSecret string + var enabled int + if err := a.db.QueryRowContext(ctx, `SELECT email,login_name,password_hash,two_factor_secret,two_factor_enabled FROM users WHERE role='admin'`).Scan(&email, &loginName, &passwordHash, &twoFactorSecret, &enabled); err != nil { + t.Fatal(err) + } + if email != "admin@example.test" || loginName != "admin@example.test" || twoFactorSecret != "legacy-secret" || enabled != 1 { + t.Fatalf("admin identity not migrated safely email=%q login=%q secret=%q enabled=%d", email, loginName, twoFactorSecret, enabled) + } + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("OriginalPass123!")); err != nil { + t.Fatalf("admin password hash was not preserved: %v", err) + } + var secondRole string + if err := a.db.QueryRowContext(ctx, `SELECT role FROM users WHERE id='usr_second_admin'`).Scan(&secondRole); err != nil || secondRole != "user" { + t.Fatalf("second admin role=%q err=%v", secondRole, err) + } + var mailboxCount int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address='admin@example.test'`).Scan(&mailboxCount); err != nil || mailboxCount != 1 { + t.Fatalf("admin mailbox count=%d err=%v", mailboxCount, err) + } + var rawResult string + if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&rawResult); err != nil { + t.Fatal(err) + } + if !strings.Contains(rawResult, `"adminEmail":"admin@example.test"`) || !strings.Contains(rawResult, `"id":"usr_second_admin"`) { + t.Fatalf("migration result not recorded: %s", rawResult) + } +} + +func TestLegacyWebUpdateResolvesAdminEmailFromExistingMailbox(t *testing.T) { + dir := t.TempDir() + cfg := Config{ + Addr: ":0", + DBPath: filepath.Join(dir, "lanqin.db"), + DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", + SessionTTLHours: 24, + AdminEmail: "bootstrap@lanqin.local", + AdminPassword: "ChangeMe123!", + PublicHostname: "mail.example.test", + PublicBaseURL: "http://localhost:5173", + AllowInsecureHTTP: true, + } + a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + stopTestWorkers(a) + ctx := context.Background() + var adminID, passwordHash string + if err := a.db.QueryRowContext(ctx, `SELECT id,password_hash FROM users WHERE role='admin'`).Scan(&adminID, &passwordHash); err != nil { + t.Fatal(err) + } + domainID, err := a.createDomainTx(ctx, nil, "example.test") + if err != nil { + t.Fatal(err) + } + if _, err := a.createMailboxWithPasswordHash(ctx, adminID, domainID, "admin", "admin@example.test", passwordHash, 1024, "active"); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE address='bootstrap@lanqin.local'`); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin' WHERE id=?`, adminID); err != nil { + t.Fatal(err) + } + if err := a.db.Close(); err != nil { + t.Fatal(err) + } + + // Old installations had only LANQIN_ADMIN_USERNAME. A webpage update starts + // the new image directly, without running the interactive installer first. + cfg.AdminUsername = "admin" + cfg.AdminEmail = "" + cfg.MailDomain = "" + updated, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = updated.Close() }) + var email, loginName string + if err := updated.db.QueryRowContext(ctx, `SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil { + t.Fatal(err) + } + if email != "admin@example.test" || loginName != "admin@example.test" { + t.Fatalf("legacy administrator resolved incorrectly: email=%q login=%q", email, loginName) + } + if updated.config().AdminEmail != "admin@example.test" { + t.Fatalf("runtime administrator email was not synchronized: %q", updated.config().AdminEmail) + } + var wrongDomainCount int + if err := updated.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email LIKE '%@lanqin.local'`).Scan(&wrongDomainCount); err != nil { + t.Fatal(err) + } + if wrongDomainCount != 0 { + t.Fatalf("web update created a lanqin.local administrator: %d", wrongDomainCount) + } + var migrationResult string + if err := updated.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&migrationResult); err != nil { + t.Fatal(err) + } + if !strings.Contains(migrationResult, `"emailSource":"existing_admin_mailbox"`) { + t.Fatalf("unexpected administrator email source: %s", migrationResult) + } +} + +func TestOnlyPrimaryEmailCanLoginSecondaryMailboxCannot(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 body=%v", code, login) + } + domainID := mustDefaultDomainID(t, a) + primary := createTestMailbox(t, admin, domainID, "primary-login", "Primary Login", "Password123!", nil) + secondary := createTestMailbox(t, admin, domainID, "secondary-login", "Secondary Login", "MailboxOnly123!", map[string]any{"ownerEmail": primary.Address}) + + user := &testClient{t: t, server: ts} + if code := user.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("primary email login code=%d", code) + } + secondaryLogin := &testClient{t: t, server: ts} + if code := secondaryLogin.do("POST", "/api/auth/login", map[string]string{"email": secondary.Address, "password": "MailboxOnly123!"}, nil); code != http.StatusUnauthorized { + t.Fatalf("secondary mailbox should not login code=%d", code) + } +} + +func TestAdminUserAPICannotCreateOrPromoteAdministrator(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 body=%v", code, login) + } + var errBody map[string]any + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "new-admin@lanqin.local", + "displayName": "New Admin", + "role": "admin", + "password": "Password123!", + "disabled": false, + }, &errBody); code != http.StatusForbidden { + t.Fatalf("create admin code=%d body=%v", code, errBody) + } + var user AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "regular@lanqin.local", + "displayName": "Regular", + "role": "user", + "password": "Password123!", + "disabled": false, + }, &user); code != http.StatusCreated { + t.Fatalf("create user code=%d user=%+v", code, user) + } + if code := admin.do("POST", "/api/admin/users/"+user.ID, map[string]any{ + "email": user.Email, + "displayName": user.DisplayName, + "role": "admin", + "disabled": false, + }, &errBody); code != http.StatusForbidden { + t.Fatalf("promote admin code=%d body=%v", code, errBody) + } +} + +func TestAdminUsersListOrdersAdministratorThenAZPrimaryEmail(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 body=%v", code, login) + } + for _, user := range []struct { + email string + displayName string + }{ + {"zeta@lanqin.local", "Zeta"}, + {"Alpha@lanqin.local", "Alpha"}, + {"bravo@lanqin.local", "Bravo"}, + } { + var created AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": user.email, + "displayName": user.displayName, + "role": "user", + "password": "Password123!", + "disabled": false, + }, &created); code != http.StatusCreated { + t.Fatalf("create user %s code=%d user=%+v", user.email, code, created) + } + } + + var users struct { + Items []AdminUser `json:"items"` + } + if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK { + t.Fatalf("list users code=%d users=%+v", code, users.Items) + } + if len(users.Items) < 4 { + t.Fatalf("expected at least 4 users, got %+v", users.Items) + } + got := []string{users.Items[0].Email, users.Items[1].Email, users.Items[2].Email, users.Items[3].Email} + want := []string{"admin@lanqin.local", "alpha@lanqin.local", "bravo@lanqin.local", "zeta@lanqin.local"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("users order=%v want prefix=%v", got, want) + } } } @@ -1548,7 +1936,7 @@ func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing. } limits := defaultPermissionLimits() limits.MaxMailboxCount = 1 - updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), limits) + setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), limits) if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &errBody); code != http.StatusForbidden { t.Fatalf("mailbox count limit code=%d body=%v", code, errBody) } @@ -1705,6 +2093,138 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { } } +func TestAllMailboxBulkMoveToCustomFolderKeepsMailboxIsolation(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 body=%v", code, login) + } + + var domainList struct { + Items []Domain `json:"items"` + } + if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 { + t.Fatalf("list domains code=%d items=%+v", code, domainList.Items) + } + domainID := domainList.Items[0].ID + primary := createTestMailbox(t, admin, domainID, "bulk-primary", "Bulk Primary", "Password123!", nil) + secondary := createTestMailbox(t, admin, domainID, "bulk-secondary", "Bulk Secondary", "Password456!", map[string]any{"ownerEmail": primary.Address}) + otherUserMailbox := createTestMailbox(t, admin, domainID, "bulk-other", "Bulk Other", "Password789!", nil) + if primary.UserID != secondary.UserID { + t.Fatalf("primary and secondary should share owner: primary=%s secondary=%s", primary.UserID, secondary.UserID) + } + if primary.UserID == otherUserMailbox.UserID { + t.Fatalf("other mailbox should belong to a different user") + } + + ctx := context.Background() + primaryInboxID, err := a.ensureFolder(ctx, primary.ID, "Inbox") + if err != nil { + t.Fatal(err) + } + secondaryInboxID, err := a.ensureFolder(ctx, secondary.ID, "Inbox") + if err != nil { + t.Fatal(err) + } + otherInboxID, err := a.ensureFolder(ctx, otherUserMailbox.ID, "Inbox") + if err != nil { + t.Fatal(err) + } + now := a.now().UTC().Format(time.RFC3339Nano) + insertMessage := func(id, mailboxID, folderID, subject string) { + t.Helper() + if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, mailboxID, folderID, "", id+"-uid", "<"+id+"@example.test>", subject, "sender@example.test", "", jsonEncode([]string{"recipient@example.test"}), "[]", "[]", now, now, subject, "", "", 0, 0, 0, 0, now, now); err != nil { + t.Fatal(err) + } + } + insertMessage("msg_bulk_primary_move", primary.ID, primaryInboxID, "bulk move primary") + insertMessage("msg_bulk_secondary_move", secondary.ID, secondaryInboxID, "bulk move secondary") + insertMessage("msg_bulk_other_stays", otherUserMailbox.ID, otherInboxID, "bulk move other") + + userClient := &testClient{t: t, server: ts} + if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("user login code=%d", code) + } + + var allInbox struct { + Items []MailMessage `json:"items"` + } + if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&folder=Inbox&q=bulk%20move", nil, &allInbox); code != http.StatusOK || len(allInbox.Items) != 2 { + t.Fatalf("all inbox code=%d items=%+v", code, allInbox.Items) + } + messageIDs := make([]string, 0, len(allInbox.Items)+1) + for _, item := range allInbox.Items { + messageIDs = append(messageIDs, item.ID) + } + messageIDs = append(messageIDs, "msg_bulk_other_stays") + var moved struct { + OK bool `json:"ok"` + Moved int `json:"moved"` + Failed int `json:"failed"` + Message string + Items []struct { + ID string `json:"id"` + MailboxID string `json:"mailboxId"` + OK bool `json:"ok"` + Message string `json:"message"` + } `json:"items"` + } + if code := userClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": messageIDs, "folder": "跨邮箱项目"}, &moved); code != http.StatusOK { + t.Fatalf("bulk move code=%d body=%+v", code, moved) + } + if moved.OK || moved.Moved != 2 || moved.Failed != 1 || !strings.Contains(moved.Message, "已移动 2 封邮件,1 封失败") || len(moved.Items) != 3 { + t.Fatalf("bulk move summary=%+v", moved) + } + + var primaryTargetID, secondaryTargetID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "跨邮箱项目").Scan(&primaryTargetID); err != nil { + t.Fatalf("primary target folder: %v", err) + } + if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "跨邮箱项目").Scan(&secondaryTargetID); err != nil { + t.Fatalf("secondary target folder: %v", err) + } + var primaryFolderID, secondaryFolderID, otherFolderID string + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_primary_move").Scan(&primaryFolderID); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_secondary_move").Scan(&secondaryFolderID); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_other_stays").Scan(&otherFolderID); err != nil { + t.Fatal(err) + } + if primaryFolderID != primaryTargetID || secondaryFolderID != secondaryTargetID { + t.Fatalf("messages moved to wrong folders primary=%s want=%s secondary=%s want=%s", primaryFolderID, primaryTargetID, secondaryFolderID, secondaryTargetID) + } + if otherFolderID != otherInboxID { + t.Fatalf("other user's message moved: folder=%s want=%s", otherFolderID, otherInboxID) + } + + otherClient := &testClient{t: t, server: ts} + if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": otherUserMailbox.Address, "password": "Password789!"}, &login); code != http.StatusOK { + t.Fatalf("other login code=%d", code) + } + var forbidden struct { + OK bool `json:"ok"` + Moved int `json:"moved"` + Failed int `json:"failed"` + Items []struct { + ID string `json:"id"` + OK bool `json:"ok"` + Message string `json:"message"` + } `json:"items"` + } + if code := otherClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": []string{"msg_bulk_primary_move"}, "folder": "Inbox"}, &forbidden); code != http.StatusOK || forbidden.OK || forbidden.Moved != 0 || forbidden.Failed != 1 || len(forbidden.Items) != 1 || forbidden.Items[0].Message != "邮件不存在或无权访问" { + t.Fatalf("other user bulk move primary message code=%d body=%+v", code, forbidden) + } +} + func TestCustomMailFoldersCreateAndMove(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -3338,7 +3858,7 @@ func TestAdminSendAuditAccessAndFilters(t *testing.T) { if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden { t.Fatalf("regular send audit code=%d", code) } - updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview}) + setRegularPermissionGroupForTest(t, a, []string{PermissionAdminOverview}, defaultPermissionLimits()) if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden { t.Fatalf("admin access without messages permission code=%d", code) } @@ -4168,11 +4688,15 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) { t.Fatal(err) } var enabled struct { - User User `json:"user"` + User User `json:"user"` + RecoveryCodes []string `json:"recoveryCodes"` } if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled { t.Fatalf("enable status=%d user=%+v", status, enabled.User) } + if len(enabled.RecoveryCodes) != 8 { + t.Fatalf("recovery codes=%+v", enabled.RecoveryCodes) + } fresh := &testClient{t: t, server: ts} var challenge struct { @@ -4185,14 +4709,28 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) { if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized { t.Fatalf("wrong challenge status=%d body=%v", status, out) } + if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &login); status != http.StatusOK || fresh.cookie == nil { + t.Fatalf("recovery login status=%d body=%v cookie=%v", status, login, fresh.cookie) + } + reused := &testClient{t: t, server: ts} + if status := reused.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" { + t.Fatalf("reused challenge status=%d challenge=%+v", status, challenge) + } + if status := reused.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &out); status != http.StatusUnauthorized { + t.Fatalf("reused recovery status=%d body=%v", status, out) + } + totpClient := &testClient{t: t, server: ts} + if status := totpClient.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" { + t.Fatalf("totp challenge status=%d challenge=%+v", status, challenge) + } code, err = generateTOTP(setup.Secret, a.now().UTC()) if err != nil { t.Fatal(err) } - if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil { - t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie) + if status := totpClient.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || totpClient.cookie == nil { + t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, totpClient.cookie) } - if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled { + if status := totpClient.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled { t.Fatalf("disable status=%d user=%+v", status, enabled.User) } } @@ -4275,9 +4813,20 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { }, &errBody); code != http.StatusForbidden { t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody) } - regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview}) - if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) { - t.Fatalf("regular group update did not persist permissions=%+v", regularGroup) + var regularUpdateErr map[string]any + if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{ + "name": "Changed Regular", + "description": "Should not change", + "permissions": []string{PermissionAdminOverview}, + }, ®ularUpdateErr); code != http.StatusForbidden { + t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr) + } + 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) } 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) @@ -4356,7 +4905,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { }, &plainUser); code != http.StatusCreated { t.Fatalf("create plain user code=%d user=%+v", code, plainUser) } - if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) { + if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionMailAccess) || userHasPermission(&plainUser.User, PermissionAdminOverview) { t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User) } @@ -4562,7 +5111,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) { t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody) } - updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess)) + setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess), defaultPermissionLimits()) noAccess := &testClient{t: t, server: ts} if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK { t.Fatalf("no access login code=%d", code) @@ -4571,7 +5120,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) { t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody) } - updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend)) + setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend), defaultPermissionLimits()) noSend := &testClient{t: t, server: ts} if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK { t.Fatalf("no send login code=%d", code) diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index 1123885..7dbf06e 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -1,6 +1,7 @@ package app import ( + "database/sql" "errors" "net/http" "strings" @@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { return } if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) { - respondError(w, http.StatusUnauthorized, "验证码错误") - return + ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode) + if consumeErr != nil || !ok { + respondError(w, http.StatusUnauthorized, "验证码或恢复码错误") + return + } } a.deleteLoginChallenge(r.Context(), challenge.ID) if err := a.issueSession(w, r, user.ID); err != nil { @@ -50,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") return } - var loginName string - var err error - if strings.TrimSpace(req.LoginName) != "" { - loginName, err = cleanUsername(req.LoginName) - } else { - loginName, err = cleanLoginName(req.Email) + emailInput := req.Email + if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") { + emailInput = req.LoginName } + email, err := cleanPrimaryEmail(emailInput) if err != nil { respondError(w, http.StatusUnauthorized, "账号或密码错误") return } - user, passwordHash, err := a.userByEmail(r.Context(), loginName) + user, passwordHash, err := a.userByEmail(r.Context(), email) if err != nil || user.Disabled { respondError(w, http.StatusUnauthorized, "账号或密码错误") return @@ -107,8 +109,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") return } - email := normalizeEmail(req.Email) - if email == "" || !strings.Contains(email, "@") { + email, err := cleanPrimaryEmail(req.Email) + if err != nil { badRequest(w, errors.New("邮箱地址无效")) return } @@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { } displayName := strings.TrimSpace(req.DisplayName) if displayName == "" { - displayName = strings.Split(email, "@")[0] + badRequest(w, errors.New("请输入显示名称")) + return } if len([]rune(displayName)) > 80 { badRequest(w, errors.New("显示名称不能超过 80 个字符")) return } + parts := strings.SplitN(email, "@", 2) + mailboxLocalPart := normalizeLocalPart(req.LocalPart) + if mailboxLocalPart == "" { + mailboxLocalPart = normalizeLocalPart(parts[0]) + } + mailboxDomainID := strings.TrimSpace(req.DomainID) + var mailboxDomain string + if mailboxDomainID != "" { + err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain) + } else { + err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain) + } + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + badRequest(w, errors.New("所选邮箱域名不可用")) + } else { + respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") + } + return + } + if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) { + badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致")) + return + } + for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) { + if item == mailboxLocalPart { + respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀") + return + } + } if _, _, err := a.userByEmail(r.Context(), email); err == nil { respondError(w, http.StatusConflict, "该邮箱已被注册") return @@ -138,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { } now := a.now().UTC().Format(time.RFC3339Nano) userID := newID("usr") - if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { respondError(w, http.StatusConflict, "该邮箱已被注册") @@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") return } + if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + respondError(w, http.StatusConflict, "该邮箱已被注册") + } else { + respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试") + } + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") + return + } user, err := a.userByID(r.Context(), userID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load user") @@ -156,38 +207,6 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试") return } - - // Create a mailbox for the registered user - var mailboxDomainID string - var mailboxLocalPart string - if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" { - // User selected a specific domain and local part - mailboxDomainID = strings.TrimSpace(req.DomainID) - mailboxLocalPart = normalizeLocalPart(req.LocalPart) - } else { - // Auto-detect: use the first active domain and email local part - if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil { - mailboxDomainID = "" - } - if mailboxDomainID != "" { - mailboxLocalPart = strings.SplitN(email, "@", 2)[0] - } - } - if mailboxDomainID != "" && mailboxLocalPart != "" { - // Check reserved prefixes - reserved := map[string]bool{} - for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) { - reserved[item] = true - } - if reserved[mailboxLocalPart] { - respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀") - return - } - if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil { - a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email) - } - } - respondJSON(w, http.StatusCreated, map[string]any{"user": user}) } @@ -205,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) { func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { user := currentUser(r) + if user == nil || user.Role != "admin" { + respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员") + return + } var req struct { DisplayName string `json:"displayName"` } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 8f3b70e..eeafb4b 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -16,6 +16,7 @@ type Config struct { SessionTTLHours int AdminUsername string AdminEmail string + MailDomain string AdminPassword string PublicHostname string PublicBaseURL string @@ -72,7 +73,8 @@ func LoadConfig() Config { CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")), - AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), + AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")), + MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index feb21d1..9a9387a 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -2156,6 +2156,74 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) { + var req struct { + IDs []string `json:"ids"` + Folder string `json:"folder"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + folder, err := normalizeFolderNameForUser(req.Folder) + if err != nil { + badRequest(w, err) + return + } + ids := make([]string, 0, len(req.IDs)) + seen := map[string]bool{} + for _, id := range req.IDs { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + badRequest(w, errors.New("请选择要移动的邮件")) + return + } + type itemResult struct { + ID string `json:"id"` + MailboxID string `json:"mailboxId,omitempty"` + OK bool `json:"ok"` + Message string `json:"message"` + } + results := make([]itemResult, 0, len(ids)) + folderByMailbox := map[string]string{} + moved := 0 + for _, id := range ids { + msg, err := a.loadMessageForRequest(r, id, false) + if err != nil { + results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"}) + continue + } + folderID := folderByMailbox[msg.MailboxID] + if folderID == "" { + folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder) + if err != nil { + results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"}) + continue + } + folderByMailbox[msg.MailboxID] = folderID + } + if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil { + a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err) + results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"}) + continue + } + moved++ + results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"}) + } + failed := len(results) - moved + message := fmt.Sprintf("已移动 %d 封邮件", moved) + if failed > 0 { + message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed) + } + respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results}) +} + func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) { row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go index 670d97c..5756ead 100644 --- a/apps/api/internal/app/open_api_handlers.go +++ b/apps/api/internal/app/open_api_handlers.go @@ -809,7 +809,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own return "", errors.New("invalid owner email") } var existing string - err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing) + err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing) if err == nil { return existing, nil } diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go index 5862388..89aae09 100644 --- a/apps/api/internal/app/permission_group_handlers.go +++ b/apps/api/internal/app/permission_group_handlers.go @@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request respondError(w, http.StatusNotFound, "permission group not found") return } - if id == PermissionGroupSuperAdmin { - respondError(w, http.StatusForbidden, "super administrator group cannot be edited") + if intBool(existingSystem) { + respondError(w, http.StatusForbidden, "system permission groups cannot be edited") return } var req struct { diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go index d6d6710..aa51c3f 100644 --- a/apps/api/internal/app/permissions.go +++ b/apps/api/internal/app/permissions.go @@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{ {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"}, {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"}, - {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"}, - {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"}, + {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"}, + {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"}, {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"}, {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"}, - {Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"}, - {Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"}, - {Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"}, - {Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"}, + {Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"}, + {Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"}, + {Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"}, + {Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"}, {Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"}, {Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"}, @@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup { { ID: PermissionGroupSuperAdmin, Name: "管理员", - Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。", + Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。", Permissions: allPermissionKeys(), Limits: PermissionLimits{}, System: true, @@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr } func (a *App) isDefaultAdminUser(u *User) bool { - if u == nil { - return false - } - if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") { - return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername) - } - adminEmail := normalizeEmail(a.config().AdminEmail) - return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail) + return u != nil && u.Role == "admin" } func sortPermissionGroups(items []PermissionGroup) { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index f40f314..aec06cb 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -128,6 +128,7 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar) r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel) r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel) + r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove) r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove) r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage) r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment) @@ -344,10 +345,9 @@ func bearerToken(r *http.Request) string { } func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) { - loginName := normalizeLoginName(email) + email = normalizeEmail(email) row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at - FROM users WHERE login_name=? OR email=? - ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName) + FROM users WHERE email=? LIMIT 1`, email) var u User var passwordHash string var disabled, twoFactorEnabled int diff --git a/apps/api/internal/app/turnstile.go b/apps/api/internal/app/turnstile.go index 2992864..e61a9af 100644 --- a/apps/api/internal/app/turnstile.go +++ b/apps/api/internal/app/turnstile.go @@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error if ip := normalizeRemoteIP(remoteIP); ip != "" { form.Set("remoteip", ip) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode())) + verifyURL := strings.TrimSpace(a.turnstileURL) + if verifyURL == "" { + verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode())) if err != nil { return err } diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go index fc6d0d1..e5c1858 100644 --- a/apps/api/internal/app/two_factor.go +++ b/apps/api/internal/app/two_factor.go @@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) { return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil } +func newTwoFactorRecoveryCode() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf) + if len(value) > 10 { + value = value[:10] + } + return value[:5] + "-" + value[5:], nil +} + +func normalizeRecoveryCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + return code +} + func totpProvisioningURI(issuer, account, secret string) string { issuer = strings.TrimSpace(issuer) account = strings.TrimSpace(account) @@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) { _, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id) } +func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) { + if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil { + return nil, err + } + now := a.now().UTC().Format(time.RFC3339Nano) + codes := make([]string, 0, 8) + for len(codes) < 8 { + code, err := newTwoFactorRecoveryCode() + if err != nil { + return nil, err + } + normalized := normalizeRecoveryCode(code) + _, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`, + newID("rcv"), userID, hashToken(normalized), now) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + continue + } + return nil, err + } + codes = append(codes, code) + } + return codes, nil +} + +func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) { + normalized := normalizeRecoveryCode(code) + if len(normalized) < 8 { + return false, nil + } + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var id string + if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) { row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id) var u User @@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "invalid verification code") return } - if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication") + return + } + recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate recovery codes") + return + } + if err := tx.Commit(); err != nil { respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication") return } @@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to load user") return } - respondJSON(w, http.StatusOK, map[string]any{"user": updated}) + respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes}) } func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) { @@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "invalid verification code") return } - if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication") + return + } + if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication") + return + } + if err := tx.Commit(); err != nil { respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication") return } diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go index c9941da..5c18113 100644 --- a/apps/api/internal/app/util.go +++ b/apps/api/internal/app/util.go @@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) { return username, nil } +func cleanPrimaryEmail(value string) (string, error) { + email := normalizeEmail(value) + if email == "" || !strings.Contains(email, "@") { + return "", errors.New("邮箱地址无效") + } + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", errors.New("邮箱地址无效") + } + if len([]rune(email)) > 254 { + return "", errors.New("邮箱地址不能超过 254 个字符") + } + return email, nil +} + func dedupeEmails(items []string) []string { seen := map[string]bool{} out := make([]string, 0, len(items)) diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index 205fc34..70db88f 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -30,7 +30,7 @@ import { const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ { key: "overview", label: "数据总览", icon: , permissions: ["admin.overview.view"] }, { key: "users", label: "账号管理", icon: , permissions: ["admin.users.view"] }, - { key: "permissionGroups", label: "权限配额", icon: , permissions: ["admin.permission_groups.view"] }, + { key: "permissionGroups", label: "权限配置", icon: , permissions: ["admin.permission_groups.view"] }, { key: "domains", label: "域名管理", icon: , permissions: ["admin.domains.view", "admin.dns.view"] }, { key: "mailboxes", label: "邮箱管理", icon: , permissions: ["admin.mailboxes.view"] }, { key: "aliases", label: "邮件转发", icon: , permissions: ["admin.aliases.view"] }, @@ -108,7 +108,7 @@ function ProtectedContent() {
{user.displayName} - {user.loginName || user.email} + {user.email}
{user.role === "admin" ? "管理员" : "普通用户"} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index cf2d2c4..6cb17a4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -24,6 +24,7 @@ --input: 214.3 31.8% 91.4%; --ring: 216 22% 42%; --radius: 0.5rem; + --app-sidebar-width: 18rem; --sidebar-background: 0 0% 100%; --sidebar-foreground: 222.2 84% 4.9%; --sidebar-primary: 224 44% 12%; @@ -85,7 +86,7 @@ @layer components { .mail-shell-grid { display: grid; - grid-template-columns: clamp(236px, 17vw, 344px) minmax(0, 1fr); + grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr); } .mail-content-grid { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 651d4d7..9fc293c 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -240,3 +240,5 @@ export type PublicSettings = { openRegistration: boolean; turnstileEnabled: bool export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string } +export type TwoFactorEnableResponse = { user: User; recoveryCodes: string[] } +export type BulkMoveResult = { ok: boolean; moved: number; failed: number; message: string; items: { id: string; mailboxId?: string; ok: boolean; message: string }[] } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 80d8e8c..106e66d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -102,7 +102,7 @@ export const api = { updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }), setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }), - enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }), + enableTwoFactor: (code: string) => request("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }), disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }), contacts: () => request>("/api/me/contacts"), createContact: (payload: { name: string; email: string; note: string }) => request("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), @@ -154,8 +154,8 @@ export const api = { updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), - createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), - updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), + createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request("/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(`/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>("/api/admin/domains"), @@ -163,7 +163,7 @@ export const api = { updateDomain: (id: string, payload: { status: string }) => request(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }), mailboxes: () => request>("/api/admin/mailboxes"), - createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), + createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }), aliases: () => request>("/api/admin/aliases"), @@ -295,5 +295,6 @@ export const api = { addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }), removeLabel: (id: string, labelID: string) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels/${labelID}`, { method: "DELETE" }), move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }), + bulkMove: (ids: string[], folder: string) => request("/api/mail/messages/bulk-move", { method: "POST", body: JSON.stringify({ ids, folder }) }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), } diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 19ef8a6..6f741fb 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -33,7 +33,7 @@ type PendingConfirm = { title: string; description?: string; confirmText: string const sectionMeta: Record = { overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" }, users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" }, - permissionGroups: { label: "权限配额", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" }, + permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" }, domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" }, mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" }, aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" }, @@ -58,7 +58,15 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email" const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5" const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultMailboxLimitOverride = 9 -const accountLoginName = (user: Pick) => user.loginName || user.email +const accountPrimaryEmail = (user: Pick) => user.email || user.loginName || "" + +function compareAdminUsers(left: AdminUser, right: AdminUser) { + if (left.role === "admin" && right.role !== "admin") return -1 + if (left.role !== "admin" && right.role === "admin") return 1 + return accountPrimaryEmail(left).localeCompare(accountPrimaryEmail(right), "en", { sensitivity: "base" }) || + left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) || + left.createdAt.localeCompare(right.createdAt) +} export function AdminPage() { const qc = useQueryClient() @@ -245,14 +253,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss const [pendingConfirm, setPendingConfirm] = React.useState(null) const canCreate = hasPermission(user, "admin.users.create") const canDelete = hasPermission(user, "admin.users.delete") - const filteredUsers = users.filter((user) => { - const keyword = query.trim().toLowerCase() - const loginName = accountLoginName(user) - const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) - const matchesRole = roleFilter === "all" || user.role === roleFilter - const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) - return matchesKeyword && matchesRole && matchesStatus - }) + const filteredUsers = users + .filter((user) => { + const keyword = query.trim().toLowerCase() + const primaryEmail = accountPrimaryEmail(user) + const matchesKeyword = !keyword || [primaryEmail, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) + const matchesRole = roleFilter === "all" || user.role === roleFilter + const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) + return matchesKeyword && matchesRole && matchesStatus + }) + .sort(compareAdminUsers) const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) return ( @@ -291,9 +301,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
{user.displayName}
-
{accountLoginName(user)}
+
{accountPrimaryEmail(user)}
- setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /> + setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
@@ -307,20 +317,20 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
- 账号身份权限配额邮箱状态创建 + 账号身份权限配置邮箱状态创建 {filteredUsers.map((user) => (
{user.displayName}
-
{accountLoginName(user)}
+
{accountPrimaryEmail(user)}
{user.disabled ? "停用" : "正常"} {new Date(user.createdAt).toLocaleDateString()} - setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /> + setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
))}
@@ -349,7 +359,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ onSuccess: () => { setPendingConfirm(null) invalidateAdmin(qc) - toast({ title: "权限配额已删除" }) + toast({ title: "权限配置已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }), }) @@ -358,20 +368,20 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ if (!keyword) return true return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword)) }) - const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin" + const isEditable = (group: PermissionGroup) => !group.system const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0 return (
- 权限配额 + 权限配置 {canCreate && }
- setQuery(event.target.value)} placeholder="搜索权限配额、说明或权限键" className="pl-9" /> + setQuery(event.target.value)} placeholder="搜索权限配置、说明或权限键" className="pl-9" />
{filtered.map((group) => ( @@ -389,14 +399,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ {(canUpdate || canDelete) && - setEditing(group)}>编辑权限配额 + setEditing(group)}>编辑权限配置 setPendingConfirm({ title: "删除权限配额?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配额", onConfirm: () => remove.mutate(group.id) })} + onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })} > - 删除权限配额 + 删除权限配置 } @@ -406,7 +416,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
))} - {filtered.length === 0 && } + {filtered.length === 0 && }
{editing && { if (!open) setEditing(null) }} />} { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> @@ -443,20 +453,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: onSuccess: () => { invalidateAdmin(qc) setDialogOpen(false) - toast({ title: group ? "权限配额已更新" : "权限配额已创建" }) + toast({ title: group ? "权限配置已更新" : "权限配置已创建" }) }, onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }), }) const trigger = group ? null : ( - + ) return ( {trigger} - {group ? "编辑权限配额" : "创建权限配额"} + {group ? "编辑权限配置" : "创建权限配置"}
{ event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
@@ -1675,7 +1685,7 @@ function invalidateAdmin(qc: ReturnType) { qc.invalidateQ function UserMailboxCell({ user }: { user: AdminUser }) { const { toast } = useToast() - const loginAddress = accountLoginName(user) + const loginAddress = accountPrimaryEmail(user) const mailboxes = user.mailboxes || [] const [mailboxQuery, setMailboxQuery] = React.useState("") const normalizedQuery = mailboxQuery.trim().toLowerCase() @@ -1776,7 +1786,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission } return (
- +
{groups.map((group) => { const checked = value.includes(group.id) @@ -1791,7 +1801,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission ) })}
- {groups.length === 0 && } + {groups.length === 0 && }
) } @@ -1815,13 +1825,14 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe const canUpdate = hasPermission(currentUser, "admin.users.update") const canResetPassword = hasPermission(currentUser, "admin.users.reset_password") const update = useMutation({ - mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload), + mutationFn: (payload: { email: string; displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }), }) - function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) { - const role = patch.role || user.role + function quickPatch(patch: Partial<{ disabled: boolean }>) { + const role = user.role update.mutate({ + email: accountPrimaryEmail(user), displayName: user.displayName, role, disabled: patch.disabled ?? user.disabled, @@ -1829,27 +1840,24 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe }) } if (!canUpdate && !canResetPassword && !onDelete) return null - return <>{canUpdate && setEditOpen(true)}>编辑账号}{canResetPassword && setPasswordOpen(true)}>重置密码}{!user.protected && canUpdate && <> quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"} quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}}{!user.protected && onDelete && <>删除账号}{canUpdate && }{canResetPassword && } + return <>{canUpdate && setEditOpen(true)}>编辑账号}{canResetPassword && setPasswordOpen(true)}>重置密码}{!user.protected && user.role !== "admin" && canUpdate && <> quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}}{!user.protected && user.role !== "admin" && onDelete && <>删除账号}{canUpdate && }{canResetPassword && } } -function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) { +function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) { const qc = useQueryClient() const { toast } = useToast() const [open, setOpen] = React.useState(false) - const [role, setRole] = React.useState<"admin" | "user">("user") const [status, setStatus] = React.useState("active") - const [permissionGroupIds, setPermissionGroupIds] = React.useState([]) const create = useMutation({ mutationFn: (form: FormData) => api.createUser({ - loginName: String(form.get("loginName") || ""), + email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), - role, + role: "user", disabled: status === "disabled", - mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined, - permissionGroupIds: role === "user" ? permissionGroupIds : [], + mailboxLimitOverride: mailboxLimitFromForm(form), }), - onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) }, + onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }), }) return ( @@ -1858,15 +1866,14 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr 创建账号 { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> - +
- setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} /> + undefined} items={[["user", "普通用户"]]} disabled />
- {role === "user" && } - {role === "user" && } +
@@ -1898,7 +1905,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: }, [user, open]) const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { - loginName: String(form.get("loginName") || ""), + email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", @@ -1913,11 +1920,11 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: 编辑账号
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> - +
- setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> - + setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled /> +
{role === "user" && !user.protected && } {role === "user" && !user.protected && } @@ -1930,7 +1937,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) { const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) }) - return 重置密码 { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}> + return 重置密码
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
} function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { @@ -1947,10 +1954,56 @@ function CreateDomainDialog() { } function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) { - const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("") - React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) - const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) - return 创建邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
{ownerMode === "existing" ? !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : }
+ const qc = useQueryClient() + const { toast } = useToast() + const [open, setOpen] = React.useState(false) + const [domainId, setDomainId] = React.useState("") + const [ownerMode, setOwnerMode] = React.useState("new") + const [userId, setUserId] = React.useState("") + React.useEffect(() => { + if (!domainId && domains[0]) setDomainId(domains[0].id) + if (!userId && users[0]) setUserId(users[0].id) + }, [domains, domainId, users, userId]) + const mut = useMutation({ + mutationFn: (form: FormData) => api.createMailbox({ + domainId, + localPart: String(form.get("localPart")), + displayName: String(form.get("displayName")), + password: String(form.get("password")), + quotaMb: Number(form.get("quotaMb") || 1024), + role: "user", + ownerEmail: String(form.get("ownerEmail") || ""), + userId: ownerMode === "existing" ? userId : "", + }), + onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, + onError: (e) => toast({ title: "创建失败", description: e.message }), + }) + return ( + + + + 创建邮箱 +
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> + +
+ + +
+ + {ownerMode === "existing" ? ( + !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} /> + ) : ( + + )} +
+ + +
+ + +
+
+ ) } function CreateAliasDialog({ domains }: { domains: Domain[] }) { diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index e467361..46434cc 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -21,7 +21,7 @@ export function LoginPage() { const login = useMutation({ mutationFn: (form: FormData) => challengeToken ? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") }) - : api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }), + : api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }), onSuccess: async (data) => { if (data.twoFactorRequired && data.challengeToken) { setChallengeToken(data.challengeToken) @@ -49,8 +49,8 @@ export function LoginPage() { {!challengeToken ? ( <>
- - + +
@@ -59,8 +59,8 @@ export function LoginPage() { ) : (
- - + +
)} {!challengeToken && turnstileRequired && ( diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 3fb1ab5..f68ebb3 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -737,6 +737,8 @@ export function MailPage() { async function runConfirmedBulkAction(action: BulkAction, ids: string[]) { setBulkPending(true) try { + let completionTitle = `已处理 ${ids.length} 封邮件` + let completionDescription: string | undefined if (action === "read" || action === "unread") { const read = action === "read" await Promise.all(ids.map((id) => api.markRead(id, read))) @@ -747,13 +749,15 @@ export function MailPage() { await Promise.all(ids.map((id) => api.delete(id))) } else { const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam" - await Promise.all(ids.map((id) => api.move(id, target))) + const result = await api.bulkMove(ids, target) + completionTitle = result.ok ? result.message : "批量移动部分失败" + completionDescription = result.ok ? undefined : result.message } if (selectedId && ids.includes(selectedId)) setSelectedId(null) setCompactSelectedIds([]) setPendingConfirm(null) await refreshMailData() - toast({ title: `已处理 ${ids.length} 封邮件` }) + toast({ title: completionTitle, description: completionDescription }) } catch (error) { toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { @@ -766,11 +770,11 @@ export function MailPage() { if (ids.length === 0) return setBulkPending(true) try { - await Promise.all(ids.map((id) => api.move(id, folderName))) + const result = await api.bulkMove(ids, folderName) if (selectedId && ids.includes(selectedId)) setSelectedId(null) setCompactSelectedIds([]) await refreshMailData() - toast({ title: folderName === "Inbox" ? `已将 ${ids.length} 封邮件移回收件箱` : `已移动 ${ids.length} 封邮件` }) + toast({ title: result.ok ? (folderName === "Inbox" ? `已将 ${result.moved} 封邮件移回收件箱` : `已移动 ${result.moved} 封邮件`) : "批量移动部分失败", description: result.ok ? undefined : result.message }) } catch (error) { toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { @@ -1219,7 +1223,7 @@ export function MailPage() { onLanguageChange={setLanguage} onSettings={openSettings} /> -
+
- {!sidebarCollapsed && showMailboxCopy && ( + {!sidebarCollapsed && ( -
- +
+ {recoveryCodes.length > 0 && ( +
+
+
+
恢复码
+
每个恢复码只能使用一次。
+
+ +
+
+ {recoveryCodes.map((code) => {code})} +
+
+ )} +
{ e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}> + +
+ +
+ +
)} diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx index 953680e..cd250b5 100644 --- a/apps/web/src/pages/register.tsx +++ b/apps/web/src/pages/register.tsx @@ -24,32 +24,28 @@ export function RegisterPage() { const [domainId, setDomainId] = React.useState("") const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || [] const selectedDomain = domains.find((d) => d.id === domainId) + React.useEffect(() => { + if (!domainId && domains[0]) setDomainId(domains[0].id) + if (domainId && !domains.some((domain) => domain.id === domainId)) setDomainId(domains[0]?.id || "") + }, [domainId, domains]) const register = useMutation({ mutationFn: (form: FormData) => { const password = String(form.get("password") || "") const confirmPassword = String(form.get("confirmPassword") || "") validatePasswordConfirm(password, confirmPassword) - - if (domainId && selectedDomain) { - const localPart = String(form.get("localPart") || "").trim() - if (!localPart) throw new Error("请输入邮箱前缀") - return api.register({ - email: `${localPart}@${selectedDomain.name}`, - displayName: String(form.get("displayName") || ""), - password, - turnstileToken, - domainId, - localPart, - }) - } - - // Fallback: no domains available, use email directly + const displayName = String(form.get("displayName") || "").trim() + if (!displayName) throw new Error("请输入显示名称") + const localPart = String(form.get("localPart") || "").trim() + if (!localPart) throw new Error("请输入邮箱前缀") + if (!domainId || !selectedDomain) throw new Error("请选择邮箱域名") return api.register({ - email: String(form.get("email") || ""), - displayName: String(form.get("displayName") || ""), + email: `${localPart}@${selectedDomain.name}`, + displayName, password, turnstileToken, + domainId, + localPart, }) }, onSuccess: async () => { @@ -102,14 +98,12 @@ export function RegisterPage() {
) : ( -
- - -
+
当前没有可注册的邮箱域名
)}
- + +

显示名称注册后不可自行修改,如需更换请联系管理员。

@@ -120,7 +114,7 @@ export function RegisterPage() {
{turnstileRequired && } - diff --git a/deploy/.env.example b/deploy/.env.example index 844b411..2ea77e4 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -51,9 +51,13 @@ LANQIN_TLS_KEY_FILE= # ========================= # 初始管理员 # ========================= -# 第一次启动时只创建管理员账号,不会自动创建同名邮箱或域名。 -# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。 -LANQIN_ADMIN_USERNAME=admin +# 第一次启动时会创建唯一管理员账号和真实管理员邮箱。 +# 网页前台和后台统一使用完整主登录邮箱 + 密码登录。 +LANQIN_MAIL_DOMAIN=example.com +LANQIN_ADMIN_EMAIL=admin@example.com + +# 旧版本兼容字段,网页登录不再使用;新安装脚本会写入管理员邮箱前缀。 +LANQIN_ADMIN_USERNAME= # 生产环境必须改掉默认密码。 LANQIN_ADMIN_PASSWORD=ChangeMe123! diff --git a/deploy/README.md b/deploy/README.md index f5016da..51257cd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,15 +18,16 @@ sudo newszxcn-email rollback sudo newszxcn-email guide sudo newszxcn-email credentials sudo newszxcn-email reset-password +sudo newszxcn-email reset-2fa ``` 一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 -首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。 +首次安装会依次询问防火墙模式、邮件服务器域名、邮箱地址域名、管理员邮箱/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。管理员邮箱默认 `admin@邮箱地址域名`,自定义管理员密码最少 6 位,留空则生成 12 位密码。 安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。 -菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。 +菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置唯一管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。唯一管理员 2FA 锁死时可使用 `sudo newszxcn-email reset-2fa` 应急关闭。 ## 最简单部署:单容器镜像版 @@ -35,7 +36,7 @@ sudo newszxcn-email reset-password ```bash cd deploy cp .env.example .env -# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / LANQIN_ADMIN_PASSWORD +# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_MAIL_DOMAIN / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD docker compose pull docker compose up -d ``` diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 766abdc..d03ced5 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -10,9 +10,9 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) ``` -安装脚本会依次询问防火墙配置、邮件服务器域名、管理员用户名和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。 +安装脚本会依次询问防火墙配置、邮件服务器域名、邮箱地址域名、管理员邮箱和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。 -安装完成后,请记录终端中显示的访问地址、管理员用户名和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。 +安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。 ## 登录入口 @@ -23,7 +23,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i | 邮箱前台 | `https://mail.example.com/` | 收发邮件、申请邮箱和账号设置 | | 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 | -管理员账号是安装时填写的用户名,默认为 `admin`。管理员用户名不是邮箱地址。 +管理员账号是安装时创建的完整邮箱地址,默认为 `admin@邮箱地址域名`。前台和后台都只能使用完整主登录邮箱 + 密码登录,显示名称仅用于页面展示。 ## 首次配置 @@ -105,13 +105,14 @@ sudo newszxcn-email rollback sudo newszxcn-email guide sudo newszxcn-email credentials sudo newszxcn-email reset-password +sudo newszxcn-email reset-2fa ``` 命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。 -`guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`。 +`guide` 命令会读取当前安装地址、管理员邮箱、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`。 -`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后重置配置管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。 +`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后按管理员邮箱重置唯一管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。唯一管理员因双因素认证无法登录时,可执行 `reset-2fa` 应急关闭管理员 2FA,登录后应重新绑定并保存新的恢复码。 超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。 diff --git a/docs/ISSUE_LEDGER.md b/docs/ISSUE_LEDGER.md new file mode 100644 index 0000000..8a0d424 --- /dev/null +++ b/docs/ISSUE_LEDGER.md @@ -0,0 +1,176 @@ +# NewSzxcn Email 问题台账 + +本文件是 NewSzxcn Email 的单一问题台账。所有问题保留唯一编号和完整历史,不重复建立“历史问题”副本。 + +## 管理模式 + +- 当前任务是唯一长期运行的 NewSzxcn 项目任务。 +- 内部按项目总览、问题中心、前端、后端、邮件核心、部署运维、质量复核、文档发布分类管理。 +- 用户提出问题后,默认在同一任务内完成分类、资料查询、代码/服务器调查、修复、测试、复核和发布门禁。 +- 只有问题确实复杂且用户同意时,才临时开启专项任务。 +- 用户明确说“先记录”“不要修改”“等问题多了一起处理”时,只登记和调查,不实施产品代码修改。 + +## 状态 + +允许状态:新反馈、待复现、待确认、待处理、处理中、待验收、已完成、已归档。 + +固定流程:收集问题 -> 复现 -> 用户确认批次 -> 实现 -> 回归 -> CI -> 全部镜像完成 -> Release -> 通知更新 -> 线上验收。 + +发布门禁:CI 未通过不得进入 Release;全部镜像未完成不得通知更新。 + +## 模块职责 + +| 分类 | 责任 | +| --- | --- | +| 项目总览 | 统一编号、状态流转、批次确认、冲突协调、发布门禁 | +| 问题中心 | 问题收集、复现证据、影响面、严重度、完整历史 | +| 前端 | Web 页面、交互、批量操作、权限可见性、前端构建 | +| 后端 | API、鉴权、数据库、业务校验、后台任务 | +| 邮件核心 | IMAP/SMTP、Postfix、Dovecot、Rspamd、Maildir、投递与同步 | +| 部署运维 | install.sh、Docker、Compose、Nginx、CI/CD、服务器调查 | +| 质量复核 | 独立复核、回归测试、风险确认、验收建议 | +| 文档发布 | 版本说明、部署说明、Release 文案、更新通知 | + +## 问题列表 + +| 编号 | 日期 | 状态 | 模块 | 现象 | 严重度 | 目标版本 | 发布状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| NSX-20260804-001 | 2026-08-04 | 已完成 | 前端主责;后端、邮件核心协作 | 全部邮箱批量移动到自定义文件夹失败 | S2 | v1.2.14 | 随 v1.2.14 发布 | +| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 | +| NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 | + +## NSX-20260804-001 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260804-001 | +| 日期 | 2026-08-04 | +| 状态 | 已完成 | +| 模块 | 前端主责;后端、邮件核心协作;质量复核待后续介入 | +| 现象 | 全部邮箱批量移动到自定义文件夹失败 | +| 截图 | 待用户补充 | +| 复现步骤 | 待补充。已知描述:选择全部邮箱后,批量移动到自定义文件夹失败。 | +| 影响 | 影响批量整理邮件,用户可能只能逐封或分批移动;是否造成数据错位或丢失待复现确认。 | +| 严重度 | S2 | +| 根因 | 全部邮箱视图中批量移动沿用单邮箱移动路径,目标自定义文件夹没有按消息所属邮箱分别解析/创建,跨邮箱消息会出现 `failed to move message`。 | +| 关联提交 | 未提交;关联工作区文件:`apps/api/internal/app/mail_handlers.go`、`apps/web/src/pages/mail.tsx`、`apps/web/src/lib/api.ts`、`apps/api/internal/app/app_test.go`。 | +| 目标版本 | v1.2.14 | +| 测试结果 | 已完成 Go 全量测试、go vet、前端 check/build、安装脚本测试和 ShellCheck;批量移动回归测试覆盖跨邮箱、自定义文件夹及部分失败结果。 | +| 发布状态 | 随 v1.2.14 发布。 | + +### 分派 + +| 分类 | 处理方式 | +| --- | --- | +| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 | +| 问题中心 | 已补充根因和实现证据;等待完整回归和用户线上验收。 | +| 前端 | 已接入批量移动接口并展示中文成功/失败明细。 | +| 后端 | 已实现批量移动接口,按消息所属邮箱隔离处理目标文件夹和权限。 | +| 邮件核心 | 已保持消息所属邮箱和文件夹映射隔离,完整运行回归待主任务执行。 | +| 部署运维 | 暂无服务器动作;涉及线上日志或容器状态时再介入。 | +| 质量复核 | 修复后独立复核批量移动、部分失败、权限边界、回归风险。 | +| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-04 | 用户指定切换为精简单任务模式,并要求“全部邮箱批量移动到自定义文件夹失败”继续只调查不修改,等待汇总更多问题。 | +| 2026-08-04 | 项目总控建立唯一问题编号 NSX-20260804-001,状态设为待复现。 | +| 2026-08-05 | 用户确认本批继续实施;已实现可靠批量移动接口并接入前端批量移动入口,状态流转为待验收。 | + +## NSX-20260804-002 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260804-002 | +| 日期 | 2026-08-04 | +| 状态 | 已完成 | +| 模块 | 前端/UI/响应式布局;质量复核待后续介入 | +| 现象 | 左侧栏选择具体邮箱账号后,顶部邮箱选择器和展开菜单比“全部邮箱”状态明显变窄,邮箱地址显示为 `crrhuawei@new...`,下拉项也被过早截断。右侧复制按钮占位后,选择器宽度发生变化。 | +| 截图 | `/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-fadc6a64-ce13-4a5e-ae8f-5de7e7756e62.png` | +| 复现步骤 | 1. 打开邮箱左侧栏。 2. 从“全部邮箱”切换到具体邮箱账号。 3. 展开顶部邮箱选择器。 4. 观察选择器宽度、展开菜单宽度、邮箱地址截断和复制按钮出现后的布局变化。 | +| 影响 | 影响账号切换区域的可读性和稳定性;邮箱地址过早截断,复制按钮出现时造成布局跳变。 | +| 严重度 | S3 | +| 根因 | 邮箱选择器右侧复制按钮只在具体邮箱状态占位,触发区域宽度随状态变化;下拉菜单宽度跟随被挤压后的触发区域,导致邮箱地址过早截断。 | +| 关联提交 | 未提交;关联工作区文件:`apps/web/src/pages/mail.tsx`。 | +| 目标版本 | v1.2.14 | +| 测试结果 | 前端 check/build 通过;桌面浏览器验证“全部邮箱”和具体邮箱状态均保留复制按钮固定槽位,具体邮箱触发区宽 225px;390×844 移动端无横向溢出。 | +| 发布状态 | 随 v1.2.14 发布。 | + +### 期望 + +邮箱选择器在“全部邮箱”和具体邮箱两种状态下保持原来的稳定长度;展开菜单宽度与选择区域协调,尽量完整显示邮箱地址;复制按钮出现时不能挤压选择器或造成布局跳变。 + +### 关联 + +与此前“全部邮箱选择器长度、复制按钮出现后布局变化”反馈关联;本条作为可复现回归继续保留,不合并归档。 + +### 分派 + +| 分类 | 处理方式 | +| --- | --- | +| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 | +| 问题中心 | 保留截图路径、现象、期望和关联背景;已补充根因和实现证据。 | +| 前端 | 已固定复制按钮占位和下拉菜单宽度,等待桌面/移动验收。 | +| 后端 | 暂无介入需求。 | +| 邮件核心 | 暂无介入需求。 | +| 部署运维 | 暂无介入需求。 | +| 质量复核 | 修复后复核“全部邮箱”和具体邮箱两种状态、展开菜单、长邮箱地址、复制按钮出现前后布局稳定性。 | +| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-04 | 用户新增“邮箱选择器展开后宽度变窄”待处理问题,要求只登记,不修改。 | +| 2026-08-04 | 项目总控建立唯一问题编号 NSX-20260804-002,状态设为待处理。 | +| 2026-08-05 | 用户确认本批继续实施;已固定复制按钮占位和下拉菜单宽度,状态流转为待验收。 | + +## NSX-20260805-003 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260805-003 | +| 日期 | 2026-08-05 | +| 状态 | 已完成 | +| 模块 | 前端/UI/布局稳定性;质量复核待后续介入 | +| 现象 | 邮箱页面左侧栏明显更宽,设置页面侧栏变窄,垂直分隔边框与主内容起点改变;点击齿轮切换时产生页面左右跳动和“页面变来变去”的感觉。 | +| 截图 | 邮箱页:`/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-e293b3f1-7797-49ec-b5f9-b7de08ba4c6c.png`;设置页:`/var/folders/wg/mx262rx107b8j614sq2mnmy00000gn/T/codex-clipboard-89b85640-d262-4b1c-8996-cd097036614e.png` | +| 复现步骤 | 1. 打开邮箱页面。 2. 观察左侧栏宽度、垂直分隔线、顶部栏高度和主内容起点。 3. 点击齿轮进入设置页面。 4. 对比设置页侧栏宽度、分隔线位置和主内容起点。 5. 返回邮箱页,观察页面是否左右跳动。 | +| 影响 | 影响应用壳层稳定性和页面切换体验;邮箱页与设置页布局不一致会造成明显跳动,并可能影响邮箱选择器可用宽度。 | +| 严重度 | S3 | +| 根因 | 邮箱页和设置页分别使用独立侧栏宽度,设置页硬编码宽度小于邮箱页,导致切换时分隔线与主内容起点变化。 | +| 关联提交 | 未提交;关联工作区文件:`apps/web/src/index.css`、`apps/web/src/pages/profile.tsx`、`apps/web/src/pages/mail.tsx`。 | +| 目标版本 | v1.2.14 | +| 测试结果 | 前端 check/build 通过;桌面浏览器实测邮箱页与设置页侧栏均为 288px,页面宽 1440px 且无横向溢出;390×844 移动端邮箱和设置页面均无横向溢出、遮挡或控制台错误。 | +| 发布状态 | 随 v1.2.14 发布。 | + +### 期望 + +邮箱和设置页面使用统一的应用壳层尺寸;侧栏宽度、垂直分隔线、顶部栏高度和主内容起点保持一致,只替换内部导航内容。后续方案需核对桌面与移动端,不允许使用会挤压邮箱选择器的临时宽度。 + +### 批次关联 + +纳入即将给用户确认的前端/UI/布局稳定性候选批次。与 NSX-20260804-002 均涉及邮箱页左侧区域布局稳定性,但本条关注跨页面应用壳层尺寸一致性,保持独立编号。 + +### 分派 + +| 分类 | 处理方式 | +| --- | --- | +| 项目总览 | 已纳入本批实施,当前状态待验收,未进入发布。 | +| 问题中心 | 保留两张截图路径、现象、期望和验收注意事项;已补充根因和实现证据。 | +| 前端 | 已统一邮箱页与设置页应用壳层侧栏宽度变量,等待桌面/移动验收。 | +| 后端 | 暂无介入需求。 | +| 邮件核心 | 暂无介入需求。 | +| 部署运维 | 暂无介入需求。 | +| 质量复核 | 修复后独立复核桌面与移动端页面切换稳定性,确认不会挤压邮箱选择器或造成新的布局跳变。 | +| 文档发布 | 进入版本批次后整理发布说明;全部镜像完成后才可通知更新。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-05 | 用户新增“邮箱页与设置页侧栏宽度/边框位置不一致”待处理问题,要求只登记并纳入即将确认的批次方案,现在不要修改。 | +| 2026-08-05 | 项目总控建立唯一问题编号 NSX-20260805-003,状态设为待处理。 | +| 2026-08-05 | 用户确认本批继续实施;已统一应用壳层侧栏宽度变量,状态流转为待验收。 | diff --git a/install.sh b/install.sh index f62c177..c7e9b95 100755 --- a/install.sh +++ b/install.sh @@ -37,6 +37,7 @@ NewSzxcn Email 管理命令 guide 显示并更新 NewSzxcn 邮箱指南 credentials 查看管理员登录信息和记录密码 reset-password 重置管理员统一登录密码(含名下邮箱) + reset-2fa 应急关闭唯一管理员双因素认证 uninstall 停止并移除容器,保留邮件与配置 EOF } @@ -266,6 +267,10 @@ prompt_text() { fi } +lowercase() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + valid_hostname() { local hostname="$1" label tld local -a labels @@ -279,6 +284,108 @@ valid_hostname() { [[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]] } +valid_mail_local_part() { + local value="$1" + [[ ${#value} -ge 1 && ${#value} -le 64 && "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]*$ ]] +} + +valid_email_address() { + local value="$1" local_part domain_part + [[ "${value}" == *@* ]] || return 1 + local_part="${value%@*}" + domain_part="${value#*@}" + valid_mail_local_part "${local_part}" && valid_hostname "${domain_part}" +} + +suggest_mail_domain() { + local hostname="$1" first rest + hostname="$(lowercase "${hostname}")" + first="${hostname%%.*}" + rest="${hostname#*.}" + if [[ "${hostname}" == *.* && "${rest}" == *.* && "${first}" =~ ^(mail|smtp|imap|pop|pop3|mx|mx[0-9]+|webmail)$ ]]; then + printf '%s' "${rest}" + return + fi + printf '%s' "${hostname}" +} + +prompt_mail_domain() { + local hostname="$1" suggestion value admin_email + admin_email="${LANQIN_ADMIN_EMAIL:-}" + if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -n "${admin_email}" && "${admin_email}" == *@* ]]; then + LANQIN_MAIL_DOMAIN="${admin_email#*@}" + fi + suggestion="$(suggest_mail_domain "${hostname}")" + value="${LANQIN_MAIL_DOMAIN:-}" + if [[ -z "${value}" ]] && has_tty; then + prompt_text "[提示] 邮件服务器域名是 ${hostname};邮箱地址域名可以使用 ${suggestion},请确认。\n" + read -r -p "邮箱地址域名 [${suggestion}]: " value /dev/null 2>&1); then + fail_test "administrator email outside mail domain accepted" + fi + if (has_tty() { return 1; }; unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL; prompt_mail_domain "mail.example.com" >/dev/null 2>&1); then + fail_test "noninteractive mail domain guessed without explicit input" + fi + unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL +} + test_install_configuration() { local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5" local temp_dir @@ -52,7 +68,8 @@ test_install_configuration() { export INSTALL_DIR="${temp_dir}" export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}" export LANQIN_PUBLIC_HOSTNAME="mail.example.com" - export LANQIN_ADMIN_USERNAME="admin" + export LANQIN_MAIL_DOMAIN="example.com" + export LANQIN_ADMIN_EMAIL="admin@example.com" export LANQIN_ADMIN_PASSWORD="abc123" export LANQIN_INSTALL_WEB_MODE="${web_mode}" configure_first_install @@ -63,6 +80,9 @@ test_install_configuration() { assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind" assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL" assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag" + assert_eq "example.com" "$(env_value LANQIN_MAIL_DOMAIN)" "mail address domain" + assert_eq "admin@example.com" "$(env_value LANQIN_ADMIN_EMAIL)" "administrator email" + assert_eq "admin" "$(env_value LANQIN_ADMIN_USERNAME)" "legacy administrator username prefix" assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password" } @@ -134,12 +154,13 @@ test_admin_credentials() ( mkdir -p "${INSTALL_DIR}" cat > "${INSTALL_DIR}/.env" <<'EOF' LANQIN_PUBLIC_BASE_URL=https://mail.example.com -LANQIN_ADMIN_USERNAME=admin +LANQIN_MAIL_DOMAIN=example.com +LANQIN_ADMIN_EMAIL=admin@example.com LANQIN_ADMIN_PASSWORD=recorded-password EOF output="$(do_show_admin_credentials 2>&1)" [[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing" - [[ "${output}" == *'管理员用户名:admin'* ]] || fail_test "administrator username missing" + [[ "${output}" == *'管理员邮箱:admin@example.com'* ]] || fail_test "administrator email missing" [[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing" [[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing" ) @@ -160,7 +181,9 @@ test_admin_password_reset_only_updates_admin_account() ( compose_calls="${temp_dir}/compose-calls" mkdir -p "${INSTALL_DIR}/data/backups" cat > "${INSTALL_DIR}/.env" <<'EOF' -LANQIN_ADMIN_USERNAME=admin +LANQIN_PUBLIC_HOSTNAME=mail.example.com +LANQIN_MAIL_DOMAIN=example.com +LANQIN_ADMIN_EMAIL=admin@example.com LANQIN_ADMIN_PASSWORD=old-password EOF printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" @@ -186,12 +209,48 @@ EOF do_reset_admin_password >/dev/null assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password" [[ -s "${backup_path}" ]] || fail_test "password reset database backup missing" - grep -Fq "login_name='admin' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not role restricted" + grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not email and role restricted" grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated" grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized" grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator" ) +test_admin_two_factor_reset_only_updates_admin_account() ( + local temp_dir compose_calls backup_path + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + compose_calls="${temp_dir}/compose-calls" + mkdir -p "${INSTALL_DIR}/data/backups" + cat > "${INSTALL_DIR}/.env" <<'EOF' +LANQIN_PUBLIC_HOSTNAME=mail.example.com +LANQIN_MAIL_DOMAIN=example.com +LANQIN_ADMIN_EMAIL=admin@example.com +EOF + printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" + + ensure_docker() { return 0; } + current_image_id() { printf 'sha256:test-image\n'; } + backup_database() { + backup_path="$1" + printf 'backup\n' > "${backup_path}" + } + compose() { + printf '%s\n' "$*" >> "${compose_calls}" + if [[ "$*" == *'SELECT id FROM users'* ]]; then + printf 'admin-user-id\n' + elif [[ "$*" == *"UPDATE users SET two_factor_secret=''"* ]]; then + printf 'user=1\nrecovery=2\nchallenges=1\n' + fi + } + + do_reset_admin_two_factor >/dev/null + [[ -s "${backup_path}" ]] || fail_test "2FA reset database backup missing" + grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "2FA administrator lookup is not email and role restricted" + grep -Fq "UPDATE users SET two_factor_secret=''" "${compose_calls}" || fail_test "administrator 2FA flag was not cleared" + grep -Fq "DELETE FROM two_factor_recovery_codes WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator recovery codes were not deleted" + grep -Fq "DELETE FROM login_challenges WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator login challenges were not deleted" +) + test_offline_database_backup() ( local temp_dir destination temp_dir="$(mktemp -d)" @@ -215,10 +274,12 @@ test_guide_generation() ( cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env" set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com" set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com" - set_env LANQIN_ADMIN_USERNAME "admin" + set_env LANQIN_MAIL_DOMAIN "example.com" + set_env LANQIN_ADMIN_EMAIL "admin@example.com" generate_guide grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing" grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing" + grep -Fq '管理员邮箱:admin@example.com' "${GUIDE_FILE}" || fail_test "guide administrator email missing" grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing" [[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600" ) @@ -503,6 +564,7 @@ test_backup_reinstall_recovers_from_nginx_reload_failure() ( test_hostname_validation test_password_validation +test_mail_domain_and_admin_email_validation test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false" test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false" test_nginx_configuration @@ -512,6 +574,7 @@ test_menu_choice test_admin_credentials test_admin_password_hash_parsing test_admin_password_reset_only_updates_admin_account +test_admin_two_factor_reset_only_updates_admin_account test_offline_database_backup test_guide_generation test_acme_cron_detection