feat: unify email identity and administrator security
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-05 02:55:36 +08:00
parent a11e1cd2f1
commit 86773c64ca
32 changed files with 1816 additions and 402 deletions
+35
View File
@@ -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)
+4 -4
View File
@@ -16,7 +16,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | | Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | | 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | | 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 | | 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | | 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | | 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | | 运维 | 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) bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
``` ```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员 脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会 `/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。 校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
@@ -44,7 +44,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
- 安装或检查 Docker Engine 与 Docker Compose v2 - 安装或检查 Docker Engine 与 Docker Compose v2
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 - 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录 - 创建 `/opt/newszxcn-email` 持久化目录
+3 -3
View File
@@ -11,7 +11,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | | Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | | 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | | 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
| 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 | | 管理后台 | 账号、权限配、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | | 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | | 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | | 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
@@ -33,8 +33,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
脚本会自动完成: 脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2 - 安装或检查 Docker Engine 与 Docker Compose v2
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置;不会清空已有防火墙规则
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 - 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录 - 创建 `/opt/newszxcn-email` 持久化目录
+1 -1
View File
@@ -1 +1 @@
1.2.13 1.2.14
+50 -31
View File
@@ -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), '') 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 FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users") respondError(w, http.StatusInternalServerError, "failed to list users")
return return
@@ -108,20 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
actor := currentUser(r) actor := currentUser(r)
var loginName string emailInput := req.Email
var err error if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
if strings.TrimSpace(req.LoginName) != "" { emailInput = req.LoginName
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
} }
primaryEmail, err := cleanPrimaryEmail(emailInput)
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
displayName := strings.TrimSpace(req.DisplayName) displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" { if displayName == "" {
displayName = loginName badRequest(w, errors.New("displayName is required"))
return
} }
role := strings.TrimSpace(req.Role) role := strings.TrimSpace(req.Role)
if role == "" { if role == "" {
@@ -131,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role")) badRequest(w, errors.New("invalid role"))
return return
} }
if role == "admin" && (actor == nil || actor.Role != "admin") { if role == "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users") respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return return
} }
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride) mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -161,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at) if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,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) badRequest(w, err)
return return
} }
@@ -190,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
current := currentUser(r) current := currentUser(r)
var req struct { var req struct {
LoginName string `json:"loginName"` LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
Disabled *bool `json:"disabled"` 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") respondError(w, http.StatusNotFound, "user not found")
return 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 loginName := existing.LoginName
if requestedLoginName != "" { if strings.TrimSpace(emailInput) != "" {
loginName, err = cleanUsername(requestedLoginName) primaryEmail, err = cleanPrimaryEmail(emailInput)
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return 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") respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return 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")) badRequest(w, errors.New("default administrator must remain an active super administrator"))
return return
} }
if existing.Role == "admin" && disabled {
badRequest(w, errors.New("唯一管理员不能停用"))
return
}
mailboxLimitOverride := existing.MailboxLimitOverride mailboxLimitOverride := existing.MailboxLimitOverride
if req.MailboxLimitOverride != nil { if req.MailboxLimitOverride != nil {
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride) mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -294,14 +311,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() 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=?`, 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") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("登录已被使用")) badRequest(w, errors.New("登录邮箱已被使用"))
return return
} }
respondError(w, http.StatusInternalServerError, "failed to update user") 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") respondError(w, http.StatusInternalServerError, "failed to update user")
return return
} }
if existing.Role == "admin" {
a.updateConfig(func(cfg *Config) {
cfg.AdminEmail = primaryEmail
})
}
user, err := a.adminUserByID(r.Context(), id) user, err := a.adminUserByID(r.Context(), id)
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "user not found") respondError(w, http.StatusNotFound, "user not found")
@@ -575,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
if role == "admin" { if role == "admin" {
current := currentUser(r) respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
if current == nil || current.Role != "admin" { return
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
} }
domain, err := a.domainByID(r.Context(), req.DomainID) domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -617,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
} else { } 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 { if err != nil {
badRequest(w, err) badRequest(w, err)
return 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) { if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -631,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
} }
userID = newID("usr") userID = newID("usr")
ownerDisplayName := displayName ownerDisplayName := displayName
if !strings.EqualFold(ownerLoginName, address) { if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerLoginName ownerDisplayName = ownerEmail
} }
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) _, 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 { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
+248 -104
View File
@@ -33,6 +33,7 @@ type App struct {
workerWG sync.WaitGroup workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory externalIMAP externalIMAPClientFactory
turnstileURL string
} }
func (a *App) config() Config { func (a *App) config() Config {
@@ -92,6 +93,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close() db.Close()
return nil, err return nil, err
} }
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
db.Close()
return nil, err
}
workerCtx, cancel := context.WithCancel(context.Background()) workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) }) a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
@@ -185,6 +190,14 @@ func (a *App) migrate(ctx context.Context) error {
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
created_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 ( `CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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 return err
} }
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed() // migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
// that implicitly created an admin mailbox with display_name "LanQin Admin". // Administrators now use a real mailbox as their primary login address, so old
// Current seed() creates mailboxes with display_name = admin email, so this migration // bootstrap mailboxes must be preserved and normalized by the admin identity
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema. // migration instead of deleted.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error { 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 return nil
} }
@@ -1412,7 +1355,11 @@ func (a *App) seed(ctx context.Context) error {
return err return err
} }
if count > 0 { 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 adminPassword := cfg.AdminPassword
@@ -1430,25 +1377,8 @@ func (a *App) seed(ctx context.Context) error {
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
if 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) 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 return err
} }
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1483,18 +1413,221 @@ func (a *App) seed(ctx context.Context) error {
} }
func (a *App) ensureConfiguredAdminSuperAdmin(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() cfg := a.config()
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") { type adminUser struct {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`, ID string `json:"id"`
a.now().UTC().Format(time.RFC3339Nano), adminUsername) 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 return err
} }
adminEmail := normalizeEmail(cfg.AdminEmail) admins := []adminUser{}
if adminEmail == "" || !strings.Contains(adminEmail, "@") { for rows.Next() {
return nil 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=?`, if err := rows.Err(); err != nil {
a.now().UTC().Format(time.RFC3339Nano), adminEmail) 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 return err
} }
@@ -1616,13 +1749,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
return err return err
} }
now := a.now().UTC() 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 邮箱" subject := "欢迎使用 NewSzxcn 邮箱"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。" bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>" bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil { if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{ rendered := renderMailTemplate(tpl, templateRenderData{
To: cfg.AdminEmail, To: cfg.AdminEmail,
From: "system@lanqin.local", From: systemAddress,
PublicHostname: cfg.PublicHostname, PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
Time: now, Time: now,
@@ -1633,9 +1777,9 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
MailboxID: mailboxID, MailboxID: mailboxID,
FolderID: folderID, FolderID: folderID,
MessageUID: newID("uid"), MessageUID: newID("uid"),
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")), MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
Subject: subject, Subject: subject,
From: "system@lanqin.local", From: systemAddress,
FromName: "NewSzxcn 邮箱", FromName: "NewSzxcn 邮箱",
To: []string{cfg.AdminEmail}, To: []string{cfg.AdminEmail},
SentAt: now, SentAt: now,
+587 -38
View File
@@ -413,6 +413,20 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
return group 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 { func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{ return map[string]any{
"publicHostname": settings.PublicHostname, "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 { 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) 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) domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil) 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) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
@@ -1345,32 +1359,92 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
} }
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true }) 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 { var registered struct {
User User `json:"user"` User User `json:"user"`
} }
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &registered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" { if code := client.do("POST", "/api/auth/register", registration, &registered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User) t.Fatalf("register code=%d user=%+v", code, registered.User)
} }
var me struct { var me struct {
User User `json:"user"` 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) t.Fatalf("me code=%d user=%+v", code, me.User)
} }
var mine struct { var mine struct {
Items []Mailbox `json:"items"` 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) t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
} }
another := &testClient{t: t, server: ts} 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) 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, &registered); 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() dir := t.TempDir()
cfg := Config{ cfg := Config{
Addr: ":0", 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 { 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) 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 { if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy mailbox count=%d err=%v", count, err) 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 { if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy domain count=%d err=%v", count, err) t.Fatalf("admin domain count=%d err=%v", count, err)
} }
} }
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { func TestConfiguredAdminEmailCreatesMailboxAndRejectsUsernameLogin(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfg := Config{ cfg := Config{
Addr: ":0", Addr: ":0",
@@ -1430,6 +1504,7 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
CookieName: "lanqin_test", CookieName: "lanqin_test",
SessionTTLHours: 24, SessionTTLHours: 24,
AdminUsername: "admin", AdminUsername: "admin",
AdminEmail: "root@example.test",
AdminPassword: "ChangeMe123!", AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test", PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173", 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 { if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if domains != 0 || mailboxes != 0 { if domains != 1 || mailboxes != 1 {
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes) 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()) ts := httptest.NewServer(a.Router())
@@ -1454,33 +1536,339 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
var login struct { var login struct {
User User `json:"user"` User User `json:"user"`
} }
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK { if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("username login code=%d", code) 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{ if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "rootadmin", "email": "root@example.test",
"displayName": "Administrator", "displayName": "Administrator",
"role": "admin", "role": "admin",
"disabled": false, "disabled": false,
}, nil); code != http.StatusOK { }, 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{ if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "root@example.test", "email": "not-an-email",
"displayName": "Administrator", "displayName": "Administrator",
"role": "admin", "role": "admin",
"disabled": false, "disabled": false,
}, nil); code != http.StatusBadRequest { }, 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} restarted, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized { if err != nil {
t.Fatalf("old username login code=%d", code) t.Fatal(err)
} }
newLogin := &testClient{t: t, server: ts} t.Cleanup(func() { _ = restarted.Close() })
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK { var email, loginName string
t.Fatalf("renamed username login code=%d", code) 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 := defaultPermissionLimits()
limits.MaxMailboxCount = 1 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 { 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) 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) { func TestCustomMailFoldersCreateAndMove(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) 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 { if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("regular send audit code=%d", code) 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 { if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("admin access without messages permission code=%d", code) t.Fatalf("admin access without messages permission code=%d", code)
} }
@@ -4168,11 +4688,15 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var enabled struct { 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 { 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) 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} fresh := &testClient{t: t, server: ts}
var challenge struct { 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 { 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) 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()) code, err = generateTOTP(setup.Secret, a.now().UTC())
if err != nil { if err != nil {
t.Fatal(err) 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 { 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, fresh.cookie) 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) t.Fatalf("disable status=%d user=%+v", status, enabled.User)
} }
} }
@@ -4275,9 +4813,20 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden { }, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody) t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
} }
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview}) var regularUpdateErr map[string]any
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) { if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup) "name": "Changed Regular",
"description": "Should not change",
"permissions": []string{PermissionAdminOverview},
}, &regularUpdateErr); code != http.StatusForbidden {
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
}
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 { if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody) t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -4356,7 +4905,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &plainUser); code != http.StatusCreated { }, &plainUser); code != http.StatusCreated {
t.Fatalf("create plain user code=%d user=%+v", code, plainUser) 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) 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) 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} 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 { 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) 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) 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} 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 { 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) t.Fatalf("no send login code=%d", code)
+68 -45
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"database/sql"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) { if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "验证码错误") ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
return if consumeErr != nil || !ok {
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
return
}
} }
a.deleteLoginChallenge(r.Context(), challenge.ID) a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil { 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, "人机验证失败,请重试") respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return return
} }
var loginName string emailInput := req.Email
var err error if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
if strings.TrimSpace(req.LoginName) != "" { emailInput = req.LoginName
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
} }
email, err := cleanPrimaryEmail(emailInput)
if err != nil { if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
} }
user, passwordHash, err := a.userByEmail(r.Context(), loginName) user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled { if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "账号或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
@@ -107,8 +109,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return return
} }
email := normalizeEmail(req.Email) email, err := cleanPrimaryEmail(req.Email)
if email == "" || !strings.Contains(email, "@") { if err != nil {
badRequest(w, errors.New("邮箱地址无效")) badRequest(w, errors.New("邮箱地址无效"))
return return
} }
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
displayName := strings.TrimSpace(req.DisplayName) displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" { if displayName == "" {
displayName = strings.Split(email, "@")[0] badRequest(w, errors.New("请输入显示名称"))
return
} }
if len([]rune(displayName)) > 80 { if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("显示名称不能超过 80 个字符")) badRequest(w, errors.New("显示名称不能超过 80 个字符"))
return 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 { if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "该邮箱已被注册") respondError(w, http.StatusConflict, "该邮箱已被注册")
return return
@@ -138,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
if _, err := a.db.ExecContext(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 { VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册") respondError(w, http.StatusConflict, "该邮箱已被注册")
@@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return return
} }
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if 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) user, err := a.userByID(r.Context(), userID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user") 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, "登录失败,请稍后重试") respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
return 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}) 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) { func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
return
}
var req struct { var req struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
} }
+3 -1
View File
@@ -16,6 +16,7 @@ type Config struct {
SessionTTLHours int SessionTTLHours int
AdminUsername string AdminUsername string
AdminEmail string AdminEmail string
MailDomain string
AdminPassword string AdminPassword string
PublicHostname string PublicHostname string
PublicBaseURL string PublicBaseURL string
@@ -72,7 +73,8 @@ func LoadConfig() Config {
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")), 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", ""), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
+68
View File
@@ -2156,6 +2156,74 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) 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) { func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role, row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
+1 -1
View File
@@ -809,7 +809,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
return "", errors.New("invalid owner email") return "", errors.New("invalid owner email")
} }
var existing string 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 { if err == nil {
return existing, nil return existing, nil
} }
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusNotFound, "permission group not found") respondError(w, http.StatusNotFound, "permission group not found")
return return
} }
if id == PermissionGroupSuperAdmin { if intBool(existingSystem) {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited") respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return return
} }
var req struct { var req struct {
+8 -15
View File
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"}, {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"}, {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"}, {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配。", Category: "账号管理"}, {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"}, {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"}, {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限配", Description: "查看权限配、权限目录和使用人数。", Category: "权限配"}, {Key: PermissionGroupsView, Label: "查看权限配", Description: "查看内置和自定义权限配、权限目录和使用人数。", Category: "权限配"},
{Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"}, {Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"}, {Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"},
{Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"}, {Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"}, {Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"}, {Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
{ {
ID: PermissionGroupSuperAdmin, ID: PermissionGroupSuperAdmin,
Name: "管理员", Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过权限配分配。", Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配分配。",
Permissions: allPermissionKeys(), Permissions: allPermissionKeys(),
Limits: PermissionLimits{}, Limits: PermissionLimits{},
System: true, System: true,
@@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
} }
func (a *App) isDefaultAdminUser(u *User) bool { func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil { return u != nil && u.Role == "admin"
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)
} }
func sortPermissionGroups(items []PermissionGroup) { func sortPermissionGroups(items []PermissionGroup) {
+3 -3
View File
@@ -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(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)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel) 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)).Post("/mail/messages/{id}/move", a.handleMove)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage) r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment) 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) { 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 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=? FROM users WHERE email=? LIMIT 1`, email)
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
var u User var u User
var passwordHash string var passwordHash string
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
+5 -1
View File
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
if ip := normalizeRemoteIP(remoteIP); ip != "" { if ip := normalizeRemoteIP(remoteIP); ip != "" {
form.Set("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 { if err != nil {
return err return err
} }
+102 -3
View File
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil 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 { func totpProvisioningURI(issuer, account, secret string) string {
issuer = strings.TrimSpace(issuer) issuer = strings.TrimSpace(issuer)
account = strings.TrimSpace(account) 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) _, _ = 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) { 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) 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 var u User
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code") respondError(w, http.StatusUnauthorized, "invalid verification code")
return 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") respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return return
} }
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load user") respondError(w, http.StatusInternalServerError, "failed to load user")
return 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) { 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") respondError(w, http.StatusUnauthorized, "invalid verification code")
return 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") respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return return
} }
+15
View File
@@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) {
return username, nil 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 { func dedupeEmails(items []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(items)) out := make([]string, 0, len(items))
+2 -2
View File
@@ -30,7 +30,7 @@ import {
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] }, { key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] }, { key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限配", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] }, { key: "permissionGroups", label: "权限配", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] }, { key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] }, { key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] }, { key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
@@ -108,7 +108,7 @@ function ProtectedContent() {
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.displayName}</span> <span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div> </div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]"> <Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"} {user.role === "admin" ? "管理员" : "普通用户"}
+2 -1
View File
@@ -24,6 +24,7 @@
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%; --ring: 216 22% 42%;
--radius: 0.5rem; --radius: 0.5rem;
--app-sidebar-width: 18rem;
--sidebar-background: 0 0% 100%; --sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%; --sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%; --sidebar-primary: 224 44% 12%;
@@ -85,7 +86,7 @@
@layer components { @layer components {
.mail-shell-grid { .mail-shell-grid {
display: 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 { .mail-content-grid {
+2
View File
@@ -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 LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: 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 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 }[] }
+6 -5
View File
@@ -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" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 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<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }), 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" }), setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }),
enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }), enableTwoFactor: (code: string) => request<TwoFactorEnableResponse>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }), disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }),
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"), contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/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<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"), defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"), domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -163,7 +163,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }), deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"), mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }), deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"), aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
@@ -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) }), 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" }), 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 }) }), move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
bulkMove: (ids: string[], folder: string) => request<BulkMoveResult>("/api/mail/messages/bulk-move", { method: "POST", body: JSON.stringify({ ids, folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
} }
+107 -54
View File
@@ -33,7 +33,7 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = { const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" }, overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" }, users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
permissionGroups: { label: "权限配", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" }, permissionGroups: { label: "权限配", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" }, domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" }, mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
aliases: { 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 projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9 const defaultMailboxLimitOverride = 9
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
function compareAdminUsers(left: AdminUser, right: AdminUser) {
if (left.role === "admin" && right.role !== "admin") return -1
if (left.role !== "admin" && right.role === "admin") return 1
return accountPrimaryEmail(left).localeCompare(accountPrimaryEmail(right), "en", { sensitivity: "base" }) ||
left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) ||
left.createdAt.localeCompare(right.createdAt)
}
export function AdminPage() { export function AdminPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -245,14 +253,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const canCreate = hasPermission(user, "admin.users.create") const canCreate = hasPermission(user, "admin.users.create")
const canDelete = hasPermission(user, "admin.users.delete") const canDelete = hasPermission(user, "admin.users.delete")
const filteredUsers = users.filter((user) => { const filteredUsers = users
const keyword = query.trim().toLowerCase() .filter((user) => {
const loginName = accountLoginName(user) const keyword = query.trim().toLowerCase()
const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) const primaryEmail = accountPrimaryEmail(user)
const matchesRole = roleFilter === "all" || user.role === roleFilter const matchesKeyword = !keyword || [primaryEmail, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) const matchesRole = roleFilter === "all" || user.role === roleFilter
return matchesKeyword && matchesRole && matchesStatus 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 }) }) const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
@@ -291,9 +301,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium">{user.displayName}</div> <div className="truncate font-medium">{user.displayName}</div>
<div className="truncate text-xs text-muted-foreground">{accountLoginName(user)}</div> <div className="truncate text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
</div> </div>
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /> <UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<RoleBadge user={user} /> <RoleBadge user={user} />
@@ -307,20 +317,20 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-[22rem]"></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> <TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-[22rem]"></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody> <TableBody>
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
<div className="font-medium">{user.displayName}</div> <div className="font-medium">{user.displayName}</div>
<div className="text-xs text-muted-foreground">{accountLoginName(user)}</div> <div className="text-xs text-muted-foreground">{accountPrimaryEmail(user)}</div>
</TableCell> </TableCell>
<TableCell><RoleBadge user={user} /></TableCell> <TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell> <TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell> <TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell> <TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell> <TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell> <TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -349,7 +359,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
onSuccess: () => { onSuccess: () => {
setPendingConfirm(null) setPendingConfirm(null)
invalidateAdmin(qc) invalidateAdmin(qc)
toast({ title: "权限配已删除" }) toast({ title: "权限配已删除" })
}, },
onError: (e) => toast({ title: "删除失败", description: e.message }), onError: (e) => toast({ title: "删除失败", description: e.message }),
}) })
@@ -358,20 +368,20 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
if (!keyword) return true if (!keyword) return true
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword)) 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 const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
{canCreate && <PermissionGroupDialog catalog={catalog} />} {canCreate && <PermissionGroupDialog catalog={catalog} />}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配、说明或权限键" className="pl-9" /> <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配、说明或权限键" className="pl-9" />
</div> </div>
<div className="grid gap-3 lg:grid-cols-2"> <div className="grid gap-3 lg:grid-cols-2">
{filtered.map((group) => ( {filtered.map((group) => (
@@ -389,14 +399,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{(canUpdate || canDelete) && <DropdownMenu> {(canUpdate || canDelete) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger> <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem> <DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
disabled={!isDeletable(group) || !canDelete} disabled={!isDeletable(group) || !canDelete}
onSelect={() => setPendingConfirm({ title: "删除权限配", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配", onConfirm: () => remove.mutate(group.id) })} onSelect={() => setPendingConfirm({ title: "删除权限配", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配", onConfirm: () => remove.mutate(group.id) })}
> >
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu>} </DropdownMenu>}
@@ -406,7 +416,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
</div> </div>
))} ))}
</div> </div>
{filtered.length === 0 && <Empty text="暂无匹配的权限配" />} {filtered.length === 0 && <Empty text="暂无匹配的权限配" />}
</CardContent> </CardContent>
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />} {editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> <ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -443,20 +453,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
onSuccess: () => { onSuccess: () => {
invalidateAdmin(qc) invalidateAdmin(qc)
setDialogOpen(false) setDialogOpen(false)
toast({ title: group ? "权限配已更新" : "权限配已创建" }) toast({ title: group ? "权限配已更新" : "权限配已创建" })
}, },
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }), onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
}) })
const trigger = group ? null : ( const trigger = group ? null : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4" /></Button> <Button size="sm"><Plus className="h-4 w-4" /></Button>
</DialogTrigger> </DialogTrigger>
) )
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger} {trigger}
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl"> <DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group ? "编辑权限配" : "创建权限配"}</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>{group ? "编辑权限配" : "创建权限配"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}> <form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" /> <Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
@@ -1675,7 +1685,7 @@ function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQ
function UserMailboxCell({ user }: { user: AdminUser }) { function UserMailboxCell({ user }: { user: AdminUser }) {
const { toast } = useToast() const { toast } = useToast()
const loginAddress = accountLoginName(user) const loginAddress = accountPrimaryEmail(user)
const mailboxes = user.mailboxes || [] const mailboxes = user.mailboxes || []
const [mailboxQuery, setMailboxQuery] = React.useState("") const [mailboxQuery, setMailboxQuery] = React.useState("")
const normalizedQuery = mailboxQuery.trim().toLowerCase() const normalizedQuery = mailboxQuery.trim().toLowerCase()
@@ -1776,7 +1786,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label></Label>
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-2 md:grid-cols-2">
{groups.map((group) => { {groups.map((group) => {
const checked = value.includes(group.id) const checked = value.includes(group.id)
@@ -1791,7 +1801,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
) )
})} })}
</div> </div>
{groups.length === 0 && <Empty text="暂无可分配权限配" />} {groups.length === 0 && <Empty text="暂无可分配自定义权限配" />}
</div> </div>
) )
} }
@@ -1815,13 +1825,14 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
const canUpdate = hasPermission(currentUser, "admin.users.update") const canUpdate = hasPermission(currentUser, "admin.users.update")
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password") const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
const update = useMutation({ 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: "账号已更新" }) }, onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) },
onError: (e) => toast({ title: "更新失败", description: e.message }), onError: (e) => toast({ title: "更新失败", description: e.message }),
}) })
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) { function quickPatch(patch: Partial<{ disabled: boolean }>) {
const role = patch.role || user.role const role = user.role
update.mutate({ update.mutate({
email: accountPrimaryEmail(user),
displayName: user.displayName, displayName: user.displayName,
role, role,
disabled: patch.disabled ?? user.disabled, disabled: patch.disabled ?? user.disabled,
@@ -1829,27 +1840,24 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
}) })
} }
if (!canUpdate && !canResetPassword && !onDelete) return null if (!canUpdate && !canResetPassword && !onDelete) return null
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</> return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
} }
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) { function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const [role, setRole] = React.useState<"admin" | "user">("user")
const [status, setStatus] = React.useState("active") const [status, setStatus] = React.useState("active")
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
const create = useMutation({ const create = useMutation({
mutationFn: (form: FormData) => api.createUser({ mutationFn: (form: FormData) => api.createUser({
loginName: String(form.get("loginName") || ""), email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""), displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""), password: String(form.get("password") || ""),
role, role: "user",
disabled: status === "disabled", disabled: status === "disabled",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined, mailboxLimitOverride: mailboxLimitFromForm(form),
permissionGroupIds: role === "user" ? permissionGroupIds : [],
}), }),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) }, onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }), onError: (e) => toast({ title: "创建失败", description: e.message }),
}) })
return ( return (
@@ -1858,15 +1866,14 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> <form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="loginName" label="登录" type="text" autoComplete="off" placeholder="admin" /> <Field name="email" label="登录邮箱" type="email" autoComplete="off" placeholder="user@example.com" />
<Field name="displayName" label="显示名称" placeholder="账号名称" /> <Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={6} /> <Field name="password" label="初始密码" type="password" minLength={6} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} /> <SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} /> <SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
</div> </div>
{role === "user" && <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />} <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter> <DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
@@ -1898,7 +1905,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
}, [user, open]) }, [user, open])
const mut = useMutation({ const mut = useMutation({
mutationFn: (form: FormData) => api.updateUser(user.id, { mutationFn: (form: FormData) => api.updateUser(user.id, {
loginName: String(form.get("loginName") || ""), email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""), displayName: String(form.get("displayName") || ""),
role, role,
disabled: disabled === "disabled", disabled: disabled === "disabled",
@@ -1913,11 +1920,11 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> <form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<Field name="loginName" label="登录" defaultValue={accountLoginName(user)} type="text" autoComplete="off" /> <Field name="email" label="登录邮箱" defaultValue={accountPrimaryEmail(user)} type="email" autoComplete="off" />
<Field name="displayName" label="显示名称" defaultValue={user.displayName} /> <Field name="displayName" label="显示名称" defaultValue={user.displayName} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> <SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected} /> <SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
</div> </div>
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />} {role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />} {role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
@@ -1930,7 +1937,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) { 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 }) }) const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
} }
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { 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[] }) { 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("") const qc = useQueryClient()
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) const { toast } = useToast()
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 }) }) const [open, setOpen] = React.useState(false)
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 6 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog> const [domainId, setDomainId] = React.useState("")
const [ownerMode, setOwnerMode] = React.useState("new")
const [userId, setUserId] = React.useState("")
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
if (!userId && users[0]) setUserId(users[0].id)
}, [domains, domainId, users, userId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createMailbox({
domainId,
localPart: String(form.get("localPart")),
displayName: String(form.get("displayName")),
password: String(form.get("password")),
quotaMb: Number(form.get("quotaMb") || 1024),
role: "user",
ownerEmail: String(form.get("ownerEmail") || ""),
userId: ownerMode === "existing" ? userId : "",
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
<Field name="displayName" label="显示名" placeholder="Alice" />
</div>
<SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[["new", "新建/按主登录邮箱匹配账号"], ["existing", "追加到已有账号"]]} />
{ownerMode === "existing" ? (
<SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} />
) : (
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
)}
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
} }
function CreateAliasDialog({ domains }: { domains: Domain[] }) { function CreateAliasDialog({ domains }: { domains: Domain[] }) {
+5 -5
View File
@@ -21,7 +21,7 @@ export function LoginPage() {
const login = useMutation({ const login = useMutation({
mutationFn: (form: FormData) => challengeToken mutationFn: (form: FormData) => challengeToken
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") }) ? 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) => { onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) { if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken) setChallengeToken(data.challengeToken)
@@ -49,8 +49,8 @@ export function LoginPage() {
{!challengeToken ? ( {!challengeToken ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="loginName" className="text-sm font-medium"></Label> <Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="loginName" name="loginName" type="text" autoComplete="username" required className="h-11 text-base" /> <Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label> <Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -59,8 +59,8 @@ export function LoginPage() {
</> </>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label> <Label htmlFor="twoFactorCode" className="text-sm font-medium"></Label>
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-center text-lg tracking-[0.35em]" /> <Input id="twoFactorCode" name="twoFactorCode" autoComplete="one-time-code" minLength={6} required className="h-11 text-center text-lg" />
</div> </div>
)} )}
{!challengeToken && turnstileRequired && ( {!challengeToken && turnstileRequired && (
+12 -8
View File
@@ -737,6 +737,8 @@ export function MailPage() {
async function runConfirmedBulkAction(action: BulkAction, ids: string[]) { async function runConfirmedBulkAction(action: BulkAction, ids: string[]) {
setBulkPending(true) setBulkPending(true)
try { try {
let completionTitle = `已处理 ${ids.length} 封邮件`
let completionDescription: string | undefined
if (action === "read" || action === "unread") { if (action === "read" || action === "unread") {
const read = action === "read" const read = action === "read"
await Promise.all(ids.map((id) => api.markRead(id, 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))) await Promise.all(ids.map((id) => api.delete(id)))
} else { } else {
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam" const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
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) if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([]) setCompactSelectedIds([])
setPendingConfirm(null) setPendingConfirm(null)
await refreshMailData() await refreshMailData()
toast({ title: `已处理 ${ids.length} 封邮件` }) toast({ title: completionTitle, description: completionDescription })
} catch (error) { } catch (error) {
toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" }) toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally { } finally {
@@ -766,11 +770,11 @@ export function MailPage() {
if (ids.length === 0) return if (ids.length === 0) return
setBulkPending(true) setBulkPending(true)
try { 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) if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([]) setCompactSelectedIds([])
await refreshMailData() 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) { } catch (error) {
toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" }) toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally { } finally {
@@ -1219,7 +1223,7 @@ export function MailPage() {
onLanguageChange={setLanguage} onLanguageChange={setLanguage}
onSettings={openSettings} onSettings={openSettings}
/> />
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : showMailboxCopy ? "grid grid-cols-[minmax(0,1fr)_2rem]" : "grid grid-cols-1")}> <div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : "grid grid-cols-[minmax(0,1fr)_2rem]")}>
<MailboxSwitcher <MailboxSwitcher
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
mailboxes={mailboxList.data?.items || []} mailboxes={mailboxList.data?.items || []}
@@ -1229,12 +1233,12 @@ export function MailPage() {
unreadCount={mailboxUnreadCount} unreadCount={mailboxUnreadCount}
onSelect={switchMailbox} onSelect={switchMailbox}
/> />
{!sidebarCollapsed && showMailboxCopy && ( {!sidebarCollapsed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background" className={cn("h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background", !showMailboxCopy && "pointer-events-none invisible")}
onClick={copyCurrentMailbox} onClick={copyCurrentMailbox}
disabled={!selectedMailbox} disabled={!selectedMailbox}
aria-label="复制邮箱地址" aria-label="复制邮箱地址"
@@ -3287,7 +3291,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
align="start" align="start"
className={cn( className={cn(
"max-w-[calc(100vw-32px)] p-1", "max-w-[calc(100vw-32px)] p-1",
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]" collapsed ? "w-[204px]" : "w-[21rem] min-w-[var(--radix-dropdown-menu-trigger-width)]"
)} )}
> >
{mailboxes.length > 0 && ( {mailboxes.length > 0 && (
+35 -15
View File
@@ -67,6 +67,7 @@ export function ProfilePage() {
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false) const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const [externalRunAccountId, setExternalRunAccountId] = React.useState("") const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
const [twoFactorRecoveryCodes, setTwoFactorRecoveryCodes] = React.useState<string[]>([])
const isMobile = useIsMobile() const isMobile = useIsMobile()
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
@@ -142,17 +143,17 @@ export function ProfilePage() {
}) })
const setupTwoFactor = useMutation({ const setupTwoFactor = useMutation({
mutationFn: api.setupTwoFactor, mutationFn: api.setupTwoFactor,
onSuccess: () => toast({ title: "双因素密钥已生成" }), onSuccess: () => { setTwoFactorRecoveryCodes([]); toast({ title: "双因素密钥已生成" }) },
onError: (error) => toast({ title: "生成失败", description: error.message }), onError: (error) => toast({ title: "生成失败", description: error.message }),
}) })
const enableTwoFactor = useMutation({ const enableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")), mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) }, onSuccess: (data) => { qc.setQueryData(["me"], { user: data.user }); setTwoFactorRecoveryCodes(data.recoveryCodes || []); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
onError: (error) => toast({ title: "启用失败", description: error.message }), onError: (error) => toast({ title: "启用失败", description: error.message }),
}) })
const disableTwoFactor = useMutation({ const disableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")), mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) }, onSuccess: (data) => { qc.setQueryData(["me"], data); setTwoFactorRecoveryCodes([]); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
onError: (error) => toast({ title: "关闭失败", description: error.message }), onError: (error) => toast({ title: "关闭失败", description: error.message }),
}) })
const createApiToken = useMutation({ const createApiToken = useMutation({
@@ -339,9 +340,9 @@ export function ProfilePage() {
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div> if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div>
const sidebarContent = ( const sidebarContent = (
<div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card"> <div className="flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
<div className="h-[64px] border-b"> <div className="h-[64px] border-b">
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.loginName || user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} /> <AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
</div> </div>
<nav className="min-h-0 flex-1 overflow-y-auto p-2"> <nav className="min-h-0 flex-1 overflow-y-auto p-2">
<div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground"></div> <div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground"></div>
@@ -445,6 +446,7 @@ export function ProfilePage() {
setupTwoFactor={setupTwoFactor} setupTwoFactor={setupTwoFactor}
enableTwoFactor={enableTwoFactor} enableTwoFactor={enableTwoFactor}
disableTwoFactor={disableTwoFactor} disableTwoFactor={disableTwoFactor}
twoFactorRecoveryCodes={twoFactorRecoveryCodes}
mailboxes={mailboxes.data?.items || []} mailboxes={mailboxes.data?.items || []}
selectedMailboxId={mailboxId} selectedMailboxId={mailboxId}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
@@ -582,6 +584,7 @@ type AccountSettingsSectionProps = {
setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean } setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }
enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean } enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean } disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
twoFactorRecoveryCodes: string[]
onCopy: (text: string) => void onCopy: (text: string) => void
mailboxes: Mailbox[] mailboxes: Mailbox[]
selectedMailboxId: string selectedMailboxId: string
@@ -637,6 +640,7 @@ function AccountSettingsSection(props: AccountSettingsSectionProps) {
setupTwoFactor={props.setupTwoFactor} setupTwoFactor={props.setupTwoFactor}
enableTwoFactor={props.enableTwoFactor} enableTwoFactor={props.enableTwoFactor}
disableTwoFactor={props.disableTwoFactor} disableTwoFactor={props.disableTwoFactor}
recoveryCodes={props.twoFactorRecoveryCodes}
onCopy={props.onCopy} onCopy={props.onCopy}
/> />
) )
@@ -672,7 +676,7 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
} }
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) { function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
const accountName = user.loginName || user.email const accountName = user.email
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0) const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
const storageBytes = stats?.storageBytes || 0 const storageBytes = stats?.storageBytes || 0
const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0 const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0
@@ -680,7 +684,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
<div className="space-y-6"> <div className="space-y-6">
<SettingsCard title="账号信息"> <SettingsCard title="账号信息">
<div className="space-y-5"> <div className="space-y-5">
<InfoLine label="用户名" value={accountName} /> <InfoLine label="主登录邮箱" value={accountName} />
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center"> <div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<Label className="text-base font-normal text-muted-foreground"></Label> <Label className="text-base font-normal text-muted-foreground"></Label>
<Select defaultValue="Asia/Shanghai"> <Select defaultValue="Asia/Shanghai">
@@ -909,14 +913,14 @@ function EditSignatureForm({ item, mailboxes, pending, onCancel, onSubmit }: { i
) )
} }
function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject<HTMLFormElement>; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; onCopy: (text: string) => void }) { function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, recoveryCodes, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject<HTMLFormElement>; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; recoveryCodes: string[]; onCopy: (text: string) => void }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SettingsCard title="当前登录" contentClassName="border-t py-5"> <SettingsCard title="当前登录" contentClassName="border-t py-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div> <div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
<div> <div>
<div className="font-semibold">{user.loginName || user.email}</div> <div className="font-semibold">{user.email}</div>
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
</div> </div>
</div> </div>
@@ -968,12 +972,28 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
</form> </form>
)} )}
{user.twoFactorEnabled && ( {user.twoFactorEnabled && (
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}> <div className="space-y-4">
<Field label="当前验证码"><Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required /></Field> {recoveryCodes.length > 0 && (
<div className="flex justify-end"> <div className="rounded-lg border bg-muted/30 p-4">
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭两步验证"}</Button> <div className="mb-3 flex items-center justify-between gap-3">
</div> <div>
</form> <div className="text-sm font-semibold"></div>
<div className="text-xs text-muted-foreground">使</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(recoveryCodes.join("\n"))}><Copy className="h-4 w-4" /></Button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((code) => <code key={code} className="rounded-md bg-background px-3 py-2 text-sm font-semibold">{code}</code>)}
</div>
</div>
)}
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前验证码或恢复码"><Input name="code" autoComplete="one-time-code" minLength={6} required /></Field>
<div className="flex justify-end">
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭两步验证"}</Button>
</div>
</form>
</div>
)} )}
</SettingsCard> </SettingsCard>
+17 -23
View File
@@ -24,32 +24,28 @@ export function RegisterPage() {
const [domainId, setDomainId] = React.useState("") const [domainId, setDomainId] = React.useState("")
const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || [] const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || []
const selectedDomain = domains.find((d) => d.id === domainId) 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({ const register = useMutation({
mutationFn: (form: FormData) => { mutationFn: (form: FormData) => {
const password = String(form.get("password") || "") const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "") const confirmPassword = String(form.get("confirmPassword") || "")
validatePasswordConfirm(password, confirmPassword) validatePasswordConfirm(password, confirmPassword)
const displayName = String(form.get("displayName") || "").trim()
if (domainId && selectedDomain) { if (!displayName) throw new Error("请输入显示名称")
const localPart = String(form.get("localPart") || "").trim() const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀") if (!localPart) throw new Error("请输入邮箱前缀")
return api.register({ if (!domainId || !selectedDomain) throw new Error("请选择邮箱域名")
email: `${localPart}@${selectedDomain.name}`,
displayName: String(form.get("displayName") || ""),
password,
turnstileToken,
domainId,
localPart,
})
}
// Fallback: no domains available, use email directly
return api.register({ return api.register({
email: String(form.get("email") || ""), email: `${localPart}@${selectedDomain.name}`,
displayName: String(form.get("displayName") || ""), displayName,
password, password,
turnstileToken, turnstileToken,
domainId,
localPart,
}) })
}, },
onSuccess: async () => { onSuccess: async () => {
@@ -102,14 +98,12 @@ export function RegisterPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="rounded-md bg-muted/40 px-4 py-3 text-center text-sm text-muted-foreground"></div>
<Label htmlFor="email" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label> <Label htmlFor="displayName" className="text-sm font-medium"></Label>
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" /> <Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
<p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label> <Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -120,7 +114,7 @@ export function RegisterPage() {
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" /> <PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div> </div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />} {turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}> <Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading || domains.length === 0}>
{register.isPending ? "注册中..." : "注册"} {register.isPending ? "注册中..." : "注册"}
{!register.isPending && <ArrowRight className="h-4 w-4" />} {!register.isPending && <ArrowRight className="h-4 w-4" />}
</Button> </Button>
+7 -3
View File
@@ -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! LANQIN_ADMIN_PASSWORD=ChangeMe123!
+4 -3
View File
@@ -18,15 +18,16 @@ sudo newszxcn-email rollback
sudo newszxcn-email guide sudo newszxcn-email guide
sudo newszxcn-email credentials sudo newszxcn-email credentials
sudo newszxcn-email reset-password sudo newszxcn-email reset-password
sudo newszxcn-email reset-2fa
``` ```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 一键安装会把配置和数据放在 `/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 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。 安装后输入 `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 ```bash
cd deploy cd deploy
cp .env.example .env 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 pull
docker compose up -d docker compose up -d
``` ```
+6 -5
View File
@@ -10,9 +10,9 @@
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) 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/` | 收发邮件、申请邮箱和账号设置 |
| 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 | | 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 |
管理员账号是安装时填写的用户名,默认为 `admin`。管理员用户名不是邮箱地址 管理员账号是安装时创建的完整邮箱地址,默认为 `admin@邮箱地址域名`。前台和后台都只能使用完整主登录邮箱 + 密码登录,显示名称仅用于页面展示
## 首次配置 ## 首次配置
@@ -105,13 +105,14 @@ sudo newszxcn-email rollback
sudo newszxcn-email guide sudo newszxcn-email guide
sudo newszxcn-email credentials sudo newszxcn-email credentials
sudo newszxcn-email reset-password sudo newszxcn-email reset-password
sudo newszxcn-email reset-2fa
``` ```
命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。 命令行更新会创建完整回滚快照、校验 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,登录后应重新绑定并保存新的恢复码。
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。 超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
+176
View File
@@ -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 | 用户确认本批继续实施;已统一应用壳层侧栏宽度变量,状态流转为待验收。 |
+163 -20
View File
@@ -37,6 +37,7 @@ NewSzxcn Email 管理命令
guide 显示并更新 NewSzxcn 邮箱指南 guide 显示并更新 NewSzxcn 邮箱指南
credentials 查看管理员登录信息和记录密码 credentials 查看管理员登录信息和记录密码
reset-password 重置管理员统一登录密码(含名下邮箱) reset-password 重置管理员统一登录密码(含名下邮箱)
reset-2fa 应急关闭唯一管理员双因素认证
uninstall 停止并移除容器,保留邮件与配置 uninstall 停止并移除容器,保留邮件与配置
EOF EOF
} }
@@ -266,6 +267,10 @@ prompt_text() {
fi fi
} }
lowercase() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
valid_hostname() { valid_hostname() {
local hostname="$1" label tld local hostname="$1" label tld
local -a labels local -a labels
@@ -279,6 +284,108 @@ valid_hostname() {
[[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]] [[ "${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/tty
fi
value="${value:-${suggestion}}"
if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -z "${admin_email}" ]] && ! has_tty; then
fail "非交互安装/更新必须设置 LANQIN_MAIL_DOMAIN 或 LANQIN_ADMIN_EMAIL,不能自动猜测邮箱地址域名。"
fi
value="$(lowercase "${value}")"
valid_hostname "${value}" || fail "邮箱地址域名格式不正确。"
printf '%s' "${value}"
}
prompt_admin_email() {
local mail_domain="$1" choice prefix email domain_part
email="${LANQIN_ADMIN_EMAIL:-}"
if [[ -n "${email}" ]]; then
email="$(lowercase "${email}")"
valid_email_address "${email}" || fail "管理员邮箱格式不正确。"
domain_part="${email#*@}"
[[ "${domain_part}" == "${mail_domain}" ]] || fail "管理员邮箱域名必须与邮箱地址域名一致。"
printf '%s' "${email}"
return
fi
if has_tty; then
prompt_text "\n创建管理员邮箱 [1]:\n1. 默认 admin,自动创建 admin@${mail_domain}\n2. 自定义前缀\n"
choice="$(prompt_choice LANQIN_ADMIN_EMAIL_MODE "请选择 [1]: " "1" "2")"
if [[ "${choice}" == "2" ]]; then
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱前缀" "admin")"
else
prefix="admin"
fi
else
prefix="${LANQIN_ADMIN_PREFIX:-admin}"
fi
valid_mail_local_part "${prefix}" || fail "管理员邮箱前缀格式不正确。"
printf '%s@%s' "$(lowercase "${prefix}")" "${mail_domain}"
}
ensure_admin_email_config() {
[[ -f "${INSTALL_DIR}/.env" ]] || return 0
local hostname existing_mail_domain existing_admin_email existing_admin_prefix mail_domain admin_email admin_prefix
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
existing_mail_domain="${LANQIN_MAIL_DOMAIN:-$(env_value LANQIN_MAIL_DOMAIN || true)}"
existing_admin_email="${LANQIN_ADMIN_EMAIL:-$(env_value LANQIN_ADMIN_EMAIL || true)}"
existing_admin_prefix="${LANQIN_ADMIN_PREFIX:-$(env_value LANQIN_ADMIN_USERNAME || true)}"
if [[ -z "${hostname}" ]]; then
if [[ -n "${existing_mail_domain}" ]]; then
hostname="${existing_mail_domain}"
elif [[ -n "${existing_admin_email}" && "${existing_admin_email}" == *@* ]]; then
hostname="${existing_admin_email#*@}"
else
fail "缺少 LANQIN_PUBLIC_HOSTNAME,无法确认管理员邮箱域名。"
fi
fi
valid_hostname "${hostname}" || fail "邮件服务器域名配置无效,无法确认管理员邮箱域名。"
LANQIN_MAIL_DOMAIN="${existing_mail_domain}"
LANQIN_ADMIN_EMAIL="${existing_admin_email}"
mail_domain="$(prompt_mail_domain "${hostname}")"
LANQIN_ADMIN_EMAIL="${existing_admin_email}"
LANQIN_ADMIN_PREFIX="${existing_admin_prefix:-admin}"
admin_email="$(prompt_admin_email "${mail_domain}")"
admin_prefix="${admin_email%@*}"
set_env LANQIN_MAIL_DOMAIN "${mail_domain}"
set_env LANQIN_ADMIN_EMAIL "${admin_email}"
set_env LANQIN_ADMIN_USERNAME "${admin_prefix}"
chmod 0600 "${INSTALL_DIR}/.env"
}
prompt_admin_password() { prompt_admin_password() {
local password="${LANQIN_ADMIN_PASSWORD:-}" confirm="" local password="${LANQIN_ADMIN_PASSWORD:-}" confirm=""
local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$' local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$'
@@ -371,15 +478,17 @@ configure_first_install() {
return return
fi fi
local firewall_mode hostname admin_username admin_password web_mode public_url update_token local firewall_mode hostname mail_domain admin_email admin_prefix admin_password web_mode public_url update_token
prompt_text '\n防火墙配置 [1]:\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n' prompt_text '\n防火墙配置 [1]:\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")" firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")" hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。" valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "管理员用户名" "admin")" mail_domain="$(prompt_mail_domain "${hostname}")"
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名需为 2-80 位且不能包含 @。" LANQIN_MAIL_DOMAIN="${mail_domain}"
admin_email="$(prompt_admin_email "${mail_domain}")"
admin_prefix="${admin_email%@*}"
admin_password="$(prompt_admin_password)" admin_password="$(prompt_admin_password)"
prompt_text '\nWeb 部署方式 [1]\n1. 自动配置 Nginx + SSL\n2. 宝塔/已有 Nginx 反代\n3. 仅 HTTP 测试\n' prompt_text '\nWeb 部署方式 [1]\n1. 自动配置 Nginx + SSL\n2. 宝塔/已有 Nginx 反代\n3. 仅 HTTP 测试\n'
@@ -395,7 +504,9 @@ configure_first_install() {
set_env LANQIN_INSTALL_FIREWALL_MODE "${firewall_mode}" set_env LANQIN_INSTALL_FIREWALL_MODE "${firewall_mode}"
set_env LANQIN_PUBLIC_HOSTNAME "${hostname}" set_env LANQIN_PUBLIC_HOSTNAME "${hostname}"
set_env LANQIN_PUBLIC_BASE_URL "${public_url}" set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
set_env LANQIN_ADMIN_USERNAME "${admin_username}" set_env LANQIN_MAIL_DOMAIN "${mail_domain}"
set_env LANQIN_ADMIN_EMAIL "${admin_email}"
set_env LANQIN_ADMIN_USERNAME "${admin_prefix}"
set_env LANQIN_ADMIN_PASSWORD "${admin_password}" set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
set_env LANQIN_INSTALL_WEB_MODE "${web_mode}" set_env LANQIN_INSTALL_WEB_MODE "${web_mode}"
set_env LANQIN_UPDATE_TOKEN "${update_token}" set_env LANQIN_UPDATE_TOKEN "${update_token}"
@@ -867,7 +978,7 @@ do_repair_install() {
create_update_snapshot || fail "修复前备份失败,未修改现有安装。" create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
stage_assets stage_assets
clear_runtime_image_pin clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token || ! configure_runtime_bindings; then if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。" fail "修复准备失败,已恢复原安装。"
fi fi
@@ -932,7 +1043,7 @@ do_update() {
create_update_snapshot || fail "更新前备份失败,未修改现有安装。" create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
stage_assets stage_assets
clear_runtime_image_pin clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token; then if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config; then
restore_update_snapshot "" false || true restore_update_snapshot "" false || true
fail "更新文件替换失败,已恢复原安装。" fail "更新文件替换失败,已恢复原安装。"
fi fi
@@ -1002,11 +1113,12 @@ do_certificate() {
generate_guide() { generate_guide() {
[[ -f "${INSTALL_DIR}/.env" ]] || return 1 [[ -f "${INSTALL_DIR}/.env" ]] || return 1
local public_url admin_url hostname admin_username certificate_expiry="未安装" renewal_status="未开启" next_renewal="等待 acme.sh 生成续期计划" ensure_admin_email_config
local public_url admin_url hostname admin_email certificate_expiry="未安装" renewal_status="未开启" next_renewal="等待 acme.sh 生成续期计划"
local acme_info="" tmp local acme_info="" tmp
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)" public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)" hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
admin_username="$(env_value LANQIN_ADMIN_USERNAME || true)" admin_email="$(env_value LANQIN_ADMIN_EMAIL || true)"
public_url="${public_url:-http://${hostname}}" public_url="${public_url:-http://${hostname}}"
admin_url="${public_url%/}/admin" admin_url="${public_url%/}/admin"
@@ -1035,7 +1147,7 @@ generate_guide() {
邮箱前台:${public_url} 邮箱前台:${public_url}
管理后台:${admin_url} 管理后台:${admin_url}
管理员账号${admin_username:-admin} 管理员邮箱${admin_email:-未记录}
管理员密码:仅在安装完成时显示;修改后请使用新密码 管理员密码:仅在安装完成时显示;修改后请使用新密码
SSL 证书:有效期至 ${certificate_expiry} SSL 证书:有效期至 ${certificate_expiry}
自动续期:${renewal_status} 自动续期:${renewal_status}
@@ -1094,8 +1206,9 @@ do_guide() {
do_show_admin_credentials() { do_show_admin_credentials() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
local username password public_url ensure_admin_email_config
username="$(env_value LANQIN_ADMIN_USERNAME || true)" local admin_email password public_url
admin_email="$(env_value LANQIN_ADMIN_EMAIL || true)"
password="$(env_value LANQIN_ADMIN_PASSWORD || true)" password="$(env_value LANQIN_ADMIN_PASSWORD || true)"
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)" public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
cat <<EOF cat <<EOF
@@ -1104,7 +1217,7 @@ do_show_admin_credentials() {
NewSzxcn 管理员登录信息 NewSzxcn 管理员登录信息
================================================== ==================================================
登录地址:${public_url:-未记录} 登录地址:${public_url:-未记录}
管理员用户名${username:-admin} 管理员邮箱${admin_email:-未记录}
记录密码:${password:-未记录} 记录密码:${password:-未记录}
================================================== ==================================================
EOF EOF
@@ -1121,13 +1234,13 @@ generate_admin_password_hash() {
do_reset_admin_password() { do_reset_admin_password() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
local username password user_id hash image timestamp backup env_backup result user_changes mailbox_changes local admin_email password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
ensure_docker ensure_docker
username="$(env_value LANQIN_ADMIN_USERNAME || true)" ensure_admin_email_config
username="${username:-admin}" admin_email="$(env_value LANQIN_ADMIN_EMAIL || true)"
[[ "${username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名配置无效,无法安全重置。" valid_email_address "${admin_email}" || fail "管理员邮箱配置无效,无法安全重置。"
user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE login_name='${username}' AND role='admin' LIMIT 1;" | tr -d '\r\n')" user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE email='${admin_email}' AND role='admin' LIMIT 1;" | tr -d '\r\n')"
[[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员账号 ${username}" [[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员邮箱 ${admin_email}"
password="$(prompt_reset_password)" password="$(prompt_reset_password)"
hash="$(generate_admin_password_hash "${password}")" || fail "无法生成安全密码哈希,管理员密码未修改。" hash="$(generate_admin_password_hash "${password}")" || fail "无法生成安全密码哈希,管理员密码未修改。"
image="$(current_image_id || true)" image="$(current_image_id || true)"
@@ -1143,7 +1256,7 @@ do_reset_admin_password() {
fail "管理员密码记录更新失败,数据库未修改。" fail "管理员密码记录更新失败,数据库未修改。"
fi fi
chmod 0600 "${INSTALL_DIR}/.env" chmod 0600 "${INSTALL_DIR}/.env"
if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND role='admin'; SELECT 'user=' || changes(); UPDATE mailboxes SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id='${user_id}' AND EXISTS (SELECT 1 FROM users WHERE id='${user_id}' AND role='admin'); SELECT 'mailboxes=' || changes(); COMMIT;" | tr -d '\r')"; then if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND email='${admin_email}' AND role='admin'; SELECT 'user=' || changes(); UPDATE mailboxes SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id='${user_id}' AND EXISTS (SELECT 1 FROM users WHERE id='${user_id}' AND email='${admin_email}' AND role='admin'); SELECT 'mailboxes=' || changes(); COMMIT;" | tr -d '\r')"; then
install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env" install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env"
rm -f "${env_backup}" rm -f "${env_backup}"
fail "管理员密码写入失败,已恢复原密码记录。" fail "管理员密码写入失败,已恢复原密码记录。"
@@ -1156,13 +1269,42 @@ do_reset_admin_password() {
fail "管理员账号不存在或身份已变化,密码未修改;已恢复原密码记录。" fail "管理员账号不存在或身份已变化,密码未修改;已恢复原密码记录。"
fi fi
rm -f "${env_backup}" rm -f "${env_backup}"
success "管理员 ${username} 的统一登录密码已重置。" success "管理员 ${admin_email} 的统一登录密码已重置。"
printf '新密码:%s\n' "${password}" printf '新密码:%s\n' "${password}"
log "重置前数据库备份:${backup}" log "重置前数据库备份:${backup}"
log "已同步 ${mailbox_changes} 个管理员邮箱的 SMTP/IMAP 密码。" log "已同步 ${mailbox_changes} 个管理员邮箱的 SMTP/IMAP 密码。"
warn "此次操作只修改管理员账号及其名下邮箱,不会修改普通用户或其邮箱密码。" warn "此次操作只修改管理员账号及其名下邮箱,不会修改普通用户或其邮箱密码。"
} }
do_reset_admin_two_factor() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
local admin_email user_id image timestamp backup result user_changes recovery_changes challenge_changes
ensure_docker
ensure_admin_email_config
admin_email="$(env_value LANQIN_ADMIN_EMAIL || true)"
valid_email_address "${admin_email}" || fail "管理员邮箱配置无效,无法安全关闭双因素认证。"
user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE email='${admin_email}' AND role='admin' LIMIT 1;" | tr -d '\r\n')"
[[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员邮箱 ${admin_email}"
image="$(current_image_id || true)"
[[ -n "${image}" ]] || fail "无法确定当前镜像,管理员双因素认证未修改。"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup="${INSTALL_DIR}/data/backups/2fa-reset-${timestamp}.db"
backup_database "${backup}" "${image}" || fail "数据库备份失败,管理员双因素认证未修改。"
if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND email='${admin_email}' AND role='admin'; SELECT 'user=' || changes(); DELETE FROM two_factor_recovery_codes WHERE user_id='${user_id}'; SELECT 'recovery=' || changes(); DELETE FROM login_challenges WHERE user_id='${user_id}'; SELECT 'challenges=' || changes(); COMMIT;" | tr -d '\r')"; then
fail "管理员双因素认证关闭失败,数据库未确认修改。"
fi
user_changes="$(printf '%s\n' "${result}" | sed -n 's/^user=//p' | tail -n 1)"
recovery_changes="$(printf '%s\n' "${result}" | sed -n 's/^recovery=//p' | tail -n 1)"
challenge_changes="$(printf '%s\n' "${result}" | sed -n 's/^challenges=//p' | tail -n 1)"
if [[ "${user_changes}" != "1" || ! "${recovery_changes}" =~ ^[0-9]+$ || ! "${challenge_changes}" =~ ^[0-9]+$ ]]; then
fail "管理员账号不存在或身份已变化,双因素认证未确认关闭。"
fi
success "管理员 ${admin_email} 的双因素认证已关闭。"
log "重置前数据库备份:${backup}"
log "已删除 ${recovery_changes} 个恢复码和 ${challenge_changes} 个登录挑战。"
warn "请管理员登录后重新绑定双因素认证并妥善保存新的恢复码。"
}
do_status() { do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps compose ps
@@ -1353,6 +1495,7 @@ case "${COMMAND}" in
guide) require_root; require_curl; do_guide ;; guide) require_root; require_curl; do_guide ;;
credentials) require_root; require_curl; do_show_admin_credentials ;; credentials) require_root; require_curl; do_show_admin_credentials ;;
reset-password) require_root; require_curl; do_reset_admin_password ;; reset-password) require_root; require_curl; do_reset_admin_password ;;
reset-2fa) require_root; require_curl; do_reset_admin_two_factor ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;; uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;; *) usage; fail "未知命令:${COMMAND}" ;;
esac esac
+69 -6
View File
@@ -43,6 +43,22 @@ test_password_validation() {
fi fi
} }
test_mail_domain_and_admin_email_validation() {
assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion"
assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion"
LANQIN_MAIL_DOMAIN="example.com"
LANQIN_ADMIN_EMAIL="admin@example.com"
assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain"
assert_eq "admin@example.com" "$(prompt_admin_email "example.com")" "explicit administrator email"
if (LANQIN_ADMIN_EMAIL="admin@other.example.com" prompt_admin_email "example.com" >/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() { test_install_configuration() {
local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5" local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5"
local temp_dir local temp_dir
@@ -52,7 +68,8 @@ test_install_configuration() {
export INSTALL_DIR="${temp_dir}" export INSTALL_DIR="${temp_dir}"
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}" export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
export LANQIN_PUBLIC_HOSTNAME="mail.example.com" 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_ADMIN_PASSWORD="abc123"
export LANQIN_INSTALL_WEB_MODE="${web_mode}" export LANQIN_INSTALL_WEB_MODE="${web_mode}"
configure_first_install configure_first_install
@@ -63,6 +80,9 @@ test_install_configuration() {
assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind" 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_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL"
assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag" 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" assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password"
} }
@@ -134,12 +154,13 @@ test_admin_credentials() (
mkdir -p "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}"
cat > "${INSTALL_DIR}/.env" <<'EOF' cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_PUBLIC_BASE_URL=https://mail.example.com 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 LANQIN_ADMIN_PASSWORD=recorded-password
EOF EOF
output="$(do_show_admin_credentials 2>&1)" output="$(do_show_admin_credentials 2>&1)"
[[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing" [[ "${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}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing"
[[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning 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" compose_calls="${temp_dir}/compose-calls"
mkdir -p "${INSTALL_DIR}/data/backups" mkdir -p "${INSTALL_DIR}/data/backups"
cat > "${INSTALL_DIR}/.env" <<'EOF' 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 LANQIN_ADMIN_PASSWORD=old-password
EOF EOF
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
@@ -186,12 +209,48 @@ EOF
do_reset_admin_password >/dev/null do_reset_admin_password >/dev/null
assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password" assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password"
[[ -s "${backup_path}" ]] || fail_test "password reset database backup missing" [[ -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 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 "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" 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() ( test_offline_database_backup() (
local temp_dir destination local temp_dir destination
temp_dir="$(mktemp -d)" temp_dir="$(mktemp -d)"
@@ -215,10 +274,12 @@ test_guide_generation() (
cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env" cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com" set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
set_env LANQIN_PUBLIC_BASE_URL "https://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 generate_guide
grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing" 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 '管理后台: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" 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" [[ "$(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_hostname_validation
test_password_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 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_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_nginx_configuration test_nginx_configuration
@@ -512,6 +574,7 @@ test_menu_choice
test_admin_credentials test_admin_credentials
test_admin_password_hash_parsing test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account test_admin_password_reset_only_updates_admin_account
test_admin_two_factor_reset_only_updates_admin_account
test_offline_database_backup test_offline_database_backup
test_guide_generation test_guide_generation
test_acme_cron_detection test_acme_cron_detection