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
+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), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
ORDER BY u.created_at DESC`)
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
@@ -108,20 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
actor := currentUser(r)
var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail, err := cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = loginName
badRequest(w, errors.New("displayName is required"))
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
@@ -131,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" && (actor == nil || actor.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
if role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -161,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
@@ -190,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
current := currentUser(r)
var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
@@ -218,16 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "user not found")
return
}
requestedLoginName := strings.TrimSpace(req.LoginName)
if existing.Role == "admin" && role != "admin" {
badRequest(w, errors.New("唯一管理员不能降级"))
return
}
if existing.Role != "admin" && role == "admin" {
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
primaryEmail := existing.Email
loginName := existing.LoginName
if requestedLoginName != "" {
loginName, err = cleanUsername(requestedLoginName)
if strings.TrimSpace(emailInput) != "" {
primaryEmail, err = cleanPrimaryEmail(emailInput)
if err != nil {
badRequest(w, err)
return
}
loginName = primaryEmail
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return
}
@@ -239,6 +252,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("default administrator must remain an active super administrator"))
return
}
if existing.Role == "admin" && disabled {
badRequest(w, errors.New("唯一管理员不能停用"))
return
}
mailboxLimitOverride := existing.MailboxLimitOverride
if req.MailboxLimitOverride != nil {
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
@@ -294,14 +311,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
emailIdentity := existing.Email
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
emailIdentity = loginName
}
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("登录已被使用"))
badRequest(w, errors.New("登录邮箱已被使用"))
return
}
respondError(w, http.StatusInternalServerError, "failed to update user")
@@ -317,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
if existing.Role == "admin" {
a.updateConfig(func(cfg *Config) {
cfg.AdminEmail = primaryEmail
})
}
user, err := a.adminUserByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
@@ -575,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
if role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -617,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
} else {
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
}
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
if err != nil {
badRequest(w, err)
return
}
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
@@ -631,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
userID = newID("usr")
ownerDisplayName := displayName
if !strings.EqualFold(ownerLoginName, address) {
ownerDisplayName = ownerLoginName
if !strings.EqualFold(ownerEmail, address) {
ownerDisplayName = ownerEmail
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
badRequest(w, err)
return
+248 -104
View File
@@ -33,6 +33,7 @@ type App struct {
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
turnstileURL string
}
func (a *App) config() Config {
@@ -92,6 +93,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
db.Close()
return nil, err
}
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
@@ -185,6 +190,14 @@ func (a *App) migrate(ctx context.Context) error {
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(user_id, code_hash)
)`,
`CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -939,81 +952,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
return err
}
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
// that implicitly created an admin mailbox with display_name "LanQin Admin".
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
// Administrators now use a real mailbox as their primary login address, so old
// bootstrap mailboxes must be preserved and normalized by the admin identity
// migration instead of deleted.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.config().AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
rows, err := a.db.QueryContext(ctx, `
SELECT mb.id, mb.domain_id
FROM mailboxes mb
JOIN users u ON u.id=mb.user_id
WHERE mb.address=?
AND mb.display_name='LanQin Admin'
AND u.email=?
AND u.role='admin'`, adminEmail, adminEmail)
if err != nil {
return err
}
type legacyMailbox struct {
id string
domainID string
}
items := []legacyMailbox{}
for rows.Next() {
var item legacyMailbox
if err := rows.Scan(&item.id, &item.domainID); err != nil {
rows.Close()
return err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
if err != nil {
return err
}
messageIDs := []string{}
for messageRows.Next() {
var messageID string
if err := messageRows.Scan(&messageID); err != nil {
messageRows.Close()
return err
}
messageIDs = append(messageIDs, messageID)
}
if err := messageRows.Err(); err != nil {
messageRows.Close()
return err
}
if err := messageRows.Close(); err != nil {
return err
}
for _, messageID := range messageIDs {
a.deleteMessage(ctx, messageID)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `
DELETE FROM domains
WHERE id=?
AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
return err
}
}
return nil
}
@@ -1412,7 +1355,11 @@ func (a *App) seed(ctx context.Context) error {
return err
}
if count > 0 {
return a.ensureConfiguredAdminSuperAdmin(ctx)
return a.migrateConfiguredAdministratorIdentity(ctx)
}
adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail)
if err != nil {
return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation")
}
adminPassword := cfg.AdminPassword
@@ -1430,25 +1377,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(cfg.AdminUsername)
if err != nil {
return fmt.Errorf("invalid admin username: %w", err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil
}
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1483,18 +1413,221 @@ func (a *App) seed(ctx context.Context) error {
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
return a.migrateConfiguredAdministratorIdentity(ctx)
}
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
cfg := a.config()
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
type adminUser struct {
ID string `json:"id"`
LoginName string `json:"loginName,omitempty"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt string `json:"createdAt"`
}
rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`)
if err != nil {
return err
}
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
admins := []adminUser{}
for rows.Next() {
var item adminUser
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err != nil {
rows.Close()
return err
}
admins = append(admins, item)
}
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if len(admins) == 0 {
if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" {
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail)
var item adminUser
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
admins = append(admins, item)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
}
}
if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername := normalizeLoginName(cfg.AdminUsername)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername)
var item adminUser
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
admins = append(admins, item)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
}
if len(admins) == 0 {
return errors.New("no administrator user found for identity migration")
}
keeper := admins[0]
adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email)
if err != nil {
return err
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var conflictID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil {
return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID)
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
demoted := []adminUser{}
for _, admin := range admins[1:] {
if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil {
return err
}
admin.PasswordHash = ""
demoted = append(demoted, admin)
}
if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`,
adminEmail, adminEmail, now, keeper.ID); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return fmt.Errorf("admin identity migration conflict: %w", err)
}
return err
}
parts := strings.SplitN(adminEmail, "@", 2)
localPart := parts[0]
domainName := normalizeDomain(parts[1])
var domainID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
domainID, err = a.createDomainTx(ctx, tx, domainName)
if err != nil {
return err
}
}
mailboxCreated := false
var mailboxID, mailboxUserID string
if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
if err != nil {
return err
}
mailboxCreated = true
} else if mailboxUserID != keeper.ID {
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
}
result := map[string]any{
"adminUserId": keeper.ID,
"adminEmail": adminEmail,
"emailSource": emailSource,
"previousEmail": keeper.Email,
"demotedAdmins": demoted,
"mailboxId": mailboxID,
"mailboxCreated": mailboxCreated,
"migratedAt": now,
}
raw, _ := json.Marshal(result)
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
a.updateConfig(func(current *Config) {
current.AdminEmail = adminEmail
if current.MailDomain == "" {
current.MailDomain = domainName
}
})
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
return nil
}
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
// Once initialized, the database identity is authoritative. This keeps an
// administrator email changed in the UI from reverting to the installer value.
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
return email, "existing_admin_email", nil
}
if strings.TrimSpace(cfg.AdminEmail) != "" {
email, err := cleanPrimaryEmail(cfg.AdminEmail)
if err != nil {
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
}
return email, "configured_admin_email", nil
}
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
preferredLocalPart = normalizeLocalPart(loginName)
}
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
preferredLocalPart = "admin"
}
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
if err != nil {
return "", "", err
}
for rows.Next() {
var address string
if err := rows.Scan(&address); err != nil {
rows.Close()
return "", "", err
}
if email, err := cleanPrimaryEmail(address); err == nil {
rows.Close()
return email, "existing_admin_mailbox", nil
}
}
if err := rows.Err(); err != nil {
rows.Close()
return "", "", err
}
if err := rows.Close(); err != nil {
return "", "", err
}
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
}
var onlyDomain string
var domainCount int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
return "", "", err
}
if domainCount == 1 && validMailDomain(onlyDomain) {
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
}
publicDomain := normalizeDomain(cfg.PublicHostname)
if strings.HasPrefix(publicDomain, "mail.") {
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
}
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
}
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
}
func validMailDomain(domain string) bool {
domain = normalizeDomain(domain)
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
}
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
return err
}
@@ -1616,13 +1749,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
return err
}
now := a.now().UTC()
systemDomain := normalizeDomain(cfg.MailDomain)
if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") {
systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1])
}
if systemDomain == "" {
systemDomain = normalizeDomain(cfg.PublicHostname)
}
if systemDomain == "" {
systemDomain = "lanqin.local"
}
systemAddress := "system@" + systemDomain
subject := "欢迎使用 NewSzxcn 邮箱"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
To: cfg.AdminEmail,
From: "system@lanqin.local",
From: systemAddress,
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
Time: now,
@@ -1633,9 +1777,9 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
MailboxID: mailboxID,
FolderID: folderID,
MessageUID: newID("uid"),
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
Subject: subject,
From: "system@lanqin.local",
From: systemAddress,
FromName: "NewSzxcn 邮箱",
To: []string{cfg.AdminEmail},
SentAt: now,
+587 -38
View File
@@ -413,6 +413,20 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
return group
}
func setRegularPermissionGroupForTest(t *testing.T, a *App, permissions []string, limits PermissionLimits) PermissionGroup {
t.Helper()
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(context.Background(), `UPDATE permission_groups SET permissions_json=?, limits_json=?, updated_at=? WHERE id=?`,
encodePermissions(permissions), encodePermissionLimits(limits), now, PermissionGroupRegular); err != nil {
t.Fatalf("set regular permission group fixture: %v", err)
}
group, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
if err != nil {
t.Fatalf("load regular permission group fixture: %v", err)
}
return *group
}
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
@@ -1279,7 +1293,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
@@ -1333,7 +1347,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
}
}
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -1345,32 +1359,92 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
}
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
domainID := mustDefaultDomainID(t, a)
registration := map[string]string{
"email": "newuser@lanqin.local",
"displayName": "New User",
"password": "Password123!",
"domainId": domainID,
"localPart": "newuser",
}
var registered struct {
User User `json:"user"`
}
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &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)
}
var me struct {
User User `json:"user"`
}
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" {
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@lanqin.local" {
t.Fatalf("me code=%d user=%+v", code, me.User)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "newuser@lanqin.local" {
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
}
another := &testClient{t: t, server: ts}
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK {
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@lanqin.local", "password": "Password123!"}, &out); code != http.StatusOK {
t.Fatalf("login registered user code=%d body=%v", code, out)
}
}
func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
func TestTurnstileRetainedForLoginAndRegistration(t *testing.T) {
a := newTestApp(t)
verifyCalls := 0
verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
verifyCalls++
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.Form.Get("secret") != "secret-key" || r.Form.Get("response") == "" {
t.Fatalf("turnstile form secret=%q response=%q", r.Form.Get("secret"), r.Form.Get("response"))
}
respondJSON(w, http.StatusOK, map[string]any{"success": r.Form.Get("response") == "valid-token"})
}))
defer verifyServer.Close()
a.turnstileURL = verifyServer.URL
a.updateConfig(func(cfg *Config) {
cfg.OpenRegistration = true
cfg.TurnstileEnabled = true
cfg.TurnstileSiteKey = "site-key"
cfg.TurnstileSecretKey = "secret-key"
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var public PublicSettings
if code := client.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.TurnstileEnabled || public.TurnstileSiteKey != "site-key" {
t.Fatalf("public turnstile settings code=%d settings=%+v", code, public)
}
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("login without turnstile code=%d", code)
}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!", "turnstileToken": "valid-token"}, &login); code != http.StatusOK {
t.Fatalf("login with turnstile code=%d body=%v", code, login)
}
domainID := mustDefaultDomainID(t, a)
registerClient := &testClient{t: t, server: ts}
registerPayload := map[string]string{"email": "turnstile-user@lanqin.local", "displayName": "Turnstile User", "password": "Password123!", "domainId": domainID, "localPart": "turnstile-user"}
if code := registerClient.do("POST", "/api/auth/register", registerPayload, nil); code != http.StatusUnauthorized {
t.Fatalf("register without turnstile code=%d", code)
}
registerPayload["turnstileToken"] = "valid-token"
var registered map[string]any
if code := registerClient.do("POST", "/api/auth/register", registerPayload, &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()
cfg := Config{
Addr: ":0",
@@ -1413,15 +1487,15 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
t.Fatalf("admin user count=%d err=%v", count, err)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
t.Fatalf("legacy mailbox count=%d err=%v", count, err)
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
t.Fatalf("admin mailbox count=%d err=%v", count, err)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
t.Fatalf("legacy domain count=%d err=%v", count, err)
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 1 {
t.Fatalf("admin domain count=%d err=%v", count, err)
}
}
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
func TestConfiguredAdminEmailCreatesMailboxAndRejectsUsernameLogin(t *testing.T) {
dir := t.TempDir()
cfg := Config{
Addr: ":0",
@@ -1430,6 +1504,7 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminUsername: "admin",
AdminEmail: "root@example.test",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
@@ -1444,8 +1519,15 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
t.Fatal(err)
}
if domains != 0 || mailboxes != 0 {
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
if domains != 1 || mailboxes != 1 {
t.Fatalf("admin email bootstrap domains=%d mailboxes=%d", domains, mailboxes)
}
var welcomeFrom, welcomeMessageID string
if err := a.db.QueryRow(`SELECT from_addr,message_id FROM messages ORDER BY created_at LIMIT 1`).Scan(&welcomeFrom, &welcomeMessageID); err != nil {
t.Fatal(err)
}
if welcomeFrom != "system@example.test" || !strings.HasSuffix(welcomeMessageID, "@example.test>") {
t.Fatalf("welcome message retained placeholder domain: from=%q messageId=%q", welcomeFrom, welcomeMessageID)
}
ts := httptest.NewServer(a.Router())
@@ -1454,33 +1536,339 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
var login struct {
User User `json:"user"`
}
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("username login code=%d", code)
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("legacy username login code=%d", code)
}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin email login code=%d", code)
}
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "rootadmin",
"email": "root@example.test",
"displayName": "Administrator",
"role": "admin",
"disabled": false,
}, nil); code != http.StatusOK {
t.Fatalf("rename administrator code=%d", code)
t.Fatalf("admin display update code=%d", code)
}
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "root@example.test",
"email": "not-an-email",
"displayName": "Administrator",
"role": "admin",
"disabled": false,
}, nil); code != http.StatusBadRequest {
t.Fatalf("email-shaped login name code=%d", code)
t.Fatalf("invalid primary email update code=%d", code)
}
}
func TestAdministratorPrimaryEmailPersistsAcrossRestart(t *testing.T) {
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "root@example.test",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(a.Router())
admin := &testClient{t: t, server: ts}
var login struct {
User User `json:"user"`
}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"email": "owner@example.test",
"displayName": "Administrator",
"role": "admin",
"disabled": false,
}, nil); code != http.StatusOK {
t.Fatalf("admin email update code=%d", code)
}
if a.config().AdminEmail != "owner@example.test" {
t.Fatalf("runtime admin email=%q", a.config().AdminEmail)
}
ts.Close()
if err := a.Close(); err != nil {
t.Fatal(err)
}
oldLogin := &testClient{t: t, server: ts}
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("old username login code=%d", code)
restarted, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
newLogin := &testClient{t: t, server: ts}
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("renamed username login code=%d", code)
t.Cleanup(func() { _ = restarted.Close() })
var email, loginName string
if err := restarted.db.QueryRow(`SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
t.Fatal(err)
}
if email != "owner@example.test" || loginName != "owner@example.test" || restarted.config().AdminEmail != "owner@example.test" {
t.Fatalf("administrator identity reverted after restart: email=%q login=%q config=%q", email, loginName, restarted.config().AdminEmail)
}
}
func TestLegacyAdminIdentityMigrationKeepsEarliestAdminAndRecordsResult(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.updateConfig(func(cfg *Config) {
cfg.AdminUsername = "admin"
cfg.AdminEmail = "admin@example.test"
})
keeperHash, err := bcrypt.GenerateFromPassword([]byte("OriginalPass123!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
demotedHash, err := bcrypt.GenerateFromPassword([]byte("OtherPass123!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_users_single_admin`); err != nil {
t.Fatal(err)
}
now := a.now().UTC()
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin', password_hash=?, two_factor_secret='legacy-secret', two_factor_enabled=1, created_at=?, updated_at=? WHERE role='admin'`,
string(keeperHash), now.Add(-2*time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES('usr_second_admin','second','second@example.test','Second Admin','admin',?,0,?,?)`, string(demotedHash), now.Add(-time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if err := a.migrateConfiguredAdministratorIdentity(ctx); err != nil {
t.Fatal(err)
}
if err := a.enforceSingleAdministratorIndex(ctx); err != nil {
t.Fatal(err)
}
var adminCount int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil || adminCount != 1 {
t.Fatalf("admin count=%d err=%v", adminCount, err)
}
var email, loginName, passwordHash, twoFactorSecret string
var enabled int
if err := a.db.QueryRowContext(ctx, `SELECT email,login_name,password_hash,two_factor_secret,two_factor_enabled FROM users WHERE role='admin'`).Scan(&email, &loginName, &passwordHash, &twoFactorSecret, &enabled); err != nil {
t.Fatal(err)
}
if email != "admin@example.test" || loginName != "admin@example.test" || twoFactorSecret != "legacy-secret" || enabled != 1 {
t.Fatalf("admin identity not migrated safely email=%q login=%q secret=%q enabled=%d", email, loginName, twoFactorSecret, enabled)
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("OriginalPass123!")); err != nil {
t.Fatalf("admin password hash was not preserved: %v", err)
}
var secondRole string
if err := a.db.QueryRowContext(ctx, `SELECT role FROM users WHERE id='usr_second_admin'`).Scan(&secondRole); err != nil || secondRole != "user" {
t.Fatalf("second admin role=%q err=%v", secondRole, err)
}
var mailboxCount int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address='admin@example.test'`).Scan(&mailboxCount); err != nil || mailboxCount != 1 {
t.Fatalf("admin mailbox count=%d err=%v", mailboxCount, err)
}
var rawResult string
if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&rawResult); err != nil {
t.Fatal(err)
}
if !strings.Contains(rawResult, `"adminEmail":"admin@example.test"`) || !strings.Contains(rawResult, `"id":"usr_second_admin"`) {
t.Fatalf("migration result not recorded: %s", rawResult)
}
}
func TestLegacyWebUpdateResolvesAdminEmailFromExistingMailbox(t *testing.T) {
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "bootstrap@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
stopTestWorkers(a)
ctx := context.Background()
var adminID, passwordHash string
if err := a.db.QueryRowContext(ctx, `SELECT id,password_hash FROM users WHERE role='admin'`).Scan(&adminID, &passwordHash); err != nil {
t.Fatal(err)
}
domainID, err := a.createDomainTx(ctx, nil, "example.test")
if err != nil {
t.Fatal(err)
}
if _, err := a.createMailboxWithPasswordHash(ctx, adminID, domainID, "admin", "admin@example.test", passwordHash, 1024, "active"); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE address='bootstrap@lanqin.local'`); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin' WHERE id=?`, adminID); err != nil {
t.Fatal(err)
}
if err := a.db.Close(); err != nil {
t.Fatal(err)
}
// Old installations had only LANQIN_ADMIN_USERNAME. A webpage update starts
// the new image directly, without running the interactive installer first.
cfg.AdminUsername = "admin"
cfg.AdminEmail = ""
cfg.MailDomain = ""
updated, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = updated.Close() })
var email, loginName string
if err := updated.db.QueryRowContext(ctx, `SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
t.Fatal(err)
}
if email != "admin@example.test" || loginName != "admin@example.test" {
t.Fatalf("legacy administrator resolved incorrectly: email=%q login=%q", email, loginName)
}
if updated.config().AdminEmail != "admin@example.test" {
t.Fatalf("runtime administrator email was not synchronized: %q", updated.config().AdminEmail)
}
var wrongDomainCount int
if err := updated.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email LIKE '%@lanqin.local'`).Scan(&wrongDomainCount); err != nil {
t.Fatal(err)
}
if wrongDomainCount != 0 {
t.Fatalf("web update created a lanqin.local administrator: %d", wrongDomainCount)
}
var migrationResult string
if err := updated.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&migrationResult); err != nil {
t.Fatal(err)
}
if !strings.Contains(migrationResult, `"emailSource":"existing_admin_mailbox"`) {
t.Fatalf("unexpected administrator email source: %s", migrationResult)
}
}
func TestOnlyPrimaryEmailCanLoginSecondaryMailboxCannot(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
domainID := mustDefaultDomainID(t, a)
primary := createTestMailbox(t, admin, domainID, "primary-login", "Primary Login", "Password123!", nil)
secondary := createTestMailbox(t, admin, domainID, "secondary-login", "Secondary Login", "MailboxOnly123!", map[string]any{"ownerEmail": primary.Address})
user := &testClient{t: t, server: ts}
if code := user.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("primary email login code=%d", code)
}
secondaryLogin := &testClient{t: t, server: ts}
if code := secondaryLogin.do("POST", "/api/auth/login", map[string]string{"email": secondary.Address, "password": "MailboxOnly123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("secondary mailbox should not login code=%d", code)
}
}
func TestAdminUserAPICannotCreateOrPromoteAdministrator(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
var errBody map[string]any
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "new-admin@lanqin.local",
"displayName": "New Admin",
"role": "admin",
"password": "Password123!",
"disabled": false,
}, &errBody); code != http.StatusForbidden {
t.Fatalf("create admin code=%d body=%v", code, errBody)
}
var user AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "regular@lanqin.local",
"displayName": "Regular",
"role": "user",
"password": "Password123!",
"disabled": false,
}, &user); code != http.StatusCreated {
t.Fatalf("create user code=%d user=%+v", code, user)
}
if code := admin.do("POST", "/api/admin/users/"+user.ID, map[string]any{
"email": user.Email,
"displayName": user.DisplayName,
"role": "admin",
"disabled": false,
}, &errBody); code != http.StatusForbidden {
t.Fatalf("promote admin code=%d body=%v", code, errBody)
}
}
func TestAdminUsersListOrdersAdministratorThenAZPrimaryEmail(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
for _, user := range []struct {
email string
displayName string
}{
{"zeta@lanqin.local", "Zeta"},
{"Alpha@lanqin.local", "Alpha"},
{"bravo@lanqin.local", "Bravo"},
} {
var created AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": user.email,
"displayName": user.displayName,
"role": "user",
"password": "Password123!",
"disabled": false,
}, &created); code != http.StatusCreated {
t.Fatalf("create user %s code=%d user=%+v", user.email, code, created)
}
}
var users struct {
Items []AdminUser `json:"items"`
}
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
t.Fatalf("list users code=%d users=%+v", code, users.Items)
}
if len(users.Items) < 4 {
t.Fatalf("expected at least 4 users, got %+v", users.Items)
}
got := []string{users.Items[0].Email, users.Items[1].Email, users.Items[2].Email, users.Items[3].Email}
want := []string{"admin@lanqin.local", "alpha@lanqin.local", "bravo@lanqin.local", "zeta@lanqin.local"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("users order=%v want prefix=%v", got, want)
}
}
}
@@ -1548,7 +1936,7 @@ func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.
}
limits := defaultPermissionLimits()
limits.MaxMailboxCount = 1
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), limits)
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), limits)
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &errBody); code != http.StatusForbidden {
t.Fatalf("mailbox count limit code=%d body=%v", code, errBody)
}
@@ -1705,6 +2093,138 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
}
}
func TestAllMailboxBulkMoveToCustomFolderKeepsMailboxIsolation(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
var domainList struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
domainID := domainList.Items[0].ID
primary := createTestMailbox(t, admin, domainID, "bulk-primary", "Bulk Primary", "Password123!", nil)
secondary := createTestMailbox(t, admin, domainID, "bulk-secondary", "Bulk Secondary", "Password456!", map[string]any{"ownerEmail": primary.Address})
otherUserMailbox := createTestMailbox(t, admin, domainID, "bulk-other", "Bulk Other", "Password789!", nil)
if primary.UserID != secondary.UserID {
t.Fatalf("primary and secondary should share owner: primary=%s secondary=%s", primary.UserID, secondary.UserID)
}
if primary.UserID == otherUserMailbox.UserID {
t.Fatalf("other mailbox should belong to a different user")
}
ctx := context.Background()
primaryInboxID, err := a.ensureFolder(ctx, primary.ID, "Inbox")
if err != nil {
t.Fatal(err)
}
secondaryInboxID, err := a.ensureFolder(ctx, secondary.ID, "Inbox")
if err != nil {
t.Fatal(err)
}
otherInboxID, err := a.ensureFolder(ctx, otherUserMailbox.ID, "Inbox")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format(time.RFC3339Nano)
insertMessage := func(id, mailboxID, folderID, subject string) {
t.Helper()
if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, mailboxID, folderID, "", id+"-uid", "<"+id+"@example.test>", subject, "sender@example.test", "", jsonEncode([]string{"recipient@example.test"}), "[]", "[]", now, now, subject, "", "", 0, 0, 0, 0, now, now); err != nil {
t.Fatal(err)
}
}
insertMessage("msg_bulk_primary_move", primary.ID, primaryInboxID, "bulk move primary")
insertMessage("msg_bulk_secondary_move", secondary.ID, secondaryInboxID, "bulk move secondary")
insertMessage("msg_bulk_other_stays", otherUserMailbox.ID, otherInboxID, "bulk move other")
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var allInbox struct {
Items []MailMessage `json:"items"`
}
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&folder=Inbox&q=bulk%20move", nil, &allInbox); code != http.StatusOK || len(allInbox.Items) != 2 {
t.Fatalf("all inbox code=%d items=%+v", code, allInbox.Items)
}
messageIDs := make([]string, 0, len(allInbox.Items)+1)
for _, item := range allInbox.Items {
messageIDs = append(messageIDs, item.ID)
}
messageIDs = append(messageIDs, "msg_bulk_other_stays")
var moved struct {
OK bool `json:"ok"`
Moved int `json:"moved"`
Failed int `json:"failed"`
Message string
Items []struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`
OK bool `json:"ok"`
Message string `json:"message"`
} `json:"items"`
}
if code := userClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": messageIDs, "folder": "跨邮箱项目"}, &moved); code != http.StatusOK {
t.Fatalf("bulk move code=%d body=%+v", code, moved)
}
if moved.OK || moved.Moved != 2 || moved.Failed != 1 || !strings.Contains(moved.Message, "已移动 2 封邮件,1 封失败") || len(moved.Items) != 3 {
t.Fatalf("bulk move summary=%+v", moved)
}
var primaryTargetID, secondaryTargetID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "跨邮箱项目").Scan(&primaryTargetID); err != nil {
t.Fatalf("primary target folder: %v", err)
}
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "跨邮箱项目").Scan(&secondaryTargetID); err != nil {
t.Fatalf("secondary target folder: %v", err)
}
var primaryFolderID, secondaryFolderID, otherFolderID string
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_primary_move").Scan(&primaryFolderID); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_secondary_move").Scan(&secondaryFolderID); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_other_stays").Scan(&otherFolderID); err != nil {
t.Fatal(err)
}
if primaryFolderID != primaryTargetID || secondaryFolderID != secondaryTargetID {
t.Fatalf("messages moved to wrong folders primary=%s want=%s secondary=%s want=%s", primaryFolderID, primaryTargetID, secondaryFolderID, secondaryTargetID)
}
if otherFolderID != otherInboxID {
t.Fatalf("other user's message moved: folder=%s want=%s", otherFolderID, otherInboxID)
}
otherClient := &testClient{t: t, server: ts}
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": otherUserMailbox.Address, "password": "Password789!"}, &login); code != http.StatusOK {
t.Fatalf("other login code=%d", code)
}
var forbidden struct {
OK bool `json:"ok"`
Moved int `json:"moved"`
Failed int `json:"failed"`
Items []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Message string `json:"message"`
} `json:"items"`
}
if code := otherClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": []string{"msg_bulk_primary_move"}, "folder": "Inbox"}, &forbidden); code != http.StatusOK || forbidden.OK || forbidden.Moved != 0 || forbidden.Failed != 1 || len(forbidden.Items) != 1 || forbidden.Items[0].Message != "邮件不存在或无权访问" {
t.Fatalf("other user bulk move primary message code=%d body=%+v", code, forbidden)
}
}
func TestCustomMailFoldersCreateAndMove(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -3338,7 +3858,7 @@ func TestAdminSendAuditAccessAndFilters(t *testing.T) {
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("regular send audit code=%d", code)
}
updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
setRegularPermissionGroupForTest(t, a, []string{PermissionAdminOverview}, defaultPermissionLimits())
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("admin access without messages permission code=%d", code)
}
@@ -4168,11 +4688,15 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
t.Fatal(err)
}
var enabled struct {
User User `json:"user"`
User User `json:"user"`
RecoveryCodes []string `json:"recoveryCodes"`
}
if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled {
t.Fatalf("enable status=%d user=%+v", status, enabled.User)
}
if len(enabled.RecoveryCodes) != 8 {
t.Fatalf("recovery codes=%+v", enabled.RecoveryCodes)
}
fresh := &testClient{t: t, server: ts}
var challenge struct {
@@ -4185,14 +4709,28 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized {
t.Fatalf("wrong challenge status=%d body=%v", status, out)
}
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &login); status != http.StatusOK || fresh.cookie == nil {
t.Fatalf("recovery login status=%d body=%v cookie=%v", status, login, fresh.cookie)
}
reused := &testClient{t: t, server: ts}
if status := reused.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
t.Fatalf("reused challenge status=%d challenge=%+v", status, challenge)
}
if status := reused.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &out); status != http.StatusUnauthorized {
t.Fatalf("reused recovery status=%d body=%v", status, out)
}
totpClient := &testClient{t: t, server: ts}
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
t.Fatalf("totp challenge status=%d challenge=%+v", status, challenge)
}
code, err = generateTOTP(setup.Secret, a.now().UTC())
if err != nil {
t.Fatal(err)
}
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil {
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie)
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || totpClient.cookie == nil {
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, totpClient.cookie)
}
if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
if status := totpClient.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
t.Fatalf("disable status=%d user=%+v", status, enabled.User)
}
}
@@ -4275,9 +4813,20 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
}
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
var regularUpdateErr map[string]any
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Changed Regular",
"description": "Should not change",
"permissions": []string{PermissionAdminOverview},
}, &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 {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -4356,7 +4905,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &plainUser); code != http.StatusCreated {
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
}
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionMailAccess) || userHasPermission(&plainUser.User, PermissionAdminOverview) {
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
}
@@ -4562,7 +5111,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
}
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess), defaultPermissionLimits())
noAccess := &testClient{t: t, server: ts}
if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("no access login code=%d", code)
@@ -4571,7 +5120,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
}
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend), defaultPermissionLimits())
noSend := &testClient{t: t, server: ts}
if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("no send login code=%d", code)
+68 -45
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"errors"
"net/http"
"strings"
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "验证码错误")
return
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
if consumeErr != nil || !ok {
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
return
}
}
a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil {
@@ -50,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
email, err := cleanPrimaryEmail(emailInput)
if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
@@ -107,8 +109,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
email, err := cleanPrimaryEmail(req.Email)
if err != nil {
badRequest(w, errors.New("邮箱地址无效"))
return
}
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
badRequest(w, errors.New("请输入显示名称"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
return
}
parts := strings.SplitN(email, "@", 2)
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
if mailboxLocalPart == "" {
mailboxLocalPart = normalizeLocalPart(parts[0])
}
mailboxDomainID := strings.TrimSpace(req.DomainID)
var mailboxDomain string
if mailboxDomainID != "" {
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
} else {
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
badRequest(w, errors.New("所选邮箱域名不可用"))
} else {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return
}
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
return
}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
if item == mailboxLocalPart {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
}
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -138,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
@@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
}
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
user, err := a.userByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -156,38 +207,6 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
return
}
// Create a mailbox for the registered user
var mailboxDomainID string
var mailboxLocalPart string
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
// User selected a specific domain and local part
mailboxDomainID = strings.TrimSpace(req.DomainID)
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
} else {
// Auto-detect: use the first active domain and email local part
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
mailboxDomainID = ""
}
if mailboxDomainID != "" {
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
}
}
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
}
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
@@ -205,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
return
}
var req struct {
DisplayName string `json:"displayName"`
}
+3 -1
View File
@@ -16,6 +16,7 @@ type Config struct {
SessionTTLHours int
AdminUsername string
AdminEmail string
MailDomain string
AdminPassword string
PublicHostname string
PublicBaseURL string
@@ -72,7 +73,8 @@ func LoadConfig() Config {
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
+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})
}
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
Folder string `json:"folder"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
folder, err := normalizeFolderNameForUser(req.Folder)
if err != nil {
badRequest(w, err)
return
}
ids := make([]string, 0, len(req.IDs))
seen := map[string]bool{}
for _, id := range req.IDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
badRequest(w, errors.New("请选择要移动的邮件"))
return
}
type itemResult struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
OK bool `json:"ok"`
Message string `json:"message"`
}
results := make([]itemResult, 0, len(ids))
folderByMailbox := map[string]string{}
moved := 0
for _, id := range ids {
msg, err := a.loadMessageForRequest(r, id, false)
if err != nil {
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
continue
}
folderID := folderByMailbox[msg.MailboxID]
if folderID == "" {
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
if err != nil {
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
continue
}
folderByMailbox[msg.MailboxID] = folderID
}
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
continue
}
moved++
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
}
failed := len(results) - moved
message := fmt.Sprintf("已移动 %d 封邮件", moved)
if failed > 0 {
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
}
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
+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")
}
var existing string
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
if err == nil {
return existing, nil
}
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if id == PermissionGroupSuperAdmin {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
if intBool(existingSystem) {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
var req struct {
+8 -15
View File
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限配", Description: "查看权限配、权限目录和使用人数。", Category: "权限配"},
{Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"},
{Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsView, Label: "查看权限配", Description: "查看内置和自定义权限配、权限目录和使用人数。", Category: "权限配"},
{Key: PermissionGroupsCreate, Label: "创建权限配", Description: "创建自定义权限配。", Category: "权限配"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配", Description: "修改自定义权限配名称、说明、功能权限和额度。", Category: "权限配"},
{Key: PermissionGroupsDelete, Label: "删除权限配", Description: "删除未被账号使用的自定义权限配。", Category: "权限配"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
{
ID: PermissionGroupSuperAdmin,
Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过权限配分配。",
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
@@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
}
func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
}
adminEmail := normalizeEmail(a.config().AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
return u != nil && u.Role == "admin"
}
func sortPermissionGroups(items []PermissionGroup) {
+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(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
@@ -344,10 +345,9 @@ func bearerToken(r *http.Request) string {
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
loginName := normalizeLoginName(email)
email = normalizeEmail(email)
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
FROM users WHERE login_name=? OR email=?
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
FROM users WHERE email=? LIMIT 1`, email)
var u User
var passwordHash string
var disabled, twoFactorEnabled int
+5 -1
View File
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
if ip := normalizeRemoteIP(remoteIP); ip != "" {
form.Set("remoteip", ip)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
verifyURL := strings.TrimSpace(a.turnstileURL)
if verifyURL == "" {
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
+102 -3
View File
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
func newTwoFactorRecoveryCode() (string, error) {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "", err
}
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
if len(value) > 10 {
value = value[:10]
}
return value[:5] + "-" + value[5:], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return code
}
func totpProvisioningURI(issuer, account, secret string) string {
issuer = strings.TrimSpace(issuer)
account = strings.TrimSpace(account)
@@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
}
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
return nil, err
}
now := a.now().UTC().Format(time.RFC3339Nano)
codes := make([]string, 0, 8)
for len(codes) < 8 {
code, err := newTwoFactorRecoveryCode()
if err != nil {
return nil, err
}
normalized := normalizeRecoveryCode(code)
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
newID("rcv"), userID, hashToken(normalized), now)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
continue
}
return nil, err
}
codes = append(codes, code)
}
return codes, nil
}
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
normalized := normalizeRecoveryCode(code)
if len(normalized) < 8 {
return false, nil
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var id string
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
return
}
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
}
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
@@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
return
}
+15
View File
@@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) {
return username, nil
}
func cleanPrimaryEmail(value string) (string, error) {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") {
return "", errors.New("邮箱地址无效")
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", errors.New("邮箱地址无效")
}
if len([]rune(email)) > 254 {
return "", errors.New("邮箱地址不能超过 254 个字符")
}
return email, nil
}
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))