Sfoglia il codice sorgente

feat: add admin panel and account recovery

AI-Co-Authored-By: Codex
chendeben 1 mese fa
parent
commit
39b753da17

File diff suppressed because it is too large
+ 0 - 1
public/assets/index-CApQI6z4.js


File diff suppressed because it is too large
+ 1 - 0
public/assets/index-CKrGl1I_.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/login-BjasTpWn.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/login-CsuWLBKI.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-CNZcrHZf.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-CeDdzaWW.css


+ 3 - 3
public/index.html

@@ -4,9 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-CApQI6z4.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CGFhCGkE.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
+    <script type="module" crossorigin src="/assets/index-CKrGl1I_.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CNZcrHZf.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-CeDdzaWW.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 3 - 3
public/login.html

@@ -4,9 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-CsuWLBKI.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CGFhCGkE.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
+    <script type="module" crossorigin src="/assets/login-BjasTpWn.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CNZcrHZf.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-CeDdzaWW.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 690 - 14
src/db.js

@@ -6,6 +6,13 @@ import { dkimPublicFromPrivateKey } from './dkim.js';
 
 let db;
 let secretKey = '';
+export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
+const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
+const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
+const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
+const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
+const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
+const maxAccountTokenTtlMinutes = 7 * 24 * 60;
 
 export function initDatabase(dataDir, secret = '') {
   secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
@@ -61,6 +68,17 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
     );
 
+    CREATE TABLE IF NOT EXISTS audit_logs (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      actor_user_id INTEGER,
+      action TEXT NOT NULL,
+      target_type TEXT NOT NULL,
+      target_id TEXT NOT NULL DEFAULT '',
+      target_user_id INTEGER,
+      summary_json TEXT NOT NULL DEFAULT '{}',
+      created_at TEXT NOT NULL
+    );
+
     CREATE TABLE IF NOT EXISTS smtp_credentials (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL UNIQUE,
@@ -83,6 +101,17 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS account_tokens (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      purpose TEXT NOT NULL,
+      token_hash TEXT NOT NULL UNIQUE,
+      expires_at TEXT NOT NULL,
+      used_at TEXT,
+      created_at TEXT NOT NULL,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS dns_credentials (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
@@ -103,7 +132,13 @@ export function initDatabase(dataDir, secret = '') {
     );
 
     CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
+    CREATE INDEX IF NOT EXISTS idx_account_tokens_user_purpose ON account_tokens(user_id, purpose);
+    CREATE INDEX IF NOT EXISTS idx_account_tokens_expires_at ON account_tokens(expires_at);
     CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
+    CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
+    CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs(actor_user_id);
+    CREATE INDEX IF NOT EXISTS idx_audit_logs_target_user_id ON audit_logs(target_user_id);
+    CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
   `);
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
@@ -137,7 +172,8 @@ export function seedAdminUser({ username, password, email }) {
     username: normalizedUsername,
     email: normalizedEmail,
     password,
-    role: 'admin'
+    role: 'admin',
+    status: 'active'
   });
 }
 
@@ -165,9 +201,10 @@ export function seedSmtpCredential(userId, username, password) {
   return saveSmtpCredential(userId, { username, password });
 }
 
-export function createUser({ username, email, password, role = 'user' }) {
+export function createUser({ username, email, password, role = 'user', status = 'active' }) {
   const cleanUsername = normalizeUsername(username);
   const cleanEmail = normalizeEmail(email);
+  const cleanStatus = normalizeUserStatus(status);
   if (!cleanUsername) throw new Error('用户名格式不正确。');
   if (!cleanEmail) throw new Error('邮箱格式不正确。');
   if (String(password || '').length < 8) throw new Error('密码至少需要 8 位。');
@@ -175,15 +212,34 @@ export function createUser({ username, email, password, role = 'user' }) {
   const result = requireDb()
     .prepare(`
       INSERT INTO users (username, email, password_hash, role, status, created_at, updated_at)
-      VALUES (?, ?, ?, ?, 'active', ?, ?)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
     `)
-    .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', createdAt, createdAt);
+    .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', cleanStatus, createdAt, createdAt);
   return getUser(result.lastInsertRowid);
 }
 
+export function createUserWithAccountToken(userInput, tokenPurpose, { ttlMinutes } = {}) {
+  const database = requireDb();
+  database.exec('BEGIN');
+  try {
+    const user = createUser(userInput);
+    const accountToken = createAccountToken(user.id, tokenPurpose, { ttlMinutes });
+    database.exec('COMMIT');
+    return { user, accountToken };
+  } catch (error) {
+    database.exec('ROLLBACK');
+    throw error;
+  }
+}
+
 export function authenticateUser(login, password) {
+  const user = verifyUserCredentials(login, password);
+  return user?.status === 'active' ? user : null;
+}
+
+export function verifyUserCredentials(login, password) {
   const user = getUserByLogin(login, { includeHash: true });
-  if (!user || user.status !== 'active' || !verifyPassword(password, user.passwordHash)) return null;
+  if (!user || !verifyPassword(password, user.passwordHash)) return null;
   return publicUser(user);
 }
 
@@ -194,6 +250,272 @@ export function listUsers() {
     .map(publicUser);
 }
 
+export function listUsersWithResourceCounts() {
+  return requireDb()
+    .prepare(`
+      SELECT
+        users.*,
+        (SELECT COUNT(*) FROM domains WHERE domains.user_id = users.id) AS domains_count,
+        (SELECT COUNT(*) FROM dns_credentials WHERE dns_credentials.user_id = users.id) AS dns_credentials_count,
+        (SELECT COUNT(*) FROM api_tokens WHERE api_tokens.user_id = users.id) AS api_tokens_count,
+        (SELECT COUNT(*) FROM send_events WHERE send_events.user_id = users.id) AS send_events_count,
+        (SELECT COUNT(*) FROM smtp_credentials WHERE smtp_credentials.user_id = users.id) AS smtp_credentials_count
+      FROM users
+      ORDER BY users.created_at DESC
+    `)
+    .all()
+    .map((row) => ({
+      ...publicUser(row),
+      resourceCounts: {
+        domains: Number(row.domains_count || 0),
+        dnsCredentials: Number(row.dns_credentials_count || 0),
+        apiTokens: Number(row.api_tokens_count || 0),
+        sendEvents: Number(row.send_events_count || 0),
+        smtpCredential: Number(row.smtp_credentials_count || 0) > 0 ? 1 : 0
+      }
+    }));
+}
+
+export function getAdminResourceInventory() {
+  const users = listUsersWithResourceCounts();
+  const domains = requireDb()
+    .prepare('SELECT * FROM domains ORDER BY user_id, created_at DESC')
+    .all()
+    .map(publicDomainRow);
+  const dnsCredentials = requireDb()
+    .prepare('SELECT * FROM dns_credentials ORDER BY user_id, created_at DESC')
+    .all()
+    .map(publicDnsCredential);
+  const smtpCredentials = requireDb()
+    .prepare('SELECT * FROM smtp_credentials ORDER BY user_id')
+    .all()
+    .map(publicSmtpCredential);
+  const apiTokens = requireDb()
+    .prepare('SELECT * FROM api_tokens ORDER BY user_id, created_at DESC')
+    .all()
+    .map(publicApiToken);
+  const sendEventCounts = new Map(
+    requireDb()
+      .prepare('SELECT user_id, COUNT(*) AS count FROM send_events GROUP BY user_id')
+      .all()
+      .map((row) => [row.user_id, Number(row.count || 0)])
+  );
+  const dnsCredentialById = new Map(dnsCredentials.map((credential) => [credential.id, credential]));
+
+  return {
+    users: users.map((user) => ({
+      user,
+      domains: domains.filter((domain) => domain.userId === user.id),
+      dnsCredentials: dnsCredentials.filter((credential) => credential.userId === user.id),
+      smtpCredential: smtpCredentials.find((credential) => credential.userId === user.id) || null,
+      apiTokens: apiTokens.filter((token) => token.userId === user.id),
+      sendEventCount: sendEventCounts.get(user.id) || 0
+    })),
+    warnings: domains.flatMap((domain) => {
+      if (!domain.dnsCredentialId) return [];
+      const credential = dnsCredentialById.get(domain.dnsCredentialId);
+      if (!credential || credential.userId === domain.userId) return [];
+      return [{
+        type: 'domain_dns_credential_owner_mismatch',
+        domainId: domain.id,
+        domain: domain.domain,
+        domainUserId: domain.userId,
+        dnsCredentialId: credential.id,
+        dnsCredentialUserId: credential.userId
+      }];
+    })
+  };
+}
+
+export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
+  return withTransaction(() => {
+    const target = requireTransferTargetUser(targetUserId);
+    const domain = requireDomainRow(domainId);
+    const mode = normalizeDnsCredentialTransferMode(dnsCredentialMode);
+    const nextDnsCredentialId = mode === 'clear_dns_credential' ? null : domain.dns_credential_id;
+    requireDb()
+      .prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
+      .run(target.id, nextDnsCredentialId, now(), domain.id);
+    if (mode === 'with_dns_credential' && domain.dns_credential_id) {
+      const credential = requireDnsCredentialRow(domain.dns_credential_id);
+      if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
+      requireDb()
+        .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
+        .run(target.id, now(), domain.dns_credential_id);
+    }
+    const updated = getDomain(domain.id);
+    logAudit({
+      actorUserId,
+      action: 'admin.transfer_domain',
+      targetType: 'domain',
+      targetId: String(domain.id),
+      targetUserId: target.id,
+      summary: {
+        domain: domain.domain,
+        fromUserId: domain.user_id,
+        toUserId: target.id,
+        dnsCredentialMode: mode,
+        dnsCredentialId: domain.dns_credential_id || null
+      }
+    });
+    return updated;
+  });
+}
+
+export function transferDnsCredential({ actorUserId, credentialId, targetUserId }) {
+  return withTransaction(() => {
+    const target = requireTransferTargetUser(targetUserId);
+    const credential = requireDnsCredentialRow(credentialId);
+    requireDb()
+      .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
+      .run(target.id, now(), credential.id);
+    const updated = publicDnsCredential(requireDnsCredentialRow(credential.id));
+    logAudit({
+      actorUserId,
+      action: 'admin.transfer_dns_credential',
+      targetType: 'dns_credential',
+      targetId: String(credential.id),
+      targetUserId: target.id,
+      summary: {
+        name: credential.name,
+        provider: credential.provider,
+        fromUserId: credential.user_id,
+        toUserId: target.id
+      }
+    });
+    return updated;
+  });
+}
+
+export function transferApiTokens({ actorUserId, tokenIds, targetUserId }) {
+  return withTransaction(() => {
+    const target = requireTransferTargetUser(targetUserId);
+    const ids = uniquePositiveIds(tokenIds);
+    if (!ids.length) throw new Error('API Token 不存在。');
+    const placeholders = ids.map(() => '?').join(', ');
+    const tokens = requireDb()
+      .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders})`)
+      .all(...ids);
+    if (tokens.length !== ids.length) throw new Error('API Token 不存在。');
+    requireDb()
+      .prepare(`UPDATE api_tokens SET user_id = ? WHERE id IN (${placeholders})`)
+      .run(target.id, ...ids);
+    const updated = requireDb()
+      .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders}) ORDER BY created_at DESC`)
+      .all(...ids)
+      .map(publicApiToken);
+    logAudit({
+      actorUserId,
+      action: 'admin.transfer_api_tokens',
+      targetType: 'api_token',
+      targetId: ids.join(','),
+      targetUserId: target.id,
+      summary: {
+        tokenIds: ids,
+        count: ids.length,
+        fromUserIds: [...new Set(tokens.map((token) => token.user_id))],
+        toUserId: target.id
+      }
+    });
+    return updated;
+  });
+}
+
+export function previewUserMerge({ sourceUserId, targetUserId }) {
+  const { source, target } = requireMergeUsers(sourceUserId, targetUserId);
+  const sourceSmtp = getSmtpCredential(source.id);
+  const targetSmtp = getSmtpCredential(target.id);
+  const counts = {
+    domains: countRows('domains', source.id),
+    dnsCredentials: countRows('dns_credentials', source.id),
+    apiTokens: countRows('api_tokens', source.id),
+    sendEvents: countRows('send_events', source.id),
+    smtpCredential: sourceSmtp ? 1 : 0
+  };
+  const smtpConflict = Boolean(sourceSmtp && targetSmtp);
+  const defaultOptions = {
+    transferDomains: true,
+    transferDnsCredentials: true,
+    transferApiTokens: true,
+    transferSendEvents: true,
+    transferSmtpCredential: Boolean(sourceSmtp && !targetSmtp),
+    disableSource: true
+  };
+  const selectedCounts = {
+    domains: counts.domains,
+    dnsCredentials: counts.dnsCredentials,
+    apiTokens: counts.apiTokens,
+    sendEvents: counts.sendEvents,
+    smtpCredential: defaultOptions.transferSmtpCredential ? counts.smtpCredential : 0
+  };
+  return {
+    sourceUser: source,
+    targetUser: target,
+    confirmationText: `MERGE ${source.username} INTO ${target.username}`,
+    counts,
+    selectedCounts,
+    defaultOptions,
+    resources: {
+      source: mergeResourcesForUser(source.id),
+      target: mergeResourcesForUser(target.id)
+    },
+    smtp: {
+      sourceCredential: sourceSmtp,
+      targetCredential: targetSmtp,
+      conflict: smtpConflict
+    },
+    warnings: smtpConflict ? [{
+      type: 'smtp_credential_conflict',
+      message: '目标用户已有 SMTP 凭据,源用户 SMTP 凭据需要手动处理。'
+    }] : []
+  };
+}
+
+export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, options = {}, confirmation }) {
+  return withTransaction(() => {
+    const preview = previewUserMerge({ sourceUserId, targetUserId });
+    if (confirmation !== preview.confirmationText) throw new Error('确认文本不匹配。');
+    const sourceId = preview.sourceUser.id;
+    const targetId = preview.targetUser.id;
+    const counts = {
+      domains: options.transferDomains === false ? 0 : moveRows('domains', sourceId, targetId),
+      dnsCredentials: options.transferDnsCredentials === false ? 0 : moveRows('dns_credentials', sourceId, targetId),
+      apiTokens: options.transferApiTokens === false ? 0 : moveRows('api_tokens', sourceId, targetId),
+      sendEvents: options.transferSendEvents === false ? 0 : moveRows('send_events', sourceId, targetId),
+      smtpCredential: 0
+    };
+    if (options.transferSmtpCredential !== false && preview.smtp.sourceCredential && !preview.smtp.targetCredential) {
+      counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
+    }
+    if (options.disableSource !== false) {
+      requireDb()
+        .prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
+        .run(now(), sourceId);
+    }
+    logAudit({
+      actorUserId,
+      action: 'admin.user_merge',
+      targetType: 'user',
+      targetId: String(targetId),
+      targetUserId: targetId,
+      summary: {
+        sourceUserId: sourceId,
+        sourceUsername: preview.sourceUser.username,
+        targetUserId: targetId,
+        targetUsername: preview.targetUser.username,
+        counts,
+        warnings: preview.warnings
+      }
+    });
+    return {
+      sourceUser: getUser(sourceId),
+      targetUser: getUser(targetId),
+      counts,
+      warnings: preview.warnings
+    };
+  });
+}
+
 export function getUser(id, { includeHash = false } = {}) {
   const row = requireDb().prepare('SELECT * FROM users WHERE id = ?').get(id);
   return includeHash ? privateUser(row) : publicUser(row);
@@ -211,18 +533,41 @@ export function getUserByLogin(login, { includeHash = false } = {}) {
 export function updateUser(id, patch) {
   const current = getUser(id, { includeHash: true });
   if (!current) return null;
+  const passwordChanged = String(patch.password || '').length > 0;
+  if (passwordChanged && String(patch.password).length < 8) throw new Error('密码至少需要 8 位。');
   const next = {
     role: patch.role === 'admin' ? 'admin' : current.role,
-    status: ['active', 'disabled'].includes(patch.status) ? patch.status : current.status,
-    passwordHash: patch.password ? hashPassword(patch.password) : current.passwordHash,
+    status: patch.status === undefined ? current.status : normalizeUserStatus(patch.status),
+    passwordHash: passwordChanged ? hashPassword(patch.password) : current.passwordHash,
     updatedAt: now()
   };
   requireDb()
     .prepare('UPDATE users SET role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ?')
     .run(next.role, next.status, next.passwordHash, next.updatedAt, id);
+  if (passwordChanged) invalidateAccountTokens(id, 'password_reset');
+  return getUser(id);
+}
+
+export function updateUserStatus(id, status) {
+  const nextStatus = normalizeUserStatus(status);
+  if (!getUser(id)) return null;
+  requireDb()
+    .prepare('UPDATE users SET status = ?, updated_at = ? WHERE id = ?')
+    .run(nextStatus, now(), id);
   return getUser(id);
 }
 
+export function approveUser(id) {
+  return updateUserStatus(id, 'active');
+}
+
+export function markUserEmailVerified(id) {
+  const user = getUser(id);
+  if (!user) return null;
+  if (user.status !== 'pending_email') return user;
+  return updateUserStatus(id, 'pending_review');
+}
+
 export function getAdminUser() {
   const row = requireDb()
     .prepare("SELECT * FROM users WHERE role = 'admin' AND status = 'active' ORDER BY id LIMIT 1")
@@ -333,6 +678,43 @@ export function deleteDomain(id, userId) {
   return result.changes > 0;
 }
 
+export function logAudit({ actorUserId, action, targetType, targetId = '', targetUserId = null, summary = {} }) {
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO audit_logs (
+        actor_user_id, action, target_type, target_id, target_user_id, summary_json, created_at
+      )
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `)
+    .run(
+      actorUserId ?? null,
+      String(action || ''),
+      String(targetType || ''),
+      String(targetId ?? ''),
+      targetUserId ?? null,
+      JSON.stringify(sanitizeAuditSummary(summary)),
+      now()
+    );
+  return result.lastInsertRowid;
+}
+
+export function listAuditLogs(filters = {}) {
+  const where = [];
+  const params = [];
+  addAuditFilter(where, params, 'actor_user_id', filters.actorUserId);
+  addAuditFilter(where, params, 'target_user_id', filters.targetUserId);
+  addAuditFilter(where, params, 'action', filters.action);
+  addAuditDateFilter(where, params, 'created_at', '>=', filters.from);
+  addAuditDateFilter(where, params, 'created_at', '<=', filters.to);
+  const query = `
+    SELECT *
+    FROM audit_logs
+    ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+    ORDER BY created_at DESC, id DESC
+  `;
+  return requireDb().prepare(query).all(...params).map(publicAuditLog);
+}
+
 export function logSendEvent(event) {
   const queueId = normalizeQueueId(event.queueId || extractQueueIdFromText(event.detail));
   const result = requireDb()
@@ -631,6 +1013,54 @@ export function verifyApiToken(token) {
   };
 }
 
+export function createAccountToken(userId, purpose, { ttlMinutes } = {}) {
+  const cleanPurpose = normalizeAccountTokenPurpose(purpose);
+  const ttl = Number(ttlMinutes);
+  if (!Number.isInteger(ttl) || ttl < 1 || ttl > maxAccountTokenTtlMinutes) throw new Error('令牌有效期不正确。');
+  const token = crypto.randomBytes(32).toString('base64url');
+  const createdAt = now();
+  const expiresAt = new Date(Date.now() + ttl * 60 * 1000).toISOString();
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO account_tokens (user_id, purpose, token_hash, expires_at, created_at)
+      VALUES (?, ?, ?, ?, ?)
+    `)
+    .run(userId, cleanPurpose, tokenHash(token), expiresAt, createdAt);
+  return {
+    ...publicAccountToken(getAccountTokenRow(result.lastInsertRowid)),
+    token
+  };
+}
+
+export function consumeAccountToken(token, purpose) {
+  const rawToken = String(token || '');
+  const cleanPurpose = String(purpose || '').trim();
+  if (!rawToken || !cleanPurpose) return null;
+  const tokenDigest = tokenHash(rawToken);
+  const usedAt = now();
+  const result = requireDb()
+    .prepare(`
+      UPDATE account_tokens
+      SET used_at = ?
+      WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?
+    `)
+    .run(usedAt, tokenDigest, cleanPurpose, usedAt);
+  if (result.changes === 0) return null;
+  const row = requireDb()
+    .prepare('SELECT * FROM account_tokens WHERE token_hash = ? AND purpose = ?')
+    .get(tokenDigest, cleanPurpose);
+  return publicAccountToken(row);
+}
+
+export function invalidateAccountTokens(userId, purpose) {
+  const cleanPurpose = String(purpose || '').trim();
+  if (!userId || !cleanPurpose) return 0;
+  const result = requireDb()
+    .prepare('UPDATE account_tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL')
+    .run(now(), userId, cleanPurpose);
+  return result.changes;
+}
+
 export function listDnsCredentials(userId) {
   return requireDb()
     .prepare('SELECT * FROM dns_credentials WHERE user_id = ? ORDER BY created_at DESC')
@@ -707,17 +1137,157 @@ export function saveSettings(patch) {
   const updatedAt = now();
   for (const [key, value] of Object.entries(patch)) {
     if (!allowed.has(key)) continue;
-    requireDb()
-      .prepare(`
-        INSERT INTO app_settings (key, value, updated_at)
-        VALUES (?, ?, ?)
-        ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
-      `)
-      .run(key, String(value ?? ''), updatedAt);
+    saveAppSetting(key, String(value ?? ''), updatedAt);
   }
   return getSettings();
 }
 
+export function getSystemEmailSettings({ includeSecret = false } = {}) {
+  const rows = requireDb()
+    .prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'")
+    .all();
+  const values = Object.fromEntries(
+    rows.map((row) => [String(row.key).replace(/^systemEmail\./, ''), row.value])
+  );
+  const passwordSecret = values.passwordSecret || '';
+  const settings = {
+    host: values.host || '',
+    port: normalizePort(values.port, 587),
+    secure: values.secure === 'true',
+    username: values.username || '',
+    helo: values.helo || '',
+    fromEmail: values.fromEmail || '',
+    fromName: values.fromName || '',
+    testRecipient: values.testRecipient || '',
+    passwordSet: Boolean(passwordSecret)
+  };
+  if (includeSecret) settings.password = decryptSecret(passwordSecret);
+  return settings;
+}
+
+export function saveSystemEmailSettings(patch = {}) {
+  const current = getSystemEmailSettings({ includeSecret: true });
+  const next = {
+    host: patch.host ?? current.host,
+    port: patch.port ?? current.port,
+    secure: patch.secure ?? current.secure,
+    username: patch.username ?? current.username,
+    helo: patch.helo ?? current.helo,
+    fromEmail: patch.fromEmail ?? current.fromEmail,
+    fromName: patch.fromName ?? current.fromName,
+    testRecipient: patch.testRecipient ?? current.testRecipient
+  };
+  const passwordSecret = Object.hasOwn(patch, 'password') && String(patch.password || '')
+    ? encryptSecret(patch.password)
+    : requireDb()
+        .prepare("SELECT value FROM app_settings WHERE key = 'systemEmail.passwordSecret'")
+        .get()?.value || '';
+  const updatedAt = now();
+  const values = {
+    host: String(next.host || ''),
+    port: String(normalizePort(next.port, 587)),
+    secure: boolString(next.secure),
+    username: String(next.username || ''),
+    helo: String(next.helo || ''),
+    fromEmail: normalizeEmail(next.fromEmail),
+    fromName: String(next.fromName || ''),
+    testRecipient: normalizeEmail(next.testRecipient),
+    passwordSecret
+  };
+  for (const [key, value] of Object.entries(values)) {
+    saveAppSetting(`systemEmail.${key}`, value, updatedAt);
+  }
+  return getSystemEmailSettings();
+}
+
+function saveAppSetting(key, value, updatedAt = now()) {
+  requireDb()
+    .prepare(`
+      INSERT INTO app_settings (key, value, updated_at)
+      VALUES (?, ?, ?)
+      ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+    `)
+    .run(key, String(value ?? ''), updatedAt);
+}
+
+function withTransaction(callback) {
+  const database = requireDb();
+  database.exec('BEGIN');
+  try {
+    const result = callback();
+    database.exec('COMMIT');
+    return result;
+  } catch (error) {
+    database.exec('ROLLBACK');
+    throw error;
+  }
+}
+
+function requireTransferTargetUser(targetUserId) {
+  const target = getUser(Number(targetUserId));
+  if (!target || target.status === 'disabled') throw new Error('目标用户不可用。');
+  return target;
+}
+
+function requireMergeUsers(sourceUserId, targetUserId) {
+  const source = getUser(Number(sourceUserId));
+  const target = requireTransferTargetUser(targetUserId);
+  if (!source) throw new Error('源用户不存在。');
+  if (source.id === target.id) throw new Error('源用户和目标用户不能相同。');
+  return { source, target };
+}
+
+function mergeResourcesForUser(userId) {
+  return {
+    domains: listDomains(userId),
+    dnsCredentials: listDnsCredentials(userId),
+    apiTokens: listApiTokens(userId),
+    sendEventCount: countRows('send_events', userId),
+    smtpCredential: getSmtpCredential(userId)
+  };
+}
+
+function countRows(table, userId) {
+  return Number(requireDb().prepare(`SELECT COUNT(*) AS count FROM ${mergeResourceTable(table)} WHERE user_id = ?`).get(userId).count || 0);
+}
+
+function moveRows(table, sourceUserId, targetUserId) {
+  const result = requireDb()
+    .prepare(`UPDATE ${mergeResourceTable(table)} SET user_id = ? WHERE user_id = ?`)
+    .run(targetUserId, sourceUserId);
+  return result.changes;
+}
+
+function mergeResourceTable(table) {
+  if (!['domains', 'dns_credentials', 'api_tokens', 'send_events', 'smtp_credentials'].includes(table)) {
+    throw new Error('资源类型不正确。');
+  }
+  return table;
+}
+
+function requireDomainRow(domainId) {
+  const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
+  if (!domain) throw new Error('域名不存在。');
+  return domain;
+}
+
+function requireDnsCredentialRow(credentialId) {
+  const credential = requireDb().prepare('SELECT * FROM dns_credentials WHERE id = ?').get(Number(credentialId));
+  if (!credential) throw new Error('DNS 凭据不存在。');
+  return credential;
+}
+
+function normalizeDnsCredentialTransferMode(value) {
+  const mode = String(value || 'domain_only').trim();
+  return ['domain_only', 'with_dns_credential', 'clear_dns_credential'].includes(mode) ? mode : 'domain_only';
+}
+
+function uniquePositiveIds(values) {
+  return [...new Set((Array.isArray(values) ? values : [values])
+    .map((value) => Number(value))
+    .filter((value) => Number.isInteger(value) && value > 0))];
+}
+
 function migrateLegacySmtpTable() {
   if (!tableExists('smtp_credentials') || columnExists('smtp_credentials', 'user_id')) return;
   if (!tableExists('smtp_credentials_legacy')) {
@@ -849,6 +1419,22 @@ function publicApiToken(row) {
   };
 }
 
+function getAccountTokenRow(id) {
+  return requireDb().prepare('SELECT * FROM account_tokens WHERE id = ?').get(id);
+}
+
+function publicAccountToken(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    purpose: row.purpose,
+    expiresAt: row.expires_at,
+    usedAt: row.used_at,
+    createdAt: row.created_at
+  };
+}
+
 function publicDnsCredential(row) {
   if (!row) return null;
   return {
@@ -864,16 +1450,51 @@ function publicDnsCredential(row) {
   };
 }
 
+function publicAuditLog(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    actorUserId: row.actor_user_id,
+    action: row.action,
+    targetType: row.target_type,
+    targetId: row.target_id,
+    targetUserId: row.target_user_id,
+    summary: safeJson(row.summary_json, {}),
+    createdAt: row.created_at
+  };
+}
+
 function normalizeUsername(value) {
   const username = String(value || '').trim().toLowerCase();
   return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
 }
 
+function normalizeUserStatus(value) {
+  const status = String(value || '').trim();
+  if (!USER_STATUSES.has(status)) throw new Error('用户状态不正确。');
+  return status;
+}
+
+function normalizeAccountTokenPurpose(value) {
+  const purpose = String(value || '').trim();
+  if (!purpose) throw new Error('账号令牌用途不能为空。');
+  return purpose;
+}
+
 function normalizeEmail(value) {
   const email = String(value || '').trim().toLowerCase();
   return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
 }
 
+function normalizePort(value, fallback) {
+  const port = Number(value);
+  return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
+}
+
+function boolString(value) {
+  return value === true || String(value).toLowerCase() === 'true' ? 'true' : 'false';
+}
+
 function normalizeProvider(value) {
   const provider = String(value || '').trim().toLowerCase();
   return ['cloudflare', 'aliyun', 'dnspod'].includes(provider) ? provider : '';
@@ -929,6 +1550,61 @@ function safeJson(value, fallback) {
   }
 }
 
+function sanitizeAuditSummary(value, parentKey = '') {
+  if (Array.isArray(value)) return value.map((item) => sanitizeAuditSummary(item, parentKey));
+  if (!value || typeof value !== 'object') return value;
+  const hasSensitiveDescriptor = hasSensitiveAuditDescriptor(value);
+  const output = {};
+  for (const [key, child] of Object.entries(value)) {
+    if (auditSecretKeyPattern.test(key) && !isSafeAuditStateKey(key, child, parentKey)) continue;
+    if (hasSensitiveDescriptor && auditValueLikeKeyPattern.test(key)) continue;
+    output[key] = sanitizeAuditSummary(child, key);
+  }
+  return output;
+}
+
+function isSafeAuditStateKey(key, value, parentKey) {
+  return (key === 'passwordSet' && typeof value === 'boolean') ||
+    (parentKey === 'counts' && typeof value === 'number');
+}
+
+function hasSensitiveAuditDescriptor(value) {
+  return Object.entries(value).some(([key, child]) => (
+    auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
+  )) || Object.entries(value).some(([key, child]) => (
+    auditDescriptorWrapperKeyPattern.test(key) && hasDirectSensitiveAuditDescriptor(child)
+  ));
+}
+
+function hasDirectSensitiveAuditDescriptor(value) {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+  return Object.entries(value).some(([key, child]) => (
+    auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
+  ));
+}
+
+function isSensitiveAuditDescriptorValue(value) {
+  if (Array.isArray(value)) return value.some(isSensitiveAuditDescriptorValue);
+  if (value && typeof value === 'object') return Object.values(value).some(isSensitiveAuditDescriptorValue);
+  return auditDescriptorValuePattern.test(String(value ?? ''));
+}
+
+function addAuditFilter(where, params, column, value) {
+  if (value === undefined) return;
+  if (value === null) {
+    where.push(`${column} IS NULL`);
+    return;
+  }
+  where.push(`${column} = ?`);
+  params.push(value);
+}
+
+function addAuditDateFilter(where, params, column, operator, value) {
+  if (value === undefined || value === null || value === '') return;
+  where.push(`${column} ${operator} ?`);
+  params.push(value);
+}
+
 function normalizeQueueId(value) {
   return String(value || '').trim().toUpperCase();
 }

+ 5 - 0
src/frontend/App.tsx

@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
 
 import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
 import { AdminLayout } from '../layouts/AdminLayout';
+import AdminPage from '../pages/Admin';
 import ApiTokens from '../pages/ApiTokens';
 import Dashboard from '../pages/Dashboard';
 import DnsApi from '../pages/DnsApi';
@@ -50,6 +51,7 @@ const viewTitleKeys: Record<ViewKey, string> = {
   tokens: 'nav.tokens',
   logs: 'nav.logs',
   webhooks: 'nav.webhooks',
+  admin: 'nav.admin',
   settings: 'nav.settings'
 };
 
@@ -477,6 +479,9 @@ function MailHubConsole() {
     if (activeView === 'logs') {
       return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} />;
     }
+    if (activeView === 'admin') {
+      return <AdminPage me={data.me} />;
+    }
     if (activeView === 'settings') {
       return (
         <Settings

+ 158 - 21
src/frontend/auth/AuthApp.tsx

@@ -10,8 +10,11 @@ import { Alert, Button, Card, Form, Input, Segmented, Select, Space, Typography
 import { useEffect, useMemo, useState, type ReactNode } from 'react';
 
 import { useI18n } from '../i18n/react';
+import { api } from '../services/api';
+import { authModeFromLocation, nextAuthSuccessState } from './auth-model';
 
-type AuthMode = 'login' | 'register';
+type AuthMode = 'login' | 'register' | 'forgot' | 'reset' | 'resend';
+type AlertKind = 'error' | 'success';
 
 interface LoginValues {
   username: string;
@@ -22,21 +25,39 @@ interface RegisterValues extends LoginValues {
   email: string;
 }
 
+interface EmailValues {
+  email: string;
+}
+
+interface ResetPasswordValues {
+  password: string;
+}
+
 export function AuthApp() {
   const { locale, locales, setLocale, t } = useI18n();
-  const [mode, setMode] = useState<AuthMode>(() => window.location.pathname === '/register' ? 'register' : 'login');
+  const initialAuth = authModeFromLocation(window.location.pathname, window.location.search);
+  const [mode, setMode] = useState<AuthMode>(initialAuth.mode as AuthMode);
+  const [resetToken, setResetToken] = useState(initialAuth.token);
   const [message, setMessage] = useState('');
+  const [messageKind, setMessageKind] = useState<AlertKind>('error');
   const [loading, setLoading] = useState(false);
   const [loginForm] = Form.useForm<LoginValues>();
   const [registerForm] = Form.useForm<RegisterValues>();
+  const [emailForm] = Form.useForm<EmailValues>();
+  const [resetForm] = Form.useForm<ResetPasswordValues>();
 
   useEffect(() => {
     const params = new URLSearchParams(window.location.search);
     const error = params.get('error');
     if (error) {
       setMessage(error);
+      setMessageKind('error');
       window.history.replaceState(null, '', window.location.pathname);
     }
+    if (initialAuth.mode === 'reset' && !initialAuth.token) {
+      setMessage(t('auth.resetTokenMissing'));
+      setMessageKind('error');
+    }
   }, []);
 
   const modeOptions = useMemo(() => [
@@ -47,6 +68,7 @@ export function AuthApp() {
   async function submit(path: string, values: LoginValues | RegisterValues) {
     setLoading(true);
     setMessage('');
+    setMessageKind('error');
     try {
       const response = await fetch(path, {
         method: 'POST',
@@ -55,14 +77,71 @@ export function AuthApp() {
       });
       const data = await response.json().catch(() => ({}));
       if (!response.ok) throw new Error(data.error || t('auth.requestFailed'));
-      window.location.href = '/';
+      const next = nextAuthSuccessState(path, data);
+      if (next.redirectTo) {
+        window.location.href = next.redirectTo;
+        return;
+      }
+      setAuthMode(next.mode as AuthMode);
+      setMessage(next.message);
+      setMessageKind('success');
+    } catch (error) {
+      setMessage(error instanceof Error ? error.message : t('auth.requestFailed'));
+      setMessageKind('error');
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function submitAccountEmail(kind: 'forgot' | 'resend', values: EmailValues) {
+    setLoading(true);
+    setMessage('');
+    setMessageKind('error');
+    try {
+      const result = kind === 'forgot'
+        ? await api.forgotPassword(values.email)
+        : await api.resendVerification(values.email);
+      setMessage(result.message);
+      setMessageKind('success');
+    } catch (error) {
+      setMessage(error instanceof Error ? error.message : t('auth.requestFailed'));
+      setMessageKind('error');
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function submitResetPassword(values: ResetPasswordValues) {
+    if (!resetToken) {
+      setMessage(t('auth.resetTokenMissing'));
+      setMessageKind('error');
+      return;
+    }
+    setLoading(true);
+    setMessage('');
+    setMessageKind('error');
+    try {
+      const result = await api.resetPassword(resetToken, values.password);
+      setAuthMode('login');
+      setResetToken('');
+      setMessage(result.message);
+      setMessageKind('success');
+      resetForm.resetFields();
     } catch (error) {
       setMessage(error instanceof Error ? error.message : t('auth.requestFailed'));
+      setMessageKind('error');
     } finally {
       setLoading(false);
     }
   }
 
+  function setAuthMode(nextMode: AuthMode) {
+    setMode(nextMode);
+    setMessage('');
+    setMessageKind('error');
+    window.history.replaceState(null, '', authPathForMode(nextMode));
+  }
+
   return (
     <main className="auth-page">
       <section className="auth-brand-panel">
@@ -88,26 +167,21 @@ export function AuthApp() {
           <Space direction="vertical" size={22} className="full-width">
             <div className="auth-heading">
               <Typography.Text className="auth-eyebrow">
-                {mode === 'login' ? t('auth.loginEyebrow') : t('auth.registerEyebrow')}
+                {mode === 'register' ? t('auth.registerEyebrow') : t('auth.loginEyebrow')}
               </Typography.Text>
-              <Typography.Title level={2}>
-                {mode === 'login' ? t('auth.loginTitle') : t('auth.registerTitle')}
-              </Typography.Title>
+              <Typography.Title level={2}>{authTitle(mode, t)}</Typography.Title>
             </div>
 
-            <Segmented
-              block
-              value={mode}
-              options={modeOptions}
-              onChange={(value) => {
-                const nextMode = value as AuthMode;
-                setMode(nextMode);
-                setMessage('');
-                window.history.replaceState(null, '', nextMode === 'register' ? '/register' : '/login');
-              }}
-            />
+            {mode === 'login' || mode === 'register' ? (
+              <Segmented
+                block
+                value={mode}
+                options={modeOptions}
+                onChange={(value) => setAuthMode(value as AuthMode)}
+              />
+            ) : null}
 
-            {message ? <Alert type="error" showIcon message={message} /> : null}
+            {message ? <Alert type={messageKind} showIcon message={message} /> : null}
 
             {mode === 'login' ? (
               <Form form={loginForm} layout="vertical" onFinish={(values) => submit('/api/login', values)} requiredMark={false}>
@@ -120,8 +194,18 @@ export function AuthApp() {
                 <Button type="primary" htmlType="submit" loading={loading} block size="large">
                   {t('auth.submitLogin')}
                 </Button>
+                <div className="auth-link-row">
+                  <Button type="link" onClick={() => setAuthMode('forgot')}>
+                    {t('auth.forgotPassword')}
+                  </Button>
+                  <Button type="link" onClick={() => setAuthMode('resend')}>
+                    {t('auth.resendVerification')}
+                  </Button>
+                </div>
               </Form>
-            ) : (
+            ) : null}
+
+            {mode === 'register' ? (
               <Form form={registerForm} layout="vertical" onFinish={(values) => submit('/api/register', values)} requiredMark={false}>
                 <Form.Item name="username" label={t('auth.username')} rules={[{ required: true, min: 3, message: t('auth.username') }]}>
                   <Input prefix={<UserOutlined />} autoComplete="username" autoFocus />
@@ -136,7 +220,40 @@ export function AuthApp() {
                   {t('auth.registerButton')}
                 </Button>
               </Form>
-            )}
+            ) : null}
+
+            {mode === 'forgot' || mode === 'resend' ? (
+              <Form
+                form={emailForm}
+                layout="vertical"
+                onFinish={(values) => submitAccountEmail(mode, values)}
+                requiredMark={false}
+              >
+                <Form.Item name="email" label={t('auth.email')} rules={[{ required: true, type: 'email', message: t('auth.email') }]}>
+                  <Input prefix={<MailOutlined />} autoComplete="email" placeholder={t('auth.emailPlaceholder')} autoFocus />
+                </Form.Item>
+                <Button type="primary" htmlType="submit" loading={loading} block size="large">
+                  {mode === 'forgot' ? t('auth.forgotPasswordButton') : t('auth.resendVerificationButton')}
+                </Button>
+                <Button type="link" block onClick={() => setAuthMode('login')}>
+                  {t('auth.backToLogin')}
+                </Button>
+              </Form>
+            ) : null}
+
+            {mode === 'reset' ? (
+              <Form form={resetForm} layout="vertical" onFinish={submitResetPassword} requiredMark={false}>
+                <Form.Item name="password" label={t('auth.newPassword')} rules={[{ required: true, min: 8, message: t('auth.password') }]}>
+                  <Input.Password prefix={<LockOutlined />} autoComplete="new-password" autoFocus />
+                </Form.Item>
+                <Button type="primary" htmlType="submit" loading={loading} block size="large" disabled={!resetToken}>
+                  {t('auth.resetPasswordButton')}
+                </Button>
+                <Button type="link" block onClick={() => setAuthMode('login')}>
+                  {t('auth.backToLogin')}
+                </Button>
+              </Form>
+            ) : null}
           </Space>
         </Card>
       </section>
@@ -144,6 +261,26 @@ export function AuthApp() {
   );
 }
 
+function authPathForMode(mode: AuthMode) {
+  return {
+    login: '/login',
+    register: '/register',
+    forgot: '/forgot-password',
+    resend: '/resend-verification',
+    reset: '/reset-password'
+  }[mode];
+}
+
+function authTitle(mode: AuthMode, t: (key: string) => string) {
+  return {
+    login: t('auth.loginTitle'),
+    register: t('auth.registerTitle'),
+    forgot: t('auth.forgotPasswordTitle'),
+    reset: t('auth.resetPasswordTitle'),
+    resend: t('auth.resendVerificationTitle')
+  }[mode];
+}
+
 function Signal({ icon, title, text }: { icon: ReactNode; title: string; text: string }) {
   return (
     <div className="auth-signal-item">

+ 34 - 0
src/frontend/auth/auth-model.js

@@ -0,0 +1,34 @@
+export function nextAuthSuccessState(path, data = {}) {
+  if (isRegisterPath(path)) {
+    return {
+      mode: 'login',
+      path: '/login',
+      message: String(data.message || '注册成功,请先验证邮箱,验证后等待管理员审核。'),
+      redirectTo: ''
+    };
+  }
+  return {
+    mode: 'login',
+    path: '/login',
+    message: '',
+    redirectTo: '/'
+  };
+}
+
+export function authModeFromLocation(pathname, search = '') {
+  const path = String(pathname || '');
+  if (path.endsWith('/register')) return { mode: 'register', token: '' };
+  if (path.endsWith('/forgot-password')) return { mode: 'forgot', token: '' };
+  if (path.endsWith('/resend-verification')) return { mode: 'resend', token: '' };
+  if (path.endsWith('/reset-password')) {
+    return {
+      mode: 'reset',
+      token: new URLSearchParams(String(search || '')).get('token') || ''
+    };
+  }
+  return { mode: 'login', token: '' };
+}
+
+function isRegisterPath(path) {
+  return String(path || '').endsWith('/register');
+}

+ 36 - 0
src/frontend/i18n/index.js

@@ -26,14 +26,25 @@ const messages = {
     'common.unsetSendingIp': '未设置发信 IP',
     'auth.email': '邮箱',
     'auth.emailPlaceholder': 'name@example.com',
+    'auth.backToLogin': '返回登录',
+    'auth.forgotPassword': '忘记密码?',
+    'auth.forgotPasswordButton': '发送重置邮件',
+    'auth.forgotPasswordTitle': '找回密码',
     'auth.login': '登录',
     'auth.loginEyebrow': 'Sign in',
     'auth.loginTitle': '登录控制台',
+    'auth.newPassword': '新密码',
     'auth.password': '密码',
     'auth.register': '注册',
     'auth.registerButton': '注册并进入',
     'auth.registerEyebrow': 'Register',
     'auth.registerTitle': '创建账号',
+    'auth.resendVerification': '重发验证邮件',
+    'auth.resendVerificationButton': '发送验证邮件',
+    'auth.resendVerificationTitle': '重发验证邮件',
+    'auth.resetPasswordButton': '重置密码',
+    'auth.resetPasswordTitle': '重置密码',
+    'auth.resetTokenMissing': '重置链接缺少 token,请重新发起密码重置。',
     'auth.submitLogin': '登录',
     'auth.username': '用户名',
     'auth.usernameOrEmail': '用户名或邮箱',
@@ -201,6 +212,12 @@ const messages = {
     'dnsApi.secretHint': '密钥只在服务端加密保存,不会在列表中回显。',
     'settings.noPermission': '当前账号没有系统设置权限。',
     'settings.save': '保存设置',
+    'admin.title': '管理员面板',
+    'admin.users': '用户',
+    'admin.resources': '资源',
+    'admin.migration': '合并迁移',
+    'admin.systemEmail': '系统邮件',
+    'admin.auditLogs': '审计日志',
     'metrics.accepted': '已接收',
     'metrics.failed': '失败',
     'metrics.recipients': '收件人',
@@ -275,6 +292,7 @@ const messages = {
     'nav.tokens': 'API Token',
     'nav.logs': '发送记录',
     'nav.webhooks': 'Webhooks',
+    'nav.admin': '管理员',
     'nav.settings': '系统设置'
   },
   'en-US': {
@@ -301,14 +319,25 @@ const messages = {
     'common.unsetSendingIp': 'Sending IP not set',
     'auth.email': 'Email',
     'auth.emailPlaceholder': 'name@example.com',
+    'auth.backToLogin': 'Back to sign in',
+    'auth.forgotPassword': 'Forgot password?',
+    'auth.forgotPasswordButton': 'Send reset email',
+    'auth.forgotPasswordTitle': 'Recover password',
     'auth.login': 'Sign in',
     'auth.loginEyebrow': 'Sign in',
     'auth.loginTitle': 'Sign in to console',
+    'auth.newPassword': 'New password',
     'auth.password': 'Password',
     'auth.register': 'Register',
     'auth.registerButton': 'Create account',
     'auth.registerEyebrow': 'Register',
     'auth.registerTitle': 'Create account',
+    'auth.resendVerification': 'Resend verification email',
+    'auth.resendVerificationButton': 'Send verification email',
+    'auth.resendVerificationTitle': 'Resend verification email',
+    'auth.resetPasswordButton': 'Reset password',
+    'auth.resetPasswordTitle': 'Reset password',
+    'auth.resetTokenMissing': 'The reset link is missing a token. Please request a new password reset.',
     'auth.submitLogin': 'Sign in',
     'auth.username': 'Username',
     'auth.usernameOrEmail': 'Username or email',
@@ -476,6 +505,12 @@ const messages = {
     'dnsApi.secretHint': 'Secrets are encrypted server-side and never shown in the list.',
     'settings.noPermission': 'This account cannot access system settings.',
     'settings.save': 'Save settings',
+    'admin.title': 'Admin Panel',
+    'admin.users': 'Users',
+    'admin.resources': 'Resources',
+    'admin.migration': 'Merge',
+    'admin.systemEmail': 'System Email',
+    'admin.auditLogs': 'Audit Logs',
     'metrics.accepted': 'Accepted',
     'metrics.failed': 'Failed',
     'metrics.recipients': 'Recipients',
@@ -550,6 +585,7 @@ const messages = {
     'nav.tokens': 'API Tokens',
     'nav.logs': 'Sending Logs',
     'nav.webhooks': 'Webhooks',
+    'nav.admin': 'Admin',
     'nav.settings': 'Settings'
   }
 };

+ 70 - 2
src/frontend/services/api.ts

@@ -1,14 +1,23 @@
 import type {
   AddDomainPayload,
+  AdminResourceInventory,
+  AdminUser,
   ApiToken,
   Analytics,
+  AuditLogEntry,
   DnsCredential,
   Domain,
   DomainPatchPayload,
   RuntimeConfig,
   SendEvent,
   SmtpCredential,
-  User
+  SystemEmailSettings,
+  User,
+  UserMergeOptions,
+  UserMergePreview,
+  UserMergeResult,
+  UserRole,
+  UserStatus
 } from '../types';
 
 interface RequestOptions extends RequestInit {
@@ -112,6 +121,65 @@ export const api = {
   adminSettings: () => request<{ settings: RuntimeConfig }>('/api/admin/settings'),
   saveAdminSettings: (data: Partial<RuntimeConfig>) =>
     request<{ settings: RuntimeConfig }>('/api/admin/settings', { method: 'PATCH', data }),
-  adminUsers: () => request<{ users: User[] }>('/api/admin/users'),
+  adminUsers: () => request<{ users: AdminUser[] }>('/api/admin/users'),
+  updateAdminUser: (id: number, data: { role?: UserRole; status?: UserStatus; password?: string }) =>
+    request<{ user: AdminUser }>(`/api/admin/users/${id}`, { method: 'PATCH', data }),
+  approveAdminUser: (id: number) =>
+    request<{ user: AdminUser }>(`/api/admin/users/${id}/approve`, { method: 'POST' }),
+  resendAdminVerification: (id: number) =>
+    request<{
+      verificationEmailSent?: boolean;
+      message: string;
+      result?: SystemMailActionResult;
+    }>(`/api/admin/users/${id}/resend-verification`, { method: 'POST' }),
+  sendAdminPasswordReset: (id: number) =>
+    request<{ result: SystemMailActionResult }>(`/api/admin/users/${id}/password-reset`, { method: 'POST' }),
+  setAdminTemporaryPassword: (id: number, password: string) =>
+    request<{ user: AdminUser }>(`/api/admin/users/${id}/temporary-password`, {
+      method: 'POST',
+      data: { password }
+    }),
+  adminResources: () => request<{ inventory: AdminResourceInventory }>('/api/admin/resources'),
+  transferAdminDomain: (
+    id: number,
+    data: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }
+  ) => request<{ domain: Domain }>(`/api/admin/resources/domains/${id}/transfer`, { method: 'POST', data }),
+  transferAdminDnsCredential: (id: number, data: { targetUserId: number }) =>
+    request<{ credential: DnsCredential }>(`/api/admin/resources/dns-credentials/${id}/transfer`, {
+      method: 'POST',
+      data
+    }),
+  transferAdminApiTokens: (data: { tokenIds: number[]; targetUserId: number }) =>
+    request<{ tokens: ApiToken[] }>('/api/admin/resources/api-tokens/transfer', { method: 'POST', data }),
+  previewUserMerge: (data: { sourceUserId: number; targetUserId: number }) =>
+    request<{ preview: UserMergePreview }>('/api/admin/migrations/user-merge/preview', { method: 'POST', data }),
+  executeUserMerge: (data: {
+    sourceUserId: number;
+    targetUserId: number;
+    options: Partial<UserMergeOptions>;
+    confirmation: string;
+  }) => request<{ result: UserMergeResult }>('/api/admin/migrations/user-merge/execute', { method: 'POST', data }),
+  adminSystemEmail: () => request<{ settings: SystemEmailSettings }>('/api/admin/system-email'),
+  saveAdminSystemEmail: (data: Partial<SystemEmailSettings>) =>
+    request<{ settings: SystemEmailSettings }>('/api/admin/system-email', { method: 'PATCH', data }),
+  testAdminSystemEmail: (to?: string) =>
+    request<{ result: SystemMailActionResult }>('/api/admin/system-email/test', {
+      method: 'POST',
+      data: to ? { to } : {}
+    }),
+  adminAuditLogs: (query = '') =>
+    request<{ logs: AuditLogEntry[] }>(`/api/admin/audit-logs${query ? `?${query}` : ''}`),
+  resendVerification: (email: string) =>
+    request<{ message: string }>('/api/auth/resend-verification', { method: 'POST', data: { email } }),
+  forgotPassword: (email: string) =>
+    request<{ message: string }>('/api/auth/forgot-password', { method: 'POST', data: { email } }),
+  resetPassword: (token: string, password: string) =>
+    request<{ message: string }>('/api/auth/reset-password', { method: 'POST', data: { token, password } }),
   logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' })
 };
+
+interface SystemMailActionResult {
+  ok: boolean;
+  message: string;
+  queueId?: string;
+}

+ 16 - 1
src/frontend/styles.css

@@ -304,6 +304,14 @@ body {
   grid-template-columns: repeat(2, minmax(0, 1fr));
 }
 
+.form-grid.three {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.table-select {
+  min-width: 120px;
+}
+
 .auth-page {
   background:
     radial-gradient(circle at 8% 8%, rgba(22, 119, 255, 0.12), transparent 28%),
@@ -411,6 +419,12 @@ body {
   margin: 6px 0 0 !important;
 }
 
+.auth-link-row {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 8px;
+}
+
 .auth-eyebrow {
   color: #1677ff;
   font-size: 12px;
@@ -442,7 +456,8 @@ body {
     width: 100%;
   }
 
-  .form-grid.two {
+  .form-grid.two,
+  .form-grid.three {
     grid-template-columns: 1fr;
   }
 

+ 106 - 2
src/frontend/types.ts

@@ -6,16 +6,34 @@ export type ViewKey =
   | 'tokens'
   | 'logs'
   | 'webhooks'
+  | 'admin'
   | 'settings';
 
 export type DomainMode = 'list' | 'detail';
+export type UserStatus = 'pending_email' | 'pending_review' | 'active' | 'disabled';
+export type UserRole = 'admin' | 'user';
+
+export interface UserResourceCounts {
+  domains: number;
+  dnsCredentials: number;
+  apiTokens: number;
+  sendEvents: number;
+  smtpCredential: number;
+}
 
 export interface User {
   id: number;
   username: string;
   email: string;
-  role: 'admin' | 'user';
-  status: 'active' | 'disabled';
+  role: UserRole;
+  status: UserStatus;
+  createdAt?: string;
+  updatedAt?: string;
+  resourceCounts?: UserResourceCounts;
+}
+
+export interface AdminUser extends User {
+  resourceCounts: UserResourceCounts;
 }
 
 export interface RuntimeConfig {
@@ -92,24 +110,110 @@ export interface Domain {
 
 export interface DnsCredential {
   id: number;
+  userId?: number;
   name: string;
   provider: 'cloudflare' | 'aliyun' | 'dnspod' | string;
   zoneName: string;
   defaultTtl: number;
+  credentialSet?: boolean;
   createdAt: string;
   updatedAt: string;
 }
 
 export interface SmtpCredential {
   id?: number;
+  userId?: number;
   username: string;
   password?: string;
   passwordSet?: boolean;
+  passwordRecoverable?: boolean;
+  createdAt?: string;
   updatedAt?: string;
 }
 
+export interface SystemEmailSettings {
+  host: string;
+  port: number;
+  secure: boolean;
+  username: string;
+  password?: string;
+  passwordSet: boolean;
+  helo: string;
+  fromEmail: string;
+  fromName: string;
+  testRecipient: string;
+}
+
+export interface AdminResourceInventory {
+  users: AdminUserResourceGroup[];
+  warnings: Array<{
+    type: 'domain_dns_credential_owner_mismatch' | string;
+    domainId: number;
+    domain: string;
+    domainUserId: number;
+    dnsCredentialId: number;
+    dnsCredentialUserId: number;
+  }>;
+}
+
+export interface AdminUserResourceGroup {
+  user: AdminUser;
+  domains: Domain[];
+  dnsCredentials: DnsCredential[];
+  smtpCredential: SmtpCredential | null;
+  apiTokens: ApiToken[];
+  sendEventCount: number;
+}
+
+export interface UserMergeOptions {
+  transferDomains: boolean;
+  transferDnsCredentials: boolean;
+  transferApiTokens: boolean;
+  transferSendEvents: boolean;
+  transferSmtpCredential: boolean;
+  disableSource: boolean;
+}
+
+export interface UserMergePreview {
+  sourceUser: User;
+  targetUser: User;
+  confirmationText: string;
+  counts: UserResourceCounts;
+  selectedCounts: UserResourceCounts;
+  defaultOptions: UserMergeOptions;
+  resources: {
+    source: Omit<AdminUserResourceGroup, 'user'>;
+    target: Omit<AdminUserResourceGroup, 'user'>;
+  };
+  smtp: {
+    sourceCredential: SmtpCredential | null;
+    targetCredential: SmtpCredential | null;
+    conflict: boolean;
+  };
+  warnings: Array<{ type: string; message?: string }>;
+}
+
+export interface UserMergeResult {
+  sourceUser: User;
+  targetUser: User;
+  counts: UserResourceCounts;
+  warnings: UserMergePreview['warnings'];
+}
+
+export interface AuditLogEntry {
+  id: number;
+  actorUserId: number | null;
+  action: string;
+  targetType: string;
+  targetId: string;
+  targetUserId: number | null;
+  summary: Record<string, unknown>;
+  createdAt: string;
+}
+
 export interface ApiToken {
   id: number;
+  userId?: number;
   name: string;
   tokenPrefix: string;
   token?: string;

+ 4 - 1
src/layouts/AdminLayout.tsx

@@ -7,6 +7,7 @@ import {
   KeyOutlined,
   MailOutlined,
   ReloadOutlined,
+  SafetyCertificateOutlined,
   SendOutlined,
   SettingOutlined,
   UserOutlined
@@ -27,6 +28,7 @@ const navItems: Array<{ key: ViewKey; labelKey: string; icon: ReactNode }> = [
   { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
   { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
   { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> },
+  { key: 'admin', labelKey: 'nav.admin', icon: <SafetyCertificateOutlined /> },
   { key: 'settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }
 ];
 
@@ -56,6 +58,7 @@ export function AdminLayout({
   onLogout
 }: AdminLayoutProps) {
   const { locale, locales, setLocale, t } = useI18n();
+  const visibleNavItems = navItems.filter((item) => item.key !== 'admin' || user?.role === 'admin');
 
   return (
     <Layout className="admin-layout">
@@ -71,7 +74,7 @@ export function AdminLayout({
           theme="dark"
           mode="inline"
           selectedKeys={[activeView]}
-          items={navItems.map((item) => ({ key: item.key, icon: item.icon, label: t(item.labelKey) }))}
+          items={visibleNavItems.map((item) => ({ key: item.key, icon: item.icon, label: t(item.labelKey) }))}
           onClick={({ key }) => onViewChange(key as ViewKey)}
         />
       </Sider>

+ 65 - 0
src/pages/Admin/admin-model.js

@@ -0,0 +1,65 @@
+const USER_STATUS_META = {
+  pending_email: { label: '待验证邮箱', color: 'gold' },
+  pending_review: { label: '待管理员审核', color: 'blue' },
+  active: { label: '正常', color: 'green' },
+  disabled: { label: '已禁用', color: 'red' }
+};
+
+const MERGE_SUMMARY_ITEMS = [
+  ['domains', '域名'],
+  ['dnsCredentials', 'DNS 凭据'],
+  ['apiTokens', 'API Token'],
+  ['sendEvents', '发送记录'],
+  ['smtpCredential', 'SMTP 凭据']
+];
+
+export function adminUserStatusMeta(status) {
+  return USER_STATUS_META[status] || { label: String(status || '未知'), color: 'default' };
+}
+
+export function buildMergeConfirmationText(sourceUser, targetUser) {
+  return `MERGE ${sourceUser?.username || sourceUser?.id || ''} INTO ${targetUser?.username || targetUser?.id || ''}`;
+}
+
+export function mergePreviewSummary(preview) {
+  const counts = preview?.selectedCounts || {};
+  return MERGE_SUMMARY_ITEMS.map(([key, label]) => ({
+    key,
+    label,
+    count: Number(counts[key] || 0)
+  }));
+}
+
+export function serializeSystemEmailPayload(values) {
+  const payload = compactObject({
+    host: values?.host,
+    port: values?.port === '' || values?.port == null ? undefined : Number(values.port),
+    secure: values?.secure,
+    username: values?.username,
+    password: values?.password,
+    fromEmail: values?.fromEmail
+  });
+
+  if (typeof payload.password === 'string' && payload.password.trim() === '') {
+    delete payload.password;
+  }
+
+  return payload;
+}
+
+export function serializeAuditFilters(filters) {
+  const params = new URLSearchParams();
+  for (const [key, value] of Object.entries(filters || {})) {
+    if (value === '' || value == null) {
+      continue;
+    }
+    params.set(key, String(value));
+  }
+  return params.toString();
+}
+
+function compactObject(values) {
+  return Object.fromEntries(
+    Object.entries(values).filter(([, value]) => value !== '' && value != null)
+  );
+}

+ 849 - 0
src/pages/Admin/index.tsx

@@ -0,0 +1,849 @@
+import {
+  CheckCircleOutlined,
+  MailOutlined,
+  ReloadOutlined,
+  SendOutlined,
+  UserSwitchOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Card,
+  Checkbox,
+  Descriptions,
+  Empty,
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Select,
+  Space,
+  Switch,
+  Table,
+  Tabs,
+  Tag,
+  Typography
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useEffect, useMemo, useState } from 'react';
+
+import {
+  adminUserStatusMeta,
+  buildMergeConfirmationText,
+  mergePreviewSummary,
+  serializeAuditFilters,
+  serializeSystemEmailPayload
+} from './admin-model.js';
+import { useI18n } from '../../frontend/i18n/react';
+import { api } from '../../frontend/services/api';
+import type {
+  AdminResourceInventory,
+  AdminUser,
+  ApiToken,
+  AuditLogEntry,
+  DnsCredential,
+  Domain,
+  SystemEmailSettings,
+  User,
+  UserMergeOptions,
+  UserMergePreview,
+  UserRole,
+  UserStatus
+} from '../../frontend/types';
+
+interface AdminPageProps {
+  me: User | null;
+}
+
+const statusValues: UserStatus[] = ['pending_email', 'pending_review', 'active', 'disabled'];
+const roleValues: UserRole[] = ['user', 'admin'];
+
+const mergeOptionLabels: Array<[keyof UserMergeOptions, string]> = [
+  ['transferDomains', '迁移域名'],
+  ['transferDnsCredentials', '迁移 DNS 凭据'],
+  ['transferApiTokens', '迁移 API Token'],
+  ['transferSendEvents', '迁移发送记录'],
+  ['transferSmtpCredential', '迁移 SMTP 凭据'],
+  ['disableSource', '禁用源用户']
+];
+
+export default function AdminPage({ me }: AdminPageProps) {
+  const { message, modal } = AntApp.useApp();
+  const { t } = useI18n();
+  const [users, setUsers] = useState<AdminUser[]>([]);
+  const [inventory, setInventory] = useState<AdminResourceInventory | null>(null);
+  const [systemEmail, setSystemEmail] = useState<SystemEmailSettings | null>(null);
+  const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
+  const [auditQuery, setAuditQuery] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [actionLoading, setActionLoading] = useState(false);
+
+  useEffect(() => {
+    if (me?.role === 'admin') void loadAdminData();
+  }, [me?.role]);
+
+  if (me?.role !== 'admin') {
+    return (
+      <Card>
+        <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
+      </Card>
+    );
+  }
+
+  async function loadAdminData(query = auditQuery) {
+    setLoading(true);
+    try {
+      const [usersResult, resourcesResult, emailResult, auditResult] = await Promise.all([
+        api.adminUsers(),
+        api.adminResources(),
+        api.adminSystemEmail(),
+        api.adminAuditLogs(query)
+      ]);
+      setUsers(usersResult.users || []);
+      setInventory(resourcesResult.inventory || null);
+      setSystemEmail(emailResult.settings || null);
+      setAuditLogs(auditResult.logs || []);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '管理员数据加载失败');
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function runAction(action: () => Promise<unknown>, success: string, refresh = true) {
+    setActionLoading(true);
+    try {
+      await action();
+      message.success(success);
+      if (refresh) await loadAdminData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '操作失败');
+    } finally {
+      setActionLoading(false);
+    }
+  }
+
+  function confirm(title: string, action: () => Promise<unknown>) {
+    modal.confirm({
+      title,
+      okText: t('common.confirm'),
+      cancelText: t('common.cancel'),
+      onOk: action
+    });
+  }
+
+  async function searchAuditLogs(query: string) {
+    setAuditQuery(query);
+    setLoading(true);
+    try {
+      const result = await api.adminAuditLogs(query);
+      setAuditLogs(result.logs || []);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '审计日志加载失败');
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  const tabItems = [
+    {
+      key: 'users',
+      label: t('admin.users'),
+      children: (
+        <AdminUsers
+          users={users}
+          loading={loading || actionLoading}
+          onApprove={(user) => confirm(`确认审批用户 ${user.username}?`, () =>
+            runAction(() => api.approveAdminUser(user.id), '用户已审批')
+          )}
+          onResendVerification={(user) => runAction(() => api.resendAdminVerification(user.id), '验证邮件请求已提交')}
+          onPasswordReset={(user) => confirm(`确认给 ${user.email} 发送密码重置邮件?`, () =>
+            runAction(() => api.sendAdminPasswordReset(user.id), '密码重置邮件请求已提交')
+          )}
+          onTemporaryPassword={(user, password) => confirm(`确认为用户 ${user.username} 设置临时密码?`, () =>
+            runAction(() => api.setAdminTemporaryPassword(user.id, password), '临时密码已设置')
+          )}
+          onUpdateUser={(user, patch) => confirm(`确认更新用户 ${user.username}?`, () =>
+            runAction(() => api.updateAdminUser(user.id, patch), '用户已更新')
+          )}
+        />
+      )
+    },
+    {
+      key: 'resources',
+      label: t('admin.resources'),
+      children: (
+        <AdminResources
+          users={users}
+          inventory={inventory}
+          loading={loading || actionLoading}
+          onTransferDomain={(domainId, values) =>
+            runAction(() => api.transferAdminDomain(domainId, values), '域名已迁移')
+          }
+          onTransferDnsCredential={(credentialId, values) =>
+            runAction(() => api.transferAdminDnsCredential(credentialId, values), 'DNS 凭据已迁移')
+          }
+          onTransferApiTokens={(values) =>
+            runAction(() => api.transferAdminApiTokens(values), 'API Token 已迁移')
+          }
+        />
+      )
+    },
+    {
+      key: 'migration',
+      label: t('admin.migration'),
+      children: (
+        <AdminMigration
+          users={users}
+          loading={loading || actionLoading}
+          onPreview={(values) => api.previewUserMerge(values)}
+          onExecute={(values) => runAction(() => api.executeUserMerge(values), '用户资源已合并')}
+        />
+      )
+    },
+    {
+      key: 'system-email',
+      label: t('admin.systemEmail'),
+      children: (
+        <AdminSystemEmail
+          settings={systemEmail}
+          loading={loading || actionLoading}
+          onSave={(values) => runAction(() => api.saveAdminSystemEmail(values), '系统邮件配置已保存')}
+          onTest={(to) => runAction(() => api.testAdminSystemEmail(to), '测试邮件请求已提交', false)}
+        />
+      )
+    },
+    {
+      key: 'audit-logs',
+      label: t('admin.auditLogs'),
+      children: (
+        <AdminAuditLogs
+          logs={auditLogs}
+          users={users}
+          loading={loading}
+          onSearch={searchAuditLogs}
+        />
+      )
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <div className="page-toolbar">
+        <Typography.Title level={3}>{t('admin.title')}</Typography.Title>
+        <Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadAdminData()}>
+          {t('common.refresh')}
+        </Button>
+      </div>
+      <Card>
+        <Tabs items={tabItems} />
+      </Card>
+    </Space>
+  );
+}
+
+function AdminUsers({
+  users,
+  loading,
+  onApprove,
+  onResendVerification,
+  onPasswordReset,
+  onTemporaryPassword,
+  onUpdateUser
+}: {
+  users: AdminUser[];
+  loading: boolean;
+  onApprove: (user: AdminUser) => void;
+  onResendVerification: (user: AdminUser) => void;
+  onPasswordReset: (user: AdminUser) => void;
+  onTemporaryPassword: (user: AdminUser, password: string) => void;
+  onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
+}) {
+  const [tempUser, setTempUser] = useState<AdminUser | null>(null);
+  const [form] = Form.useForm<{ password: string }>();
+
+  const columns: ColumnsType<AdminUser> = [
+    { title: 'ID', dataIndex: 'id', width: 80 },
+    {
+      title: '用户',
+      render: (_, user) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{user.username}</Typography.Text>
+          <Typography.Text type="secondary">{user.email}</Typography.Text>
+        </Space>
+      )
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      width: 180,
+      render: (_, user) => (
+        <Select
+          value={user.status}
+          options={statusValues.map((value) => ({ value, label: adminUserStatusMeta(value).label }))}
+          onChange={(status) => onUpdateUser(user, { status })}
+          className="table-select"
+        />
+      )
+    },
+    {
+      title: '角色',
+      dataIndex: 'role',
+      width: 140,
+      render: (_, user) => (
+        <Select
+          value={user.role}
+          options={roleValues.map((value) => ({ value, label: value }))}
+          onChange={(role) => onUpdateUser(user, { role })}
+          className="table-select"
+        />
+      )
+    },
+    {
+      title: '资源',
+      render: (_, user) => <ResourceCountTags counts={user.resourceCounts} />
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      width: 190,
+      render: formatDate
+    },
+    {
+      title: '操作',
+      width: 380,
+      render: (_, user) => (
+        <Space wrap>
+          <Button
+            icon={<CheckCircleOutlined />}
+            disabled={user.status !== 'pending_review'}
+            onClick={() => onApprove(user)}
+          >
+            审批
+          </Button>
+          <Button
+            icon={<MailOutlined />}
+            disabled={user.status !== 'pending_email'}
+            onClick={() => onResendVerification(user)}
+          >
+            重发验证
+          </Button>
+          <Button icon={<SendOutlined />} onClick={() => onPasswordReset(user)}>
+            重置邮件
+          </Button>
+          <Button icon={<UserSwitchOutlined />} onClick={() => setTempUser(user)}>
+            临时密码
+          </Button>
+        </Space>
+      )
+    }
+  ];
+
+  async function submitTemporaryPassword() {
+    if (!tempUser) return;
+    const values = await form.validateFields();
+    onTemporaryPassword(tempUser, values.password);
+    setTempUser(null);
+    form.resetFields();
+  }
+
+  return (
+    <>
+      <Table
+        rowKey="id"
+        columns={columns}
+        dataSource={users}
+        loading={loading}
+        scroll={{ x: 1100 }}
+      />
+      <Modal
+        title={tempUser ? `设置临时密码 · ${tempUser.username}` : '设置临时密码'}
+        open={Boolean(tempUser)}
+        confirmLoading={loading}
+        onCancel={() => setTempUser(null)}
+        onOk={submitTemporaryPassword}
+      >
+        <Form form={form} layout="vertical">
+          <Form.Item
+            name="password"
+            label="临时密码"
+            rules={[{ required: true, min: 8, message: '密码至少需要 8 位。' }]}
+          >
+            <Input.Password autoComplete="new-password" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+}
+
+function AdminResources({
+  users,
+  inventory,
+  loading,
+  onTransferDomain,
+  onTransferDnsCredential,
+  onTransferApiTokens
+}: {
+  users: AdminUser[];
+  inventory: AdminResourceInventory | null;
+  loading: boolean;
+  onTransferDomain: (domainId: number, values: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) => Promise<void>;
+  onTransferDnsCredential: (credentialId: number, values: { targetUserId: number }) => Promise<void>;
+  onTransferApiTokens: (values: { tokenIds: number[]; targetUserId: number }) => Promise<void>;
+}) {
+  const [domainForm] = Form.useForm<{ domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }>();
+  const [dnsForm] = Form.useForm<{ credentialId: number; targetUserId: number }>();
+  const [tokenForm] = Form.useForm<{ tokenIds: number[]; targetUserId: number }>();
+  const groups = inventory?.users || [];
+  const targetOptions = users
+    .filter((user) => user.status !== 'disabled')
+    .map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
+  const domains = groups.flatMap((group) => group.domains.map((domain) => ({ ...domain, owner: group.user })));
+  const credentials = groups.flatMap((group) => group.dnsCredentials.map((credential) => ({ ...credential, owner: group.user })));
+  const tokens = groups.flatMap((group) => group.apiTokens.map((token) => ({ ...token, owner: group.user })));
+
+  const groupColumns: ColumnsType<AdminResourceInventory['users'][number]> = [
+    {
+      title: '用户',
+      render: (_, group) => (
+        <Space>
+          <Typography.Text strong>{group.user.username}</Typography.Text>
+          <UserStatusTag status={group.user.status} />
+        </Space>
+      )
+    },
+    { title: '资源', render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
+    { title: '发送记录', dataIndex: 'sendEventCount', width: 120 },
+    {
+      title: 'SMTP',
+      width: 120,
+      render: (_, group) => group.smtpCredential ? <Tag color="green">已配置</Tag> : <Tag>无</Tag>
+    }
+  ];
+
+  async function submitDomainTransfer(values: { domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) {
+    Modal.confirm({
+      title: '确认迁移该域名?',
+      onOk: async () => {
+        await onTransferDomain(values.domainId, {
+          targetUserId: values.targetUserId,
+          dnsCredentialMode: values.dnsCredentialMode
+        });
+        domainForm.resetFields();
+      }
+    });
+  }
+
+  async function submitDnsTransfer(values: { credentialId: number; targetUserId: number }) {
+    Modal.confirm({
+      title: '确认迁移该 DNS 凭据?',
+      onOk: async () => {
+        await onTransferDnsCredential(values.credentialId, { targetUserId: values.targetUserId });
+        dnsForm.resetFields();
+      }
+    });
+  }
+
+  async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
+    Modal.confirm({
+      title: `确认迁移 ${values.tokenIds.length} 个 API Token?`,
+      onOk: async () => {
+        await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
+        tokenForm.resetFields();
+      }
+    });
+  }
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      {inventory?.warnings?.length ? (
+        <Alert
+          type="warning"
+          showIcon
+          message={`发现 ${inventory.warnings.length} 个 DNS 凭据归属不一致的域名`}
+        />
+      ) : null}
+      <div className="form-grid three">
+        <Card title="迁移域名">
+          <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading}>
+            <Form.Item name="domainId" label="域名" rules={[{ required: true }]}>
+              <Select
+                showSearch
+                optionFilterProp="label"
+                options={domains.map((domain) => ({
+                  value: domain.id,
+                  label: `${domain.domain} · ${domain.owner.username}`
+                }))}
+              />
+            </Form.Item>
+            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+              <Select options={targetOptions} />
+            </Form.Item>
+            <Form.Item name="dnsCredentialMode" label="DNS 凭据" initialValue="domain_only">
+              <Select
+                options={[
+                  { value: 'domain_only', label: '仅迁移域名' },
+                  { value: 'with_dns_credential', label: '连同 DNS 凭据迁移' },
+                  { value: 'clear_dns_credential', label: '清空 DNS 凭据绑定' }
+                ]}
+              />
+            </Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+          </Form>
+        </Card>
+        <Card title="迁移 DNS 凭据">
+          <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading}>
+            <Form.Item name="credentialId" label="DNS 凭据" rules={[{ required: true }]}>
+              <Select
+                showSearch
+                optionFilterProp="label"
+                options={credentials.map((credential) => ({
+                  value: credential.id,
+                  label: `${credential.name} · ${credential.zoneName} · ${credential.owner.username}`
+                }))}
+              />
+            </Form.Item>
+            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+              <Select options={targetOptions} />
+            </Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+          </Form>
+        </Card>
+        <Card title="迁移 API Token">
+          <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading}>
+            <Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
+              <Select
+                mode="multiple"
+                optionFilterProp="label"
+                options={tokens.map((token) => ({
+                  value: token.id,
+                  label: `${token.name} · ${token.tokenPrefix} · ${token.owner.username}`
+                }))}
+              />
+            </Form.Item>
+            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+              <Select options={targetOptions} />
+            </Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+          </Form>
+        </Card>
+      </div>
+      <Card title="资源归属">
+        <Table
+          rowKey={(group) => group.user.id}
+          columns={groupColumns}
+          dataSource={groups}
+          loading={loading}
+          expandable={{ expandedRowRender: renderResourceDetails }}
+        />
+      </Card>
+    </Space>
+  );
+}
+
+function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Descriptions size="small" column={2}>
+        <Descriptions.Item label="邮箱">{group.user.email}</Descriptions.Item>
+        <Descriptions.Item label="角色">{group.user.role}</Descriptions.Item>
+      </Descriptions>
+      <Table
+        size="small"
+        rowKey="id"
+        pagination={false}
+        dataSource={group.domains}
+        columns={[
+          { title: '域名', dataIndex: 'domain' },
+          { title: '发信主机', dataIndex: 'senderHost' },
+          { title: 'DNS 凭据 ID', dataIndex: 'dnsCredentialId' }
+        ]}
+      />
+      <Table
+        size="small"
+        rowKey="id"
+        pagination={false}
+        dataSource={group.dnsCredentials}
+        columns={[
+          { title: 'DNS 凭据', dataIndex: 'name' },
+          { title: 'Provider', dataIndex: 'provider' },
+          { title: 'Zone', dataIndex: 'zoneName' }
+        ]}
+      />
+      <Table
+        size="small"
+        rowKey="id"
+        pagination={false}
+        dataSource={group.apiTokens}
+        columns={[
+          { title: 'API Token', dataIndex: 'name' },
+          { title: '前缀', dataIndex: 'tokenPrefix' },
+          { title: '创建时间', dataIndex: 'createdAt', render: formatDate }
+        ]}
+      />
+    </Space>
+  );
+}
+
+function AdminMigration({
+  users,
+  loading,
+  onPreview,
+  onExecute
+}: {
+  users: AdminUser[];
+  loading: boolean;
+  onPreview: (values: { sourceUserId: number; targetUserId: number }) => Promise<{ preview: UserMergePreview }>;
+  onExecute: (values: {
+    sourceUserId: number;
+    targetUserId: number;
+    options: Partial<UserMergeOptions>;
+    confirmation: string;
+  }) => Promise<void>;
+}) {
+  const { message } = AntApp.useApp();
+  const [form] = Form.useForm<{ sourceUserId: number; targetUserId: number }>();
+  const [preview, setPreview] = useState<UserMergePreview | null>(null);
+  const [options, setOptions] = useState<Partial<UserMergeOptions>>({});
+  const [confirmation, setConfirmation] = useState('');
+  const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
+
+  async function submitPreview(values: { sourceUserId: number; targetUserId: number }) {
+    try {
+      const result = await onPreview(values);
+      setPreview(result.preview);
+      setOptions(result.preview.defaultOptions);
+      setConfirmation('');
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '预览失败');
+    }
+  }
+
+  async function execute() {
+    if (!preview) return;
+    await onExecute({
+      sourceUserId: preview.sourceUser.id,
+      targetUserId: preview.targetUser.id,
+      options,
+      confirmation
+    });
+    setPreview(null);
+    form.resetFields();
+    setConfirmation('');
+  }
+
+  const expectedConfirmation = preview
+    ? buildMergeConfirmationText(preview.sourceUser, preview.targetUser)
+    : '';
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card title="合并预览">
+        <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
+          <Form.Item name="sourceUserId" label="源用户" rules={[{ required: true }]}>
+            <Select options={userOptions} className="toolbar-select" />
+          </Form.Item>
+          <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+            <Select options={userOptions} className="toolbar-select" />
+          </Form.Item>
+          <Button type="primary" htmlType="submit" loading={loading}>预览</Button>
+        </Form>
+      </Card>
+      {preview ? (
+        <Card title={`${preview.sourceUser.username} → ${preview.targetUser.username}`}>
+          <Space direction="vertical" size={16} className="full-width">
+            {preview.warnings.length ? (
+              <Alert type="warning" showIcon message={preview.warnings.map((item) => item.message || item.type).join(';')} />
+            ) : null}
+            <Space wrap>
+              {mergePreviewSummary(preview).map((item) => (
+                <Tag key={item.key}>{item.label}: {item.count}</Tag>
+              ))}
+            </Space>
+            <div className="form-grid two">
+              {mergeOptionLabels.map(([key, label]) => (
+                <Checkbox
+                  key={key}
+                  checked={options[key] !== false}
+                  onChange={(event) => setOptions((current) => ({ ...current, [key]: event.target.checked }))}
+                >
+                  {label}
+                </Checkbox>
+              ))}
+            </div>
+            <Descriptions column={1} bordered size="small">
+              <Descriptions.Item label="确认文本">
+                <Typography.Text code>{expectedConfirmation}</Typography.Text>
+              </Descriptions.Item>
+            </Descriptions>
+            <Input
+              value={confirmation}
+              onChange={(event) => setConfirmation(event.target.value)}
+              placeholder={expectedConfirmation}
+            />
+            <Button
+              danger
+              type="primary"
+              loading={loading}
+              disabled={confirmation !== expectedConfirmation}
+              onClick={execute}
+            >
+              执行合并
+            </Button>
+          </Space>
+        </Card>
+      ) : (
+        <Empty description="暂无预览" />
+      )}
+    </Space>
+  );
+}
+
+function AdminSystemEmail({
+  settings,
+  loading,
+  onSave,
+  onTest
+}: {
+  settings: SystemEmailSettings | null;
+  loading: boolean;
+  onSave: (values: Partial<SystemEmailSettings>) => Promise<void>;
+  onTest: (to?: string) => Promise<void>;
+}) {
+  const [form] = Form.useForm<SystemEmailSettings>();
+
+  useEffect(() => {
+    if (settings) form.setFieldsValue(settings);
+  }, [form, settings]);
+
+  async function submit(values: SystemEmailSettings) {
+    await onSave(serializeSystemEmailPayload(values));
+    form.setFieldValue('password', '');
+  }
+
+  return (
+    <Card title="系统邮件服务器">
+      <Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
+        <div className="form-grid two">
+          <Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="port" label="SMTP Port" rules={[{ required: true }]}>
+            <InputNumber min={1} max={65535} className="full-width" />
+          </Form.Item>
+          <Form.Item name="secure" label="SSL / TLS" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+          <Form.Item name="helo" label="HELO">
+            <Input />
+          </Form.Item>
+          <Form.Item name="username" label="Username">
+            <Input autoComplete="off" />
+          </Form.Item>
+          <Form.Item name="password" label={settings?.passwordSet ? 'Password(留空保留)' : 'Password'}>
+            <Input.Password autoComplete="new-password" />
+          </Form.Item>
+          <Form.Item name="fromEmail" label="From Email" rules={[{ required: true, type: 'email' }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="fromName" label="From Name">
+            <Input />
+          </Form.Item>
+          <Form.Item name="testRecipient" label="Test Recipient">
+            <Input />
+          </Form.Item>
+        </div>
+        <Space wrap>
+          <Button type="primary" htmlType="submit" loading={loading}>保存配置</Button>
+          <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={loading}>发送测试</Button>
+        </Space>
+      </Form>
+    </Card>
+  );
+}
+
+function AdminAuditLogs({
+  logs,
+  users,
+  loading,
+  onSearch
+}: {
+  logs: AuditLogEntry[];
+  users: AdminUser[];
+  loading: boolean;
+  onSearch: (query: string) => Promise<void>;
+}) {
+  const [form] = Form.useForm();
+  const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
+
+  const columns: ColumnsType<AuditLogEntry> = [
+    { title: '时间', dataIndex: 'createdAt', width: 190, render: formatDate },
+    { title: '动作', dataIndex: 'action', width: 220 },
+    { title: '操作者', dataIndex: 'actorUserId', width: 130, render: (value) => value ?? 'system' },
+    { title: '目标用户', dataIndex: 'targetUserId', width: 130, render: (value) => value ?? '-' },
+    { title: '目标', render: (_, log) => `${log.targetType}:${log.targetId || '-'}`, width: 180 },
+    {
+      title: '摘要',
+      dataIndex: 'summary',
+      render: (value) => (
+        <Typography.Text code ellipsis>
+          {JSON.stringify(value)}
+        </Typography.Text>
+      )
+    }
+  ];
+
+  async function submit(values: Record<string, unknown>) {
+    await onSearch(serializeAuditFilters(values));
+  }
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card>
+        <Form form={form} layout="inline" onFinish={submit} disabled={loading}>
+          <Form.Item name="actorUserId" label="操作者">
+            <Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
+          </Form.Item>
+          <Form.Item name="targetUserId" label="目标用户">
+            <Select allowClear options={userOptions} className="toolbar-select" />
+          </Form.Item>
+          <Form.Item name="action" label="动作">
+            <Input placeholder="admin.user_merge" />
+          </Form.Item>
+          <Form.Item name="from" label="开始">
+            <Input placeholder="2026-07-08" />
+          </Form.Item>
+          <Form.Item name="to" label="结束">
+            <Input placeholder="2026-07-09" />
+          </Form.Item>
+          <Button type="primary" htmlType="submit" loading={loading}>查询</Button>
+        </Form>
+      </Card>
+      <Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
+    </Space>
+  );
+}
+
+function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] }) {
+  if (!counts) return <Tag>无资源</Tag>;
+  return (
+    <Space wrap>
+      <Tag>域名 {counts.domains}</Tag>
+      <Tag>DNS {counts.dnsCredentials}</Tag>
+      <Tag>Token {counts.apiTokens}</Tag>
+      <Tag>记录 {counts.sendEvents}</Tag>
+      <Tag>SMTP {counts.smtpCredential}</Tag>
+    </Space>
+  );
+}
+
+function UserStatusTag({ status }: { status: UserStatus }) {
+  const meta = adminUserStatusMeta(status);
+  return <Tag color={meta.color}>{meta.label}</Tag>;
+}
+
+function formatDate(value?: string) {
+  return value ? new Date(value).toLocaleString() : '-';
+}

+ 497 - 21
src/server.js

@@ -6,13 +6,17 @@ import path from 'node:path';
 import { fileURLToPath, domainToASCII } from 'node:url';
 import {
   authenticateUser,
+  approveUser,
   claimLegacyData,
   createApiToken,
+  createAccountToken,
   createDomain,
-  createUser,
+  createUserWithAccountToken,
+  consumeAccountToken,
   deleteApiToken,
   deleteDnsCredential,
   deleteDomain,
+  getAdminResourceInventory,
   getAdminUser,
   getDnsCredential,
   getDomain,
@@ -20,24 +24,37 @@ import {
   getSendAnalytics,
   getSettings,
   getSmtpCredential,
+  getSystemEmailSettings,
   getUser,
+  getUserByLogin,
   initDatabase,
+  invalidateAccountTokens,
   listApiTokens,
+  listAuditLogs,
   listDnsCredentials,
   listDomains,
   listSendEvents,
-  listUsers,
+  listUsersWithResourceCounts,
+  logAudit,
   logSendEvent,
+  markUserEmailVerified,
+  previewUserMerge,
   saveDnsCredential,
   saveDomainStatus,
   saveSettings,
   saveSmtpCredential,
+  saveSystemEmailSettings,
   seedAdminUser,
   seedSmtpCredential,
+  transferApiTokens,
+  transferDnsCredential,
+  transferDomain,
   updateDkim,
   updateDomain,
   updateUser,
-  verifyApiToken
+  executeUserMerge,
+  verifyApiToken,
+  verifyUserCredentials
 } from './db.js';
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
 import { startPostfixDeliveryTracker } from './delivery-tracker.js';
@@ -56,6 +73,11 @@ import {
   publicSubmissionListeners,
   startSubmissionServer
 } from './submission.js';
+import {
+  buildPasswordResetEmail,
+  buildVerificationEmail,
+  sendSystemEmail
+} from './system-mail.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 loadDotEnv();
@@ -100,6 +122,9 @@ const defaultSettings = {
   sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false'
 };
 
+const emailVerificationPurpose = 'email_verification';
+const passwordResetPurpose = 'password_reset';
+
 initDatabase(envConfig.dataDir, envConfig.sessionSecret);
 const admin = seedAdminUser({
   username: envConfig.adminUser,
@@ -124,12 +149,13 @@ const server = http.createServer(async (req, res) => {
     if (req.method === 'POST' && (url.pathname === '/api/register' || url.pathname === '/register')) return await handleRegister(req, res);
     if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/login')) return await handleLogin(req, res);
     if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
+    if (url.pathname === '/api/auth/verify-email') return await handleVerifyEmail(req, res, url);
+    if (req.method === 'POST' && url.pathname === '/api/auth/resend-verification') return await handleResendVerification(req, res);
+    if (req.method === 'POST' && url.pathname === '/api/auth/forgot-password') return await handleForgotPassword(req, res);
+    if (req.method === 'POST' && url.pathname === '/api/auth/reset-password') return await handleResetPassword(req, res);
 
     const user = getRequestUser(req, url.pathname);
     if (isLoginAsset(url.pathname)) {
-      if ((url.pathname === '/login' || url.pathname === '/register') && url.search) {
-        return redirect(res, url.pathname);
-      }
       if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
       return await serveStatic(req, res, url);
     }
@@ -282,7 +308,7 @@ async function handleApi(req, res, url, user) {
   }
 
   if (pathname.startsWith('/api/admin/')) {
-    return await handleAdminApi(req, res, pathname, method, user);
+    return await handleAdminApi(req, res, url, user);
   }
 
   const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
@@ -366,12 +392,138 @@ async function handleApi(req, res, url, user) {
   return sendJson(res, 404, { error: 'Not found.' });
 }
 
-async function handleAdminApi(req, res, pathname, method, user) {
+async function handleAdminApi(req, res, url, user) {
+  const method = req.method || 'GET';
+  const pathname = url.pathname;
   if (!pathname.startsWith('/api/admin/')) return null;
   if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限。' });
   if (method === 'GET' && pathname === '/api/admin/settings') {
     return sendJson(res, 200, { settings: runtimeSettings() });
   }
+  if (method === 'GET' && pathname === '/api/admin/system-email') {
+    return sendJson(res, 200, { settings: getSystemEmailSettings() });
+  }
+  if (method === 'GET' && pathname === '/api/admin/audit-logs') {
+    return sendJson(res, 200, { logs: listAuditLogs(adminAuditFilters(url.searchParams)) });
+  }
+  if (method === 'GET' && pathname === '/api/admin/resources') {
+    return sendJson(res, 200, { inventory: getAdminResourceInventory() });
+  }
+  const transferDomainMatch = pathname.match(/^\/api\/admin\/resources\/domains\/(\d+)\/transfer$/);
+  if (transferDomainMatch && method === 'POST') {
+    const body = await readJson(req);
+    try {
+      const domain = transferDomain({
+        actorUserId: user.id,
+        domainId: Number(transferDomainMatch[1]),
+        targetUserId: body.targetUserId,
+        dnsCredentialMode: body.dnsCredentialMode
+      });
+      return sendJson(res, 200, { domain });
+    } catch (error) {
+      return sendAdminTransferError(res, error);
+    }
+  }
+  const transferDnsCredentialMatch = pathname.match(/^\/api\/admin\/resources\/dns-credentials\/(\d+)\/transfer$/);
+  if (transferDnsCredentialMatch && method === 'POST') {
+    const body = await readJson(req);
+    try {
+      const credential = transferDnsCredential({
+        actorUserId: user.id,
+        credentialId: Number(transferDnsCredentialMatch[1]),
+        targetUserId: body.targetUserId
+      });
+      return sendJson(res, 200, { credential });
+    } catch (error) {
+      return sendAdminTransferError(res, error);
+    }
+  }
+  if (method === 'POST' && pathname === '/api/admin/resources/api-tokens/transfer') {
+    const body = await readJson(req);
+    try {
+      const tokens = transferApiTokens({
+        actorUserId: user.id,
+        tokenIds: body.tokenIds,
+        targetUserId: body.targetUserId
+      });
+      return sendJson(res, 200, { tokens });
+    } catch (error) {
+      return sendAdminTransferError(res, error);
+    }
+  }
+  if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/preview') {
+    const body = await readJson(req);
+    try {
+      const preview = previewUserMerge({
+        sourceUserId: body.sourceUserId,
+        targetUserId: body.targetUserId
+      });
+      return sendJson(res, 200, { preview });
+    } catch (error) {
+      return sendAdminMigrationError(res, error);
+    }
+  }
+  if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/execute') {
+    const body = await readJson(req);
+    try {
+      const result = executeUserMerge({
+        actorUserId: user.id,
+        sourceUserId: body.sourceUserId,
+        targetUserId: body.targetUserId,
+        options: body.options,
+        confirmation: body.confirmation
+      });
+      return sendJson(res, 200, { result });
+    } catch (error) {
+      return sendAdminMigrationError(res, error);
+    }
+  }
+  if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/system-email') {
+    const body = await readJson(req);
+    const settings = saveSystemEmailSettings({
+      host: body.host,
+      port: body.port,
+      secure: body.secure,
+      username: body.username,
+      password: body.password,
+      helo: body.helo,
+      fromEmail: body.fromEmail,
+      fromName: body.fromName,
+      testRecipient: body.testRecipient
+    });
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.update_system_email',
+      targetType: 'system_email',
+      targetId: 'default',
+      summary: settings
+    });
+    return sendJson(res, 200, { settings });
+  }
+  if (method === 'POST' && pathname === '/api/admin/system-email/test') {
+    const body = await readJson(req).catch(() => ({}));
+    const settings = systemMailSettingsForSend();
+    const to = extractAddress(body.to || settings.testRecipient);
+    if (!to) return sendJson(res, 400, { error: '测试收件人地址格式不正确。' });
+    const result = await sendSystemEmail(settings, {
+      to,
+      subject: 'MailHub 系统邮件测试',
+      text: '这是一封 MailHub 系统邮件测试。'
+    });
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.test_system_email',
+      targetType: 'system_email',
+      targetId: 'default',
+      summary: {
+        to,
+        ok: result.ok,
+        message: result.message,
+        queueId: result.queueId
+      }
+    });
+    return sendJson(res, result.ok ? 202 : 502, { result });
+  }
   if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/settings') {
     const body = await readJson(req);
     saveSettings({
@@ -386,21 +538,192 @@ async function handleAdminApi(req, res, pathname, method, user) {
     return sendJson(res, 200, { settings: runtimeSettings() });
   }
   if (method === 'GET' && pathname === '/api/admin/users') {
-    return sendJson(res, 200, { users: listUsers() });
+    return sendJson(res, 200, { users: listUsersWithResourceCounts() });
+  }
+  const approveMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/approve$/);
+  if (approveMatch && method === 'POST') {
+    const current = getUser(Number(approveMatch[1]));
+    if (!current) return sendJson(res, 404, { error: '用户不存在。' });
+    if (current.status === 'pending_email') return sendJson(res, 400, { error: '用户尚未验证邮箱。' });
+    if (current.status !== 'pending_review') return sendJson(res, 400, { error: '只能审批等待审核的用户。' });
+    const target = approveUser(current.id);
+    if (!target) return sendJson(res, 404, { error: '用户不存在。' });
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.approve_user',
+      targetType: 'user',
+      targetId: String(target.id),
+      targetUserId: target.id,
+      summary: {
+        username: target.username,
+        status: target.status
+      }
+    });
+    return sendJson(res, 200, { user: target });
+  }
+  const resendVerificationMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/resend-verification$/);
+  if (resendVerificationMatch && method === 'POST') {
+    const target = getUser(Number(resendVerificationMatch[1]));
+    if (!target) return sendJson(res, 404, { error: '用户不存在。' });
+    if (target.status !== 'pending_email') return sendJson(res, 400, { error: '用户不需要重新发送验证邮件。' });
+    const result = await createAndSendVerificationEmail(target);
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.resend_verification',
+      targetType: 'user',
+      targetId: String(target.id),
+      targetUserId: target.id,
+      summary: {
+        username: target.username,
+        email: target.email,
+        verificationEmailSent: result.ok,
+        message: result.message,
+        queueId: result.queueId
+      }
+    });
+    return sendJson(res, 202, verificationEmailResponse(result));
+  }
+  const passwordResetMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/password-reset$/);
+  if (passwordResetMatch && method === 'POST') {
+    const target = getUser(Number(passwordResetMatch[1]));
+    if (!target) return sendJson(res, 404, { error: '用户不存在。' });
+    const result = await createAndSendPasswordResetEmail(target);
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.password_reset',
+      targetType: 'user',
+      targetId: String(target.id),
+      targetUserId: target.id,
+      summary: {
+        username: target.username,
+        email: target.email,
+        ok: result.ok,
+        message: result.message,
+        queueId: result.queueId
+      }
+    });
+    return sendJson(res, result.ok ? 202 : 502, { result });
+  }
+  const temporaryPasswordMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/temporary-password$/);
+  if (temporaryPasswordMatch && method === 'POST') {
+    const target = getUser(Number(temporaryPasswordMatch[1]));
+    if (!target) return sendJson(res, 404, { error: '用户不存在。' });
+    const body = await readJson(req);
+    let updated;
+    try {
+      updated = updateUser(target.id, { password: body.password });
+    } catch (error) {
+      if (error?.message === '密码至少需要 8 位。') return sendJson(res, 400, { error: error.message });
+      throw error;
+    }
+    logAudit({
+      actorUserId: user.id,
+      action: 'admin.temporary_password',
+      targetType: 'user',
+      targetId: String(target.id),
+      targetUserId: target.id,
+      summary: {
+        username: target.username,
+        email: target.email,
+        passwordSet: true
+      }
+    });
+    return sendJson(res, 200, { user: updated });
   }
   const userMatch = pathname.match(/^\/api\/admin\/users\/(\d+)$/);
   if (userMatch && method === 'PATCH') {
     const body = await readJson(req);
-    const updated = updateUser(Number(userMatch[1]), {
-      role: body.role,
-      status: body.status,
-      password: body.password
-    });
+    let updated;
+    try {
+      updated = updateUser(Number(userMatch[1]), {
+        role: body.role,
+        status: body.status,
+        password: body.password
+      });
+    } catch (error) {
+      if (['用户状态不正确。', '密码至少需要 8 位。'].includes(error?.message)) {
+        return sendJson(res, 400, { error: error.message });
+      }
+      throw error;
+    }
     return sendJson(res, updated ? 200 : 404, { user: updated });
   }
   return sendJson(res, 404, { error: 'Not found.' });
 }
 
+function adminAuditFilters(searchParams) {
+  const requested = {
+    actorUserId: auditUserIdParam(searchParams.get('actorUserId'), { allowSystem: true }),
+    targetUserId: auditUserIdParam(searchParams.get('targetUserId')),
+    action: auditTextParam(searchParams.get('action')),
+    from: auditDateParam(searchParams.get('from')),
+    to: auditDateParam(searchParams.get('to'))
+  };
+  return Object.fromEntries(
+    ['actorUserId', 'targetUserId', 'action', 'from', 'to']
+      .filter((key) => requested[key] !== undefined)
+      .map((key) => [key, requested[key]])
+  );
+}
+
+function sendAdminTransferError(res, error) {
+  if (['域名不存在。', 'DNS 凭据不存在。', 'API Token 不存在。'].includes(error?.message)) {
+    return sendJson(res, 404, { error: error.message });
+  }
+  if (['目标用户不可用。', 'DNS 凭据归属不一致。'].includes(error?.message)) {
+    return sendJson(res, 400, { error: error.message });
+  }
+  throw error;
+}
+
+function sendAdminMigrationError(res, error) {
+  if (['源用户不存在。'].includes(error?.message)) return sendJson(res, 404, { error: error.message });
+  if ([
+    '目标用户不可用。',
+    '源用户和目标用户不能相同。',
+    '确认文本不匹配。'
+  ].includes(error?.message)) {
+    return sendJson(res, 400, { error: error.message });
+  }
+  throw error;
+}
+
+function auditUserIdParam(value, { allowSystem = false } = {}) {
+  if (value === null) return undefined;
+  const text = String(value).trim();
+  if (!text) return undefined;
+  if (allowSystem && ['system', 'null'].includes(text.toLowerCase())) return null;
+  return /^[1-9]\d*$/.test(text) ? Number(text) : undefined;
+}
+
+function auditTextParam(value) {
+  const text = String(value || '').trim();
+  return text || undefined;
+}
+
+function auditDateParam(value) {
+  const text = String(value || '').trim();
+  if (!text) return undefined;
+  const dateOnly = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+  if (dateOnly) {
+    const year = Number(dateOnly[1]);
+    const month = Number(dateOnly[2]);
+    const day = Number(dateOnly[3]);
+    const date = new Date(Date.UTC(year, month - 1, day));
+    if (
+      date.getUTCFullYear() === year &&
+      date.getUTCMonth() === month - 1 &&
+      date.getUTCDate() === day
+    ) {
+      return date.toISOString();
+    }
+    return undefined;
+  }
+  if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text)) return undefined;
+  const date = new Date(text);
+  return !Number.isNaN(date.getTime()) && date.toISOString() === text ? text : undefined;
+}
+
 async function sendMailFromBody(body, user) {
   const settings = runtimeSettings();
   const from = extractAddress(body.from);
@@ -473,28 +796,165 @@ function deliveryLogFromError(error) {
   }];
 }
 
+function runBackground(promise) {
+  promise.catch((error) => console.error(error));
+}
+
 async function handleRegister(req, res) {
   const body = await readJson(req);
   try {
-    const user = createUser({
+    const { user, accountToken } = createUserWithAccountToken({
       username: body.username,
       email: body.email,
-      password: body.password
-    });
-    return sendAuthSuccess(req, res, 201, user);
+      password: body.password,
+      status: 'pending_email'
+    }, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
+    const emailResult = await sendVerificationEmail(user, accountToken.token);
+    return sendRegisterSuccess(req, res, user, emailResult);
   } catch (error) {
     if (isUniqueError(error)) return sendAuthError(req, res, 409, '用户名或邮箱已被注册。', '/register');
     return sendAuthError(req, res, 400, error.message || '注册失败。', '/register');
   }
 }
 
+async function handleResendVerification(req, res) {
+  const body = await readJson(req);
+  const user = getUserByLogin(body.email);
+  if (user?.status === 'pending_email') {
+    runBackground(createAndSendVerificationEmail(user));
+  }
+  return sendJson(res, 202, publicVerificationResendResponse());
+}
+
+async function handleForgotPassword(req, res) {
+  const body = await readJson(req);
+  const user = getUserByLogin(body.email);
+  if (user) runBackground(createAndSendPasswordResetEmail(user));
+  return sendJson(res, 202, publicForgotPasswordResponse());
+}
+
+async function handleResetPassword(req, res) {
+  const body = await readJson(req);
+  if (String(body.password || '').length < 8) return sendJson(res, 400, { error: '密码至少需要 8 位。' });
+  const consumed = consumeAccountToken(body.token, passwordResetPurpose);
+  if (!consumed) return sendJson(res, 400, { error: '重置链接无效或已过期。' });
+  updateUser(consumed.userId, { password: body.password });
+  return sendJson(res, 200, { message: '密码已重置,请使用新密码登录。' });
+}
+
+async function handleVerifyEmail(req, res, url) {
+  if ((req.method || 'GET') !== 'GET') return sendJson(res, 404, { error: 'Not found.' });
+  const token = String(url.searchParams.get('token') || '').trim();
+  if (!token) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  const consumed = consumeAccountToken(token, emailVerificationPurpose);
+  if (!consumed) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  const user = markUserEmailVerified(consumed.userId);
+  if (!user) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  return sendJson(res, 200, {
+    user,
+    message: '邮箱验证成功,请等待管理员审核。'
+  });
+}
+
+async function createAndSendVerificationEmail(user) {
+  const settings = systemMailSettingsForSend();
+  if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
+  invalidateAccountTokens(user.id, emailVerificationPurpose);
+  const accountToken = createAccountToken(user.id, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
+  const result = await sendVerificationEmailWithSettings(user, accountToken.token, settings);
+  if (!result.ok) invalidateAccountTokens(user.id, emailVerificationPurpose);
+  return result;
+}
+
+async function createAndSendPasswordResetEmail(user) {
+  const settings = systemMailSettingsForSend();
+  if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
+  invalidateAccountTokens(user.id, passwordResetPurpose);
+  const accountToken = createAccountToken(user.id, passwordResetPurpose, { ttlMinutes: 60 });
+  const result = await sendSystemEmail(settings, buildPasswordResetEmail({
+    appBaseUrl: settings.appBaseUrl,
+    to: user.email,
+    token: accountToken.token,
+    fromEmail: settings.fromEmail,
+    fromName: settings.fromName
+  }));
+  if (!result.ok) invalidateAccountTokens(user.id, passwordResetPurpose);
+  return result;
+}
+
+async function sendVerificationEmail(user, token) {
+  const settings = systemMailSettingsForSend();
+  if (!systemMailConfigured(settings)) {
+    return { ok: false, message: '系统邮件未配置。' };
+  }
+  return await sendVerificationEmailWithSettings(user, token, settings);
+}
+
+async function sendVerificationEmailWithSettings(user, token, settings) {
+  return await sendSystemEmail(settings, buildVerificationEmail({
+    appBaseUrl: settings.appBaseUrl,
+    to: user.email,
+    token,
+    fromEmail: settings.fromEmail,
+    fromName: settings.fromName
+  }));
+}
+
+function systemMailConfigured(settings) {
+  return Boolean(settings.host && extractAddress(settings.fromEmail));
+}
+
+function systemMailSettingsForSend() {
+  return {
+    ...getSystemEmailSettings({ includeSecret: true }),
+    appBaseUrl: runtimeSettings().appBaseUrl
+  };
+}
+
+function publicVerificationResendResponse() {
+  return {
+    message: '如果账号需要验证,我们会发送验证邮件。'
+  };
+}
+
+function publicForgotPasswordResponse() {
+  return {
+    message: '如果邮箱存在,我们会发送密码重置邮件。'
+  };
+}
+
+function verificationEmailResponse(result) {
+  return {
+    verificationEmailSent: Boolean(result.ok),
+    message: result.ok ? '验证邮件已发送。' : '验证邮件暂未发送,请稍后重试或联系管理员。',
+    result: {
+      ok: Boolean(result.ok),
+      message: result.message || '',
+      queueId: result.queueId || ''
+    }
+  };
+}
+
 async function handleLogin(req, res) {
   const body = await readJson(req);
-  const user = authenticateUser(body.username || body.email, body.password);
+  const user = verifyUserCredentials(body.username || body.email, body.password);
   if (!user) return sendAuthError(req, res, 401, '账号或密码不正确。', '/login');
+  if (user.status !== 'active') return sendAuthError(req, res, 403, loginStatusMessage(user.status), '/login');
   return sendAuthSuccess(req, res, 200, user);
 }
 
+function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
+  const message = emailResult.ok
+    ? '注册成功,验证邮件已发送,请先验证邮箱,验证后等待管理员审核。'
+    : '注册成功,请先验证邮箱;验证邮件暂未发送,请联系管理员或稍后重试。';
+  if (wantsHtmlRedirect(req)) return redirect(res, `/login?error=${encodeURIComponent(message)}`, 303);
+  return sendJson(res, 201, {
+    user,
+    message,
+    verificationEmailSent: Boolean(emailResult.ok)
+  });
+}
+
 function sendAuthSuccess(req, res, status, user) {
   const token = createSessionToken(user);
   const cookie = sessionCookie(token);
@@ -511,6 +971,13 @@ function sendAuthError(req, res, status, message, fallbackPath) {
   return sendJson(res, status, { error: message });
 }
 
+function loginStatusMessage(status) {
+  if (status === 'pending_email') return '请先验证邮箱。';
+  if (status === 'pending_review') return '账号正在等待管理员审核。';
+  if (status === 'disabled') return '账号已被禁用。';
+  return '账号或密码不正确。';
+}
+
 function handleLogout(res) {
   res.writeHead(200, {
     'Content-Type': 'application/json; charset=utf-8',
@@ -766,12 +1233,21 @@ function isUniqueError(error) {
 
 function isLoginAsset(pathname) {
   return pathname.startsWith('/assets/')
-    || ['/login', '/register', '/login.html', '/login.css', '/login.js'].includes(pathname);
+    || [
+      '/login',
+      '/register',
+      '/forgot-password',
+      '/resend-verification',
+      '/reset-password',
+      '/login.html',
+      '/login.css',
+      '/login.js'
+    ].includes(pathname);
 }
 
 function resolveStaticPathname(pathname) {
   if (pathname === '/') return '/index.html';
-  if (pathname === '/login' || pathname === '/register') return '/login.html';
+  if (['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password'].includes(pathname)) return '/login.html';
   return pathname;
 }
 

+ 94 - 0
src/system-mail.js

@@ -0,0 +1,94 @@
+import {
+  buildMessage,
+  extractAddress,
+  parseAddressList,
+  sendViaSmtp as smtpSendViaSmtp
+} from './mailer.js';
+
+export function buildVerificationEmail({ appBaseUrl, to, token, fromEmail, fromName }) {
+  const verifyUrl = accountUrl(appBaseUrl, '/api/auth/verify-email', token);
+  return {
+    from: formatSender(fromEmail, fromName),
+    to,
+    subject: '验证邮箱 - MailHub',
+    text: [
+      '请点击下面的链接验证你的邮箱:',
+      '',
+      verifyUrl,
+      '',
+      '验证后账号会进入管理员审核流程。'
+    ].join('\n')
+  };
+}
+
+export function buildPasswordResetEmail({ appBaseUrl, to, token, fromEmail, fromName }) {
+  const resetUrl = accountUrl(appBaseUrl, '/reset-password', token);
+  return {
+    from: formatSender(fromEmail, fromName),
+    to,
+    subject: '重置密码 - MailHub',
+    text: [
+      '请点击下面的链接重置你的密码:',
+      '',
+      resetUrl,
+      '',
+      '如果不是你本人发起的请求,可以忽略这封邮件。'
+    ].join('\n')
+  };
+}
+
+export async function sendSystemEmail(settings, message, { sendViaSmtp = smtpSendViaSmtp } = {}) {
+  const from = message.from || formatSender(settings.fromEmail, settings.fromName);
+  const recipients = normalizeRecipients(message.to);
+  const rawMessage = buildMessage({
+    from,
+    to: recipients,
+    subject: message.subject,
+    text: message.text,
+    html: message.html,
+    baseUrl: settings.appBaseUrl
+  });
+  try {
+    const result = await sendViaSmtp({
+      host: settings.host,
+      port: settings.port,
+      secure: settings.secure,
+      username: settings.username,
+      password: settings.password,
+      helo: settings.helo,
+      mailFrom: extractAddress(settings.fromEmail || from),
+      recipients,
+      rawMessage
+    });
+    return {
+      ok: true,
+      code: result.code,
+      message: result.message,
+      queueId: result.queueId || ''
+    };
+  } catch (error) {
+    return {
+      ok: false,
+      message: error.message
+    };
+  }
+}
+
+function accountUrl(appBaseUrl, pathname, token) {
+  const base = String(appBaseUrl || '').replace(/\/+$/, '') || 'http://127.0.0.1:3000';
+  return `${base}${pathname}?token=${encodeURIComponent(token || '')}`;
+}
+
+function formatSender(fromEmail, fromName) {
+  const email = extractAddress(fromEmail);
+  const name = String(fromName || '').replace(/["\r\n]+/g, ' ').trim();
+  if (!name) return email;
+  return `"${name}" <${email}>`;
+}
+
+function normalizeRecipients(value) {
+  const items = Array.isArray(value) ? value : parseAddressList(value);
+  return items
+    .map((item) => (/[\r\n]/.test(String(item || '')) ? '' : extractAddress(item)))
+    .filter(Boolean);
+}

+ 730 - 0
test/db.test.js

@@ -1,4 +1,5 @@
 import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
 import { mkdtempSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
@@ -7,17 +8,40 @@ import { DatabaseSync } from 'node:sqlite';
 import {
   authenticateUser,
   claimLegacyData,
+  consumeAccountToken,
   createApiToken,
+  createAccountToken,
   createDomain,
   createUser,
+  createUserWithAccountToken,
+  getDnsCredential,
+  getDomain,
   getSendAnalytics,
   getSmtpCredential,
+  getAdminResourceInventory,
+  getSystemEmailSettings,
+  getUser,
   initDatabase,
+  listAuditLogs,
   listDomains,
   listSendEvents,
+  listUsersWithResourceCounts,
+  invalidateAccountTokens,
+  logAudit,
   logSendEvent,
+  approveUser,
+  markUserEmailVerified,
+  previewUserMerge,
+  saveDnsCredential,
   saveSmtpCredential,
+  saveSystemEmailSettings,
   seedAdminUser,
+  transferApiTokens,
+  transferDnsCredential,
+  transferDomain,
+  updateUser,
+  updateUserStatus,
+  executeUserMerge,
   verifyApiToken,
   verifySmtpCredential
 } from '../src/db.js';
@@ -127,6 +151,521 @@ test('isolates domains, smtp credentials, and api tokens by user', () => {
   assert.equal(verifyApiToken(token.token).id, alice.id);
 });
 
+test('stores account tokens as hashes and enforces token lifecycle', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const purpose = 'email_verification';
+  const created = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
+  const expectedHash = createHash('sha256').update(created.token).digest('hex');
+
+  assert.equal(created.userId, alice.id);
+  assert.equal(created.purpose, purpose);
+  assert.equal(typeof created.token, 'string');
+  assert.equal(created.token.length > 32, true);
+  assert.equal(created.usedAt, null);
+  assert.equal('tokenHash' in created, false);
+
+  const stored = database.prepare('SELECT * FROM account_tokens WHERE id = ?').get(created.id);
+  assert.equal(stored.user_id, alice.id);
+  assert.equal(stored.purpose, purpose);
+  assert.equal(stored.token_hash, expectedHash);
+  assert.notEqual(stored.token_hash, created.token);
+  assert.equal(Object.values(stored).includes(created.token), false);
+
+  assert.equal(consumeAccountToken(created.token, 'password_reset'), null);
+  const consumed = consumeAccountToken(created.token, purpose);
+  assert.equal(consumed.id, created.id);
+  assert.equal(consumed.userId, alice.id);
+  assert.equal(consumed.purpose, purpose);
+  assert.equal(typeof consumed.usedAt, 'string');
+  assert.equal('token' in consumed, false);
+  assert.equal(consumeAccountToken(created.token, purpose), null);
+
+  const expired = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
+  database
+    .prepare('UPDATE account_tokens SET expires_at = ? WHERE id = ?')
+    .run('2000-01-01T00:00:00.000Z', expired.id);
+  assert.equal(consumeAccountToken(expired.token, purpose), null);
+
+  const alreadyUsedReset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
+  assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset').id, alreadyUsedReset.id);
+  const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
+  const otherPurpose = createAccountToken(alice.id, 'email_change', { ttlMinutes: 30 });
+  const otherUserReset = createAccountToken(bob.id, 'password_reset', { ttlMinutes: 30 });
+
+  assert.equal(invalidateAccountTokens(alice.id, 'password_reset'), 1);
+  assert.equal(typeof database.prepare('SELECT used_at FROM account_tokens WHERE id = ?').get(reset.id).used_at, 'string');
+  assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
+  assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset'), null);
+  assert.equal(consumeAccountToken(otherPurpose.token, 'email_change').id, otherPurpose.id);
+  assert.equal(consumeAccountToken(otherUserReset.token, 'password_reset').id, otherUserReset.id);
+});
+
+test('password updates invalidate unused password reset tokens only', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
+  const verification = createAccountToken(alice.id, 'email_verification', { ttlMinutes: 30 });
+
+  updateUser(alice.id, { password: 'new-password-123' });
+
+  assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
+  assert.equal(consumeAccountToken(verification.token, 'email_verification').id, verification.id);
+  assert.equal(authenticateUser('alice', 'password123'), null);
+  assert.equal(authenticateUser('alice', 'new-password-123').id, alice.id);
+});
+
+test('validates account token ttl minute boundaries', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const maxTtlMinutes = 7 * 24 * 60;
+
+  for (const ttlMinutes of [0, -1, 1.5, maxTtlMinutes + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
+    assert.throws(
+      () => createAccountToken(alice.id, 'email_verification', { ttlMinutes }),
+      /令牌有效期不正确。/
+    );
+  }
+
+  assert.equal(createAccountToken(alice.id, 'email_verification', { ttlMinutes: 1 }).userId, alice.id);
+  assert.equal(createAccountToken(alice.id, 'password_reset', { ttlMinutes: maxTtlMinutes }).userId, alice.id);
+});
+
+test('creates users with account tokens atomically', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+
+  assert.throws(
+    () => createUserWithAccountToken({
+      username: 'rollback',
+      email: 'rollback@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    }, 'email_verification', { ttlMinutes: 0 }),
+    /令牌有效期不正确。/
+  );
+  assert.equal(
+    database
+      .prepare("SELECT COUNT(*) AS count FROM users WHERE username = 'rollback' OR email = 'rollback@example.com'")
+      .get()
+      .count,
+    0
+  );
+  assert.equal(database.prepare('SELECT COUNT(*) AS count FROM account_tokens').get().count, 0);
+
+  const { user, accountToken } = createUserWithAccountToken({
+    username: 'atomic',
+    email: 'atomic@example.com',
+    password: 'password123',
+    status: 'pending_email'
+  }, 'email_verification', { ttlMinutes: 30 });
+
+  assert.equal(user.status, 'pending_email');
+  assert.equal(accountToken.userId, user.id);
+  assert.equal(accountToken.purpose, 'email_verification');
+  assert.equal(typeof accountToken.token, 'string');
+  assert.equal('tokenHash' in accountToken, false);
+  assert.equal(
+    database
+      .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?')
+      .get(user.id, 'email_verification')
+      .count,
+    1
+  );
+});
+
+test('moves users through the extended status lifecycle', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const pendingEmail = createUser({
+    username: 'pending',
+    email: 'pending@example.com',
+    password: 'password123',
+    status: 'pending_email'
+  });
+  const seededAdmin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
+
+  assert.equal(pendingEmail.status, 'pending_email');
+  assert.equal(seededAdmin.status, 'active');
+  assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
+  assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
+  assert.equal(approveUser(pendingEmail.id).status, 'active');
+  assert.equal(markUserEmailVerified(pendingEmail.id).status, 'active');
+  assert.equal(updateUserStatus(pendingEmail.id, 'disabled').status, 'disabled');
+  assert.equal(updateUser(pendingEmail.id, { status: 'pending_review' }).status, 'pending_review');
+
+  assert.equal(markUserEmailVerified(999999), null);
+  assert.throws(() => updateUserStatus(pendingEmail.id, 'archived'), /用户状态不正确/);
+  assert.throws(
+    () => createUser({
+      username: 'invalidstatus',
+      email: 'invalidstatus@example.com',
+      password: 'password123',
+      status: 'archived'
+    }),
+    /用户状态不正确/
+  );
+  assert.equal(getUser(pendingEmail.id).status, 'pending_review');
+});
+
+test('lists users with owned resource counts', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
+  createDomain(alice.id, domainFixture('news.alice.example'));
+  const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
+
+  saveDnsCredential(alice.id, {
+    name: 'Alice Cloudflare',
+    provider: 'cloudflare',
+    zoneName: 'alice.example',
+    credentials: { apiToken: 'alice-secret-dns-token', zoneId: 'alice-zone' }
+  });
+  saveDnsCredential(alice.id, {
+    name: 'Alice News Cloudflare',
+    provider: 'cloudflare',
+    zoneName: 'news.alice.example',
+    credentials: { apiToken: 'alice-news-secret-dns-token', zoneId: 'alice-news-zone' }
+  });
+
+  createApiToken(alice.id, 'primary');
+  createApiToken(alice.id, 'secondary');
+  createApiToken(bob.id, 'primary');
+  saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
+
+  logSendEvent({
+    userId: alice.id,
+    domainId: aliceDomain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['one@example.com'],
+    subject: 'First',
+    status: 'queued'
+  });
+  logSendEvent({
+    userId: alice.id,
+    domainId: aliceDomain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['two@example.com'],
+    subject: 'Second',
+    status: 'sent'
+  });
+  logSendEvent({
+    userId: bob.id,
+    domainId: bobDomain.id,
+    sender: 'noreply@bob.example',
+    recipients: ['bob@example.com'],
+    subject: 'Bob',
+    status: 'queued'
+  });
+
+  const users = listUsersWithResourceCounts();
+  const aliceWithCounts = users.find((user) => user.id === alice.id);
+  const bobWithCounts = users.find((user) => user.id === bob.id);
+
+  assert.deepEqual(aliceWithCounts.resourceCounts, {
+    domains: 2,
+    dnsCredentials: 2,
+    apiTokens: 2,
+    sendEvents: 2,
+    smtpCredential: 1
+  });
+  assert.deepEqual(bobWithCounts.resourceCounts, {
+    domains: 1,
+    dnsCredentials: 0,
+    apiTokens: 1,
+    sendEvents: 1,
+    smtpCredential: 0
+  });
+  assert.equal(typeof aliceWithCounts.resourceCounts.domains, 'number');
+  assert.equal('passwordHash' in aliceWithCounts, false);
+  assert.equal(JSON.stringify(users).includes('alice-secret-dns-token'), false);
+  assert.equal(JSON.stringify(users).includes('smtp-secret-123'), false);
+});
+
+test('builds admin resource inventory grouped by user with ownership warnings', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const bobCredential = saveDnsCredential(bob.id, {
+    name: 'Bob DNS',
+    provider: 'cloudflare',
+    zoneName: 'bob.example',
+    credentials: { apiToken: 'bob-secret-token', zoneId: 'bob-zone' }
+  });
+  const aliceDomain = createDomain(alice.id, {
+    ...domainFixture('alice.example'),
+    dnsCredentialId: bobCredential.id
+  });
+  createDomain(bob.id, domainFixture('bob.example'));
+  createApiToken(alice.id, 'primary');
+  saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
+  logSendEvent({
+    userId: alice.id,
+    domainId: aliceDomain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['a@example.com'],
+    subject: 'Queued',
+    status: 'queued'
+  });
+
+  const inventory = getAdminResourceInventory();
+  const aliceResources = inventory.users.find((entry) => entry.user.id === alice.id);
+  const bobResources = inventory.users.find((entry) => entry.user.id === bob.id);
+
+  assert.equal(aliceResources.domains.length, 1);
+  assert.equal(aliceResources.domains[0].domain, 'alice.example');
+  assert.equal(aliceResources.dnsCredentials.length, 0);
+  assert.equal(aliceResources.apiTokens.length, 1);
+  assert.equal(aliceResources.smtpCredential.username, 'smtp-alice');
+  assert.equal(aliceResources.smtpCredential.passwordSet, true);
+  assert.equal(aliceResources.sendEventCount, 1);
+  assert.equal(bobResources.domains.length, 1);
+  assert.equal(bobResources.dnsCredentials.length, 1);
+  assert.deepEqual(inventory.warnings, [{
+    type: 'domain_dns_credential_owner_mismatch',
+    domainId: aliceDomain.id,
+    domain: 'alice.example',
+    domainUserId: alice.id,
+    dnsCredentialId: bobCredential.id,
+    dnsCredentialUserId: bob.id
+  }]);
+  assert.equal(JSON.stringify(inventory).includes('bob-secret-token'), false);
+  assert.equal(JSON.stringify(inventory).includes('smtp-secret-123'), false);
+});
+
+test('transfers individual resources with audit logs', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const disabled = createUser({ username: 'disabled', email: 'disabled@example.com', password: 'password123', status: 'disabled' });
+  const domainOnlyCredential = saveDnsCredential(alice.id, {
+    name: 'Alice DNS 1',
+    provider: 'cloudflare',
+    zoneName: 'alice.example',
+    credentials: { apiToken: 'alice-secret-1' }
+  });
+  const clearCredential = saveDnsCredential(alice.id, {
+    name: 'Alice DNS 2',
+    provider: 'cloudflare',
+    zoneName: 'clear.example',
+    credentials: { apiToken: 'alice-secret-2' }
+  });
+  const withCredential = saveDnsCredential(alice.id, {
+    name: 'Alice DNS 3',
+    provider: 'cloudflare',
+    zoneName: 'with.example',
+    credentials: { apiToken: 'alice-secret-3' }
+  });
+  const standaloneCredential = saveDnsCredential(alice.id, {
+    name: 'Alice DNS 4',
+    provider: 'cloudflare',
+    zoneName: 'standalone.example',
+    credentials: { apiToken: 'alice-secret-4' }
+  });
+  const domainOnly = createDomain(alice.id, { ...domainFixture('domain-only.example'), dnsCredentialId: domainOnlyCredential.id });
+  const clearDomain = createDomain(alice.id, { ...domainFixture('clear.example'), dnsCredentialId: clearCredential.id });
+  const withDomain = createDomain(alice.id, { ...domainFixture('with.example'), dnsCredentialId: withCredential.id });
+  const apiToken = createApiToken(alice.id, 'primary');
+
+  assert.equal(transferDomain({
+    actorUserId: admin.id,
+    domainId: domainOnly.id,
+    targetUserId: bob.id,
+    dnsCredentialMode: 'domain_only'
+  }).userId, bob.id);
+  assert.equal(getDomain(domainOnly.id).dnsCredentialId, domainOnlyCredential.id);
+  assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
+  assert.throws(
+    () => transferDomain({
+      actorUserId: admin.id,
+      domainId: domainOnly.id,
+      targetUserId: bob.id,
+      dnsCredentialMode: 'with_dns_credential'
+    }),
+    /DNS 凭据归属不一致。/
+  );
+  assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
+
+  assert.equal(transferDomain({
+    actorUserId: admin.id,
+    domainId: clearDomain.id,
+    targetUserId: bob.id,
+    dnsCredentialMode: 'clear_dns_credential'
+  }).dnsCredentialId, null);
+  assert.equal(getDnsCredential(clearCredential.id, alice.id).id, clearCredential.id);
+
+  assert.equal(transferDomain({
+    actorUserId: admin.id,
+    domainId: withDomain.id,
+    targetUserId: bob.id,
+    dnsCredentialMode: 'with_dns_credential'
+  }).userId, bob.id);
+  assert.equal(getDnsCredential(withCredential.id, bob.id).id, withCredential.id);
+
+  assert.equal(transferDnsCredential({
+    actorUserId: admin.id,
+    credentialId: standaloneCredential.id,
+    targetUserId: bob.id
+  }).userId, bob.id);
+  assert.equal(getDnsCredential(standaloneCredential.id, bob.id).id, standaloneCredential.id);
+
+  const transferredTokens = transferApiTokens({
+    actorUserId: admin.id,
+    tokenIds: [apiToken.id],
+    targetUserId: bob.id
+  });
+  assert.equal(transferredTokens.length, 1);
+  assert.equal(verifyApiToken(apiToken.token).id, bob.id);
+
+  assert.throws(
+    () => transferDomain({ actorUserId: admin.id, domainId: domainOnly.id, targetUserId: disabled.id }),
+    /目标用户不可用。/
+  );
+  assert.throws(
+    () => transferApiTokens({ actorUserId: admin.id, tokenIds: [apiToken.id], targetUserId: 999999 }),
+    /目标用户不可用。/
+  );
+
+  const actions = listAuditLogs({ actorUserId: admin.id }).map((entry) => entry.action);
+  assert.ok(actions.includes('admin.transfer_domain'));
+  assert.ok(actions.includes('admin.transfer_dns_credential'));
+  assert.ok(actions.includes('admin.transfer_api_tokens'));
+  assert.equal(JSON.stringify(listAuditLogs({ actorUserId: admin.id })).includes('alice-secret'), false);
+});
+
+test('previews and executes user merge with resource counts and smtp conflict handling', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
+  const source = createUser({ username: 'source', email: 'source@example.com', password: 'password123' });
+  const target = createUser({ username: 'target', email: 'target@example.com', password: 'password123' });
+  const credential = saveDnsCredential(source.id, {
+    name: 'Source DNS',
+    provider: 'cloudflare',
+    zoneName: 'source.example',
+    credentials: { apiToken: 'source-secret' }
+  });
+  const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
+  const apiToken = createApiToken(source.id, 'primary');
+  saveSmtpCredential(source.id, { username: 'smtp-source', password: 'source-secret-123' });
+  saveSmtpCredential(target.id, { username: 'smtp-target', password: 'target-secret-123' });
+  logSendEvent({
+    userId: source.id,
+    domainId: domain.id,
+    sender: 'noreply@source.example',
+    recipients: ['a@example.com'],
+    subject: 'Queued',
+    status: 'queued'
+  });
+
+  const preview = previewUserMerge({ sourceUserId: source.id, targetUserId: target.id });
+  assert.equal(preview.confirmationText, 'MERGE source INTO target');
+  assert.deepEqual(preview.counts, {
+    domains: 1,
+    dnsCredentials: 1,
+    apiTokens: 1,
+    sendEvents: 1,
+    smtpCredential: 1
+  });
+  assert.equal(preview.resources.source.domains[0].domain, 'source.example');
+  assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
+  assert.equal(preview.resources.source.apiTokens[0].name, 'primary');
+  assert.equal(preview.resources.source.sendEventCount, 1);
+  assert.equal(preview.resources.target.domains.length, 0);
+  assert.deepEqual(preview.selectedCounts, {
+    domains: 1,
+    dnsCredentials: 1,
+    apiTokens: 1,
+    sendEvents: 1,
+    smtpCredential: 0
+  });
+  assert.equal(preview.smtp.conflict, true);
+  assert.ok(preview.warnings.some((warning) => warning.type === 'smtp_credential_conflict'));
+
+  assert.throws(
+    () => executeUserMerge({
+      actorUserId: admin.id,
+      sourceUserId: source.id,
+      targetUserId: target.id,
+      confirmation: 'wrong'
+    }),
+    /确认文本不匹配。/
+  );
+  assert.equal(getDomain(domain.id).userId, source.id);
+
+  const result = executeUserMerge({
+    actorUserId: admin.id,
+    sourceUserId: source.id,
+    targetUserId: target.id,
+    confirmation: preview.confirmationText
+  });
+
+  assert.deepEqual(result.counts, {
+    domains: 1,
+    dnsCredentials: 1,
+    apiTokens: 1,
+    sendEvents: 1,
+    smtpCredential: 0
+  });
+  assert.equal(getDomain(domain.id).userId, target.id);
+  assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);
+  assert.equal(verifyApiToken(apiToken.token).id, target.id);
+  assert.equal(listSendEvents(target.id).length, 1);
+  assert.equal(getSmtpCredential(target.id).username, 'smtp-target');
+  assert.equal(getSmtpCredential(source.id).username, 'smtp-source');
+  assert.equal(getUser(source.id).status, 'disabled');
+
+  const [audit] = listAuditLogs({ action: 'admin.user_merge' });
+  assert.equal(audit.targetUserId, target.id);
+  assert.equal(audit.summary.sourceUserId, source.id);
+  assert.deepEqual(audit.summary.counts, result.counts);
+  assert.equal(JSON.stringify(audit).includes('source-secret'), false);
+  assert.equal(JSON.stringify(audit).includes('target-secret'), false);
+});
+
+test('stores system email settings without exposing smtp password publicly', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+
+  const saved = saveSystemEmailSettings({
+    host: 'smtp.example.com',
+    port: 465,
+    secure: true,
+    username: 'mailer@example.com',
+    password: 'smtp-password-123',
+    helo: 'mail.example.com',
+    fromEmail: 'notify@example.com',
+    fromName: 'MailHub Notify',
+    testRecipient: 'admin@example.com'
+  });
+
+  assert.equal(saved.host, 'smtp.example.com');
+  assert.equal(saved.port, 465);
+  assert.equal(saved.secure, true);
+  assert.equal(saved.passwordSet, true);
+  assert.equal('password' in saved, false);
+  assert.equal(JSON.stringify(saved).includes('smtp-password-123'), false);
+
+  const storedRows = database.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'").all();
+  assert.equal(storedRows.some((row) => row.value === 'smtp-password-123'), false);
+
+  const publicSettings = getSystemEmailSettings();
+  assert.equal(publicSettings.passwordSet, true);
+  assert.equal('password' in publicSettings, false);
+  assert.equal(JSON.stringify(publicSettings).includes('smtp-password-123'), false);
+
+  const internalSettings = getSystemEmailSettings({ includeSecret: true });
+  assert.equal(internalSettings.password, 'smtp-password-123');
+  assert.equal(internalSettings.passwordSet, true);
+
+  const unchangedPassword = saveSystemEmailSettings({
+    host: 'smtp2.example.com',
+    password: ''
+  });
+  assert.equal(unchangedPassword.host, 'smtp2.example.com');
+  assert.equal(unchangedPassword.passwordSet, true);
+  assert.equal(getSystemEmailSettings({ includeSecret: true }).password, 'smtp-password-123');
+});
+
 test('summarizes send analytics by user', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
@@ -238,6 +777,197 @@ test('stores and returns structured delivery logs for send events', () => {
   assert.deepEqual(event.deliveryLog, deliveryLog);
 });
 
+test('records audit logs without storing secrets', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
+  const user = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+
+  logAudit({
+    actorUserId: admin.id,
+    action: 'admin.temporary_password',
+    targetType: 'user',
+    targetId: String(user.id),
+    targetUserId: user.id,
+    summary: {
+      username: user.username,
+      temporaryPassword: 'secret-password',
+      passwordSet: true,
+      dkimPrivate: 'private-key',
+      dkimPublic: 'public-key',
+      dkim_private: 'private-key',
+      dkim_public: 'public-key',
+      authorization: 'Bearer top-level-token',
+      headers: {
+        authorization: 'Bearer nested-token',
+        from: 'admin@example.com'
+      },
+      nested: {
+        apiToken: 'secret-token',
+        note: 'kept'
+      },
+      changes: [
+        {
+          credential: 'secret-credential',
+          field: 'password'
+        },
+        {
+          field: 'password',
+          value: 'plain-secret',
+          label: 'password change'
+        },
+        {
+          name: 'apiToken',
+          oldValue: 'old-token',
+          newValue: 'new-token'
+        },
+        {
+          header: 'authorization',
+          value: 'Bearer token',
+          status: 'set'
+        },
+        {
+          path: 'smtp.password',
+          from: 'old-password',
+          to: 'new-password'
+        },
+        {
+          key: 'credentials.accessKeySecret',
+          before: 'old-secret',
+          after: 'new-secret'
+        },
+        {
+          field: 'displayName',
+          value: 'Alice Example'
+        },
+        {
+          field: 'password',
+          old: 'old-secret',
+          new: 'new-secret'
+        },
+        {
+          change: { field: 'password' },
+          value: 'plain-secret'
+        },
+        {
+          context: { path: 'smtp.password' },
+          from: 'old-wrapper-secret',
+          to: 'new-wrapper-secret'
+        },
+        {
+          change: { field: 'displayName' },
+          value: 'Alice Wrapper'
+        }
+      ]
+    }
+  });
+
+  const [entry] = listAuditLogs({ actorUserId: admin.id });
+  assert.equal(entry.action, 'admin.temporary_password');
+  assert.equal(entry.targetType, 'user');
+  assert.equal(entry.targetId, String(user.id));
+  assert.equal(entry.targetUserId, user.id);
+  assert.equal(entry.summary.username, 'alice');
+  assert.equal(entry.summary.temporaryPassword, undefined);
+  assert.equal(entry.summary.passwordSet, true);
+  assert.equal(entry.summary.dkimPrivate, undefined);
+  assert.equal(entry.summary.dkimPublic, 'public-key');
+  assert.equal(entry.summary.dkim_private, undefined);
+  assert.equal(entry.summary.dkim_public, 'public-key');
+  assert.equal(entry.summary.authorization, undefined);
+  assert.equal(entry.summary.headers.authorization, undefined);
+  assert.equal(entry.summary.headers.from, 'admin@example.com');
+  assert.equal(entry.summary.nested.apiToken, undefined);
+  assert.equal(entry.summary.nested.note, 'kept');
+  assert.equal(entry.summary.changes[0].credential, undefined);
+  assert.equal(entry.summary.changes[0].field, 'password');
+  assert.equal(entry.summary.changes[1].field, 'password');
+  assert.equal(entry.summary.changes[1].value, undefined);
+  assert.equal(entry.summary.changes[1].label, 'password change');
+  assert.equal(entry.summary.changes[2].name, 'apiToken');
+  assert.equal(entry.summary.changes[2].oldValue, undefined);
+  assert.equal(entry.summary.changes[2].newValue, undefined);
+  assert.equal(entry.summary.changes[3].header, 'authorization');
+  assert.equal(entry.summary.changes[3].value, undefined);
+  assert.equal(entry.summary.changes[3].status, 'set');
+  assert.equal(entry.summary.changes[4].path, 'smtp.password');
+  assert.equal(entry.summary.changes[4].from, undefined);
+  assert.equal(entry.summary.changes[4].to, undefined);
+  assert.equal(entry.summary.changes[5].key, undefined);
+  assert.equal(entry.summary.changes[5].before, undefined);
+  assert.equal(entry.summary.changes[5].after, undefined);
+  assert.equal(entry.summary.changes[6].field, 'displayName');
+  assert.equal(entry.summary.changes[6].value, 'Alice Example');
+  assert.equal(entry.summary.changes[7].field, 'password');
+  assert.equal(entry.summary.changes[7].old, undefined);
+  assert.equal(entry.summary.changes[7].new, undefined);
+  assert.deepEqual(entry.summary.changes[8].change, { field: 'password' });
+  assert.equal(entry.summary.changes[8].value, undefined);
+  assert.deepEqual(entry.summary.changes[9].context, { path: 'smtp.password' });
+  assert.equal(entry.summary.changes[9].from, undefined);
+  assert.equal(entry.summary.changes[9].to, undefined);
+  assert.deepEqual(entry.summary.changes[10].change, { field: 'displayName' });
+  assert.equal(entry.summary.changes[10].value, 'Alice Wrapper');
+});
+
+test('filters audit logs and returns newest entries first', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
+  const alice = createUser({ username: 'alice2', email: 'alice2@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob2', email: 'bob2@example.com', password: 'password123' });
+
+  logAudit({
+    actorUserId: admin.id,
+    action: 'admin.disable_user',
+    targetType: 'user',
+    targetId: String(alice.id),
+    targetUserId: alice.id,
+    summary: { username: alice.username }
+  });
+  logAudit({
+    actorUserId: admin.id,
+    action: 'admin.reset_password',
+    targetType: 'user',
+    targetId: String(alice.id),
+    targetUserId: alice.id,
+    summary: { username: alice.username }
+  });
+  logAudit({
+    actorUserId: null,
+    action: 'system.rotation',
+    targetType: 'system',
+    summary: { reason: 'scheduled' }
+  });
+  logAudit({
+    actorUserId: admin.id,
+    action: 'admin.reset_password',
+    targetType: 'user',
+    targetId: String(bob.id),
+    targetUserId: bob.id,
+    summary: { username: bob.username }
+  });
+
+  assert.deepEqual(
+    listAuditLogs({ action: 'admin.reset_password' }).map((entry) => entry.summary.username),
+    ['bob2', 'alice2']
+  );
+  assert.deepEqual(
+    listAuditLogs({ targetUserId: alice.id }).map((entry) => entry.action),
+    ['admin.reset_password', 'admin.disable_user']
+  );
+  assert.deepEqual(
+    listAuditLogs({ actorUserId: null }).map((entry) => entry.action),
+    ['system.rotation']
+  );
+  const pastIso = '2000-01-01T00:00:00.000Z';
+  const futureIso = '2999-01-01T00:00:00.000Z';
+  assert.deepEqual(listAuditLogs({ from: futureIso }), []);
+  assert.deepEqual(listAuditLogs({ to: pastIso }), []);
+  assert.deepEqual(
+    listAuditLogs({ from: pastIso, to: futureIso }).map((entry) => entry.action),
+    ['admin.reset_password', 'system.rotation', 'admin.reset_password', 'admin.disable_user']
+  );
+});
+
 function domainFixture(domain) {
   return {
     domain,

+ 67 - 0
test/frontend-admin-model.test.js

@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+  adminUserStatusMeta,
+  buildMergeConfirmationText,
+  mergePreviewSummary,
+  serializeAuditFilters,
+  serializeSystemEmailPayload
+} from '../src/pages/Admin/admin-model.js';
+
+test('maps admin user statuses to labels and colors', () => {
+  assert.deepEqual(adminUserStatusMeta('pending_email'), { label: '待验证邮箱', color: 'gold' });
+  assert.deepEqual(adminUserStatusMeta('pending_review'), { label: '待管理员审核', color: 'blue' });
+  assert.deepEqual(adminUserStatusMeta('active'), { label: '正常', color: 'green' });
+  assert.deepEqual(adminUserStatusMeta('disabled'), { label: '已禁用', color: 'red' });
+});
+
+test('summarizes merge preview counts and confirmation text', () => {
+  const preview = {
+    sourceUser: { username: 'admin' },
+    targetUser: { username: 'chendeben' },
+    confirmationText: 'MERGE admin INTO chendeben',
+    selectedCounts: {
+      domains: 3,
+      dnsCredentials: 2,
+      apiTokens: 1,
+      sendEvents: 9,
+      smtpCredential: 0
+    }
+  };
+
+  assert.equal(buildMergeConfirmationText(preview.sourceUser, preview.targetUser), preview.confirmationText);
+  assert.deepEqual(mergePreviewSummary(preview), [
+    { key: 'domains', label: '域名', count: 3 },
+    { key: 'dnsCredentials', label: 'DNS 凭据', count: 2 },
+    { key: 'apiTokens', label: 'API Token', count: 1 },
+    { key: 'sendEvents', label: '发送记录', count: 9 },
+    { key: 'smtpCredential', label: 'SMTP 凭据', count: 0 }
+  ]);
+});
+
+test('serializes system email payload without blank password', () => {
+  assert.deepEqual(serializeSystemEmailPayload({
+    host: 'smtp.example.com',
+    port: '587',
+    secure: true,
+    username: 'mailer',
+    password: '   ',
+    fromEmail: 'notify@example.com'
+  }), {
+    host: 'smtp.example.com',
+    port: 587,
+    secure: true,
+    username: 'mailer',
+    fromEmail: 'notify@example.com'
+  });
+});
+
+test('serializes audit filters to query params', () => {
+  assert.equal(serializeAuditFilters({
+    actorUserId: 1,
+    targetUserId: '',
+    action: 'admin.user_merge',
+    from: '2026-07-08',
+    to: undefined
+  }), 'actorUserId=1&action=admin.user_merge&from=2026-07-08');
+});

+ 41 - 0
test/frontend-auth-model.test.js

@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { authModeFromLocation, nextAuthSuccessState } from '../src/frontend/auth/auth-model.js';
+
+test('registration success returns to login without navigating to the protected app', () => {
+  assert.deepEqual(
+    nextAuthSuccessState('/api/register', {
+      user: { status: 'pending_email' },
+      message: '注册成功,请先验证邮箱,验证后等待管理员审核。'
+    }),
+    {
+      mode: 'login',
+      path: '/login',
+      message: '注册成功,请先验证邮箱,验证后等待管理员审核。',
+      redirectTo: ''
+    }
+  );
+});
+
+test('login success still redirects to the protected app', () => {
+  assert.deepEqual(
+    nextAuthSuccessState('/api/login', {
+      user: { status: 'active' }
+    }),
+    {
+      mode: 'login',
+      path: '/login',
+      message: '',
+      redirectTo: '/'
+    }
+  );
+});
+
+test('detects account recovery modes from auth routes', () => {
+  assert.deepEqual(authModeFromLocation('/forgot-password'), { mode: 'forgot', token: '' });
+  assert.deepEqual(authModeFromLocation('/resend-verification'), { mode: 'resend', token: '' });
+  assert.deepEqual(authModeFromLocation('/reset-password', '?token=abc123'), { mode: 'reset', token: 'abc123' });
+  assert.deepEqual(authModeFromLocation('/login'), { mode: 'login', token: '' });
+  assert.deepEqual(authModeFromLocation('/register'), { mode: 'register', token: '' });
+});

+ 14 - 0
test/frontend-i18n.test.js

@@ -25,6 +25,8 @@ test('translates known UI keys and falls back safely', () => {
   assert.equal(en('common.refresh'), 'Refresh');
   assert.equal(zh('auth.loginTitle'), '登录控制台');
   assert.equal(en('auth.loginTitle'), 'Sign in to console');
+  assert.equal(zh('auth.forgotPassword'), '忘记密码?');
+  assert.equal(en('auth.resetPasswordTitle'), 'Reset password');
   assert.equal(en('missing.translation.key'), 'missing.translation.key');
 });
 
@@ -38,5 +40,17 @@ test('uses localized Chinese labels for the main navigation', () => {
   assert.equal(zh('nav.tokens'), 'API Token');
   assert.equal(zh('nav.logs'), '发送记录');
   assert.equal(zh('nav.webhooks'), 'Webhooks');
+  assert.equal(zh('nav.admin'), '管理员');
   assert.equal(zh('nav.settings'), '系统设置');
 });
+
+test('translates admin panel labels', () => {
+  const zh = createTranslator('zh-CN');
+  const en = createTranslator('en-US');
+
+  assert.equal(zh('admin.title'), '管理员面板');
+  assert.equal(zh('admin.users'), '用户');
+  assert.equal(zh('admin.resources'), '资源');
+  assert.equal(zh('admin.auditLogs'), '审计日志');
+  assert.equal(en('admin.title'), 'Admin Panel');
+});

+ 1334 - 1
test/server-admin-api.test.js

@@ -1,5 +1,5 @@
 import assert from 'node:assert/strict';
-import { spawn } from 'node:child_process';
+import { spawn, spawnSync } from 'node:child_process';
 import { mkdtempSync, readdirSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
@@ -80,6 +80,1339 @@ test('built auth assets are served before authentication', async () => {
   }
 });
 
+test('auth pages preserve query messages instead of redirecting them away', async () => {
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    for (const pathname of ['/login?error=hello', '/reset-password?token=abc123']) {
+      const response = await fetch(`${baseUrl}${pathname}`, { redirect: 'manual' });
+
+      assert.equal(response.status, 200);
+      assert.equal(response.headers.get('location'), null);
+      assert.match(await response.text(), /auth-root/);
+    }
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can list audit logs', async () => {
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
+      headers: { Cookie: cookie }
+    });
+
+    assert.equal(response.status, 200);
+    assert.deepEqual(await response.json(), { logs: [] });
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can list resource inventory', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'alice',
+      email: 'alice@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const userCookie = await login(baseUrl, 'alice', 'password123');
+
+    const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
+      headers: { Cookie: userCookie }
+    });
+    assert.equal(forbidden.status, 403);
+
+    const response = await fetch(`${baseUrl}/api/admin/resources`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(response.status, 200);
+    const body = await response.json();
+    assert.ok(Array.isArray(body.inventory.users));
+    assert.ok(Array.isArray(body.inventory.warnings));
+    assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can transfer individual resources', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const seeded = seedTransferResources(dataDir, sessionSecret);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const aliceCookie = await login(baseUrl, 'alice', 'password123');
+
+    const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: aliceCookie
+      },
+      body: JSON.stringify({ targetUserId: seeded.bobId })
+    });
+    assert.equal(forbidden.status, 403);
+
+    const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        targetUserId: seeded.bobId,
+        dnsCredentialMode: 'with_dns_credential'
+      })
+    });
+    assert.equal(domain.status, 200);
+    const domainBody = await domain.json();
+    assert.equal(domainBody.domain.userId, seeded.bobId);
+    assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
+
+    const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({ targetUserId: seeded.bobId })
+    });
+    assert.equal(dns.status, 200);
+    assert.equal((await dns.json()).credential.userId, seeded.bobId);
+
+    const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        targetUserId: seeded.bobId,
+        tokenIds: [seeded.apiTokenId]
+      })
+    });
+    assert.equal(tokens.status, 200);
+    const tokensBody = await tokens.json();
+    assert.equal(tokensBody.tokens.length, 1);
+    assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
+
+    const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
+      headers: { Cookie: adminCookie }
+    });
+    const actions = (await audit.json()).logs.map((entry) => entry.action);
+    assert.ok(actions.includes('admin.transfer_domain'));
+    assert.ok(actions.includes('admin.transfer_dns_credential'));
+    assert.ok(actions.includes('admin.transfer_api_tokens'));
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can preview and execute user merge', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const seeded = seedMergeResources(dataDir, sessionSecret);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+
+    const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        sourceUserId: seeded.sourceId,
+        targetUserId: seeded.targetId
+      })
+    });
+    assert.equal(previewResponse.status, 200);
+    const preview = (await previewResponse.json()).preview;
+    assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
+    assert.equal(preview.counts.domains, 1);
+
+    const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        sourceUserId: seeded.sourceId,
+        targetUserId: seeded.targetId,
+        confirmation: 'wrong'
+      })
+    });
+    assert.equal(invalid.status, 400);
+
+    const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        sourceUserId: seeded.sourceId,
+        targetUserId: seeded.targetId,
+        confirmation: preview.confirmationText
+      })
+    });
+    assert.equal(execute.status, 200);
+    const result = (await execute.json()).result;
+    assert.equal(result.counts.domains, 1);
+    assert.equal(result.sourceUser.status, 'disabled');
+
+    const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(audit.status, 200);
+    assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can manage system email settings without exposing password', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'alice',
+      email: 'alice@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const userCookie = await login(baseUrl, 'alice', 'password123');
+
+    const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
+      headers: { Cookie: userCookie }
+    });
+    assert.equal(forbidden.status, 403);
+
+    const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(empty.status, 200);
+    assert.equal((await empty.json()).settings.passwordSet, false);
+
+    const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        host: 'smtp.example.com',
+        port: 587,
+        secure: false,
+        username: 'mailer@example.com',
+        password: 'smtp-password-123',
+        helo: 'mail.example.com',
+        fromEmail: 'notify@example.com',
+        fromName: 'MailHub Notify',
+        testRecipient: 'admin@example.com'
+      })
+    });
+    assert.equal(saved.status, 200);
+    const savedBody = await saved.json();
+    assert.equal(savedBody.settings.host, 'smtp.example.com');
+    assert.equal(savedBody.settings.port, 587);
+    assert.equal(savedBody.settings.secure, false);
+    assert.equal(savedBody.settings.passwordSet, true);
+    assert.equal('password' in savedBody.settings, false);
+    assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
+
+    const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({
+        host: 'smtp2.example.com',
+        password: ''
+      })
+    });
+    assert.equal(preserved.status, 200);
+    const preservedBody = await preserved.json();
+    assert.equal(preservedBody.settings.host, 'smtp2.example.com');
+    assert.equal(preservedBody.settings.passwordSet, true);
+    assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
+
+    const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(audit.status, 200);
+    const [entry] = (await audit.json()).logs;
+    assert.equal(entry.action, 'admin.update_system_email');
+    assert.equal(entry.targetType, 'system_email');
+    assert.equal(entry.summary.host, 'smtp2.example.com');
+    assert.equal(entry.summary.password, undefined);
+    assert.equal(entry.summary.passwordSet, true);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('registration and verification resend use configured system email', async () => {
+  const smtp = await startFakeSmtpServer();
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
+      { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
+    ]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
+
+    const register = await fetch(`${baseUrl}/api/register`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        username: 'mailuser',
+        email: 'mailuser@example.com',
+        password: 'password123'
+      })
+    });
+    assert.equal(register.status, 201);
+    const registerBody = await register.json();
+    assert.equal(registerBody.user.status, 'pending_email');
+    assert.equal(registerBody.verificationEmailSent, true);
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
+
+    const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email: 'publicpending@example.com' })
+    });
+    assert.equal(publicResend.status, 202);
+    const publicResendBody = await publicResend.json();
+    assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
+    assert.equal('verificationEmailSent' in publicResendBody, false);
+    assert.equal('result' in publicResendBody, false);
+    await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
+
+    const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
+      headers: { Cookie: adminCookie }
+    });
+    const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
+    assert.ok(adminPending);
+    const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(adminResend.status, 202);
+    assert.equal((await adminResend.json()).verificationEmailSent, true);
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
+
+    assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
+    assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
+    assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
+    assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
+    assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await smtp.close();
+  }
+});
+
+test('public verification resend is generic and does not create tokens without mail config', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'pendingnomail',
+      email: 'pendingnomail@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    }]);
+
+    const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email: 'pendingnomail@example.com' })
+    });
+
+    assert.equal(response.status, 202);
+    assert.deepEqual(await response.json(), {
+      message: '如果账号需要验证,我们会发送验证邮件。'
+    });
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('registration reports pending email when system email is not configured', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const register = await fetch(`${baseUrl}/api/register`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        username: 'nomailuser',
+        email: 'nomailuser@example.com',
+        password: 'password123'
+      })
+    });
+
+    assert.equal(register.status, 201);
+    const body = await register.json();
+    assert.equal(body.user.status, 'pending_email');
+    assert.equal(body.verificationEmailSent, false);
+    assert.match(body.message, /验证邮件暂未发送/);
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can send system email test messages', async () => {
+  const smtp = await startFakeSmtpServer();
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
+
+    const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({ to: 'operator@example.com' })
+    });
+
+    assert.equal(response.status, 202);
+    const body = await response.json();
+    assert.equal(body.result.ok, true);
+    assert.equal(body.result.queueId, 'SYS123');
+    assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
+    assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
+
+    const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(audit.status, 200);
+    const [entry] = (await audit.json()).logs;
+    assert.equal(entry.targetType, 'system_email');
+    assert.equal(entry.summary.to, 'operator@example.com');
+    assert.equal(entry.summary.ok, true);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await smtp.close();
+  }
+});
+
+test('public forgot password is generic and sends reset email when configured', async () => {
+  const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'resetme',
+      email: 'resetme@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
+
+    const startedAt = Date.now();
+    const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email: 'resetme@example.com' })
+    });
+    const elapsedMs = Date.now() - startedAt;
+    assert.equal(existing.status, 202);
+    assert.equal(elapsedMs < 500, true);
+    assert.deepEqual(await existing.json(), {
+      message: '如果邮箱存在,我们会发送密码重置邮件。'
+    });
+    await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
+
+    const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email: 'missing@example.com' })
+    });
+    assert.equal(missing.status, 202);
+    assert.deepEqual(await missing.json(), {
+      message: '如果邮箱存在,我们会发送密码重置邮件。'
+    });
+
+    await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await smtp.close();
+  }
+});
+
+test('public reset password consumes token and updates password', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'tokenreset',
+      email: 'tokenreset@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
+
+    const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        token,
+        password: 'new-password-123'
+      })
+    });
+    assert.equal(response.status, 200);
+    assert.deepEqual(await response.json(), {
+      message: '密码已重置,请使用新密码登录。'
+    });
+
+    const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
+    assert.equal(oldLogin.status, 401);
+    const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
+    assert.equal(newLogin.status, 200);
+
+    const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        token,
+        password: 'another-password-123'
+      })
+    });
+    assert.equal(reused.status, 400);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can trigger password reset email and set temporary password', async () => {
+  const smtp = await startFakeSmtpServer();
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
+      { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
+    ]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const memberCookie = await login(baseUrl, 'member2', 'password123');
+    await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
+
+    const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
+      headers: { Cookie: adminCookie }
+    });
+    const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
+    assert.ok(target);
+
+    const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
+      method: 'POST',
+      headers: { Cookie: memberCookie }
+    });
+    assert.equal(forbiddenReset.status, 403);
+
+    const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(reset.status, 202);
+    assert.equal((await reset.json()).result.ok, true);
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
+    assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
+
+    const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: memberCookie
+      },
+      body: JSON.stringify({ password: 'temporary-123' })
+    });
+    assert.equal(forbiddenTemporary.status, 403);
+
+    const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({ password: 'temporary-123' })
+    });
+    assert.equal(temporary.status, 200);
+    assert.equal((await temporary.json()).user.id, target.id);
+    assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
+
+    const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
+    assert.equal(oldLogin.status, 401);
+    const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
+    assert.equal(tempLogin.status, 200);
+
+    const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(audit.status, 200);
+    const logs = (await audit.json()).logs;
+    assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
+    const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
+    assert.ok(temporaryLog);
+    assert.equal(temporaryLog.summary.username, 'targetuser');
+    assert.equal(temporaryLog.summary.password, undefined);
+    assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await smtp.close();
+  }
+});
+
+test('non-admin users cannot list audit logs', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'alice',
+      email: 'alice@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const cookie = await login(baseUrl, 'alice', 'password123');
+
+    const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
+      headers: { Cookie: cookie }
+    });
+
+    assert.equal(response.status, 403);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin user patch rejects invalid status with a bad request', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [{
+      username: 'badstatus',
+      email: 'badstatus@example.com',
+      password: 'password123',
+      status: 'active'
+    }]);
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
+      headers: { Cookie: cookie }
+    });
+    assert.equal(usersResponse.status, 200);
+    const usersBody = await usersResponse.json();
+    const target = usersBody.users.find((user) => user.username === 'badstatus');
+    assert.ok(target);
+
+    const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ status: 'archived' })
+    });
+
+    assert.equal(response.status, 400);
+    assert.equal((await response.json()).error, '用户状态不正确。');
+
+    const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ password: 'short' })
+    });
+    assert.equal(shortPassword.status, 400);
+    assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('self registration creates a pending email user and verification token without a session', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const register = await fetch(`${baseUrl}/api/register`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        username: 'newuser',
+        email: 'newuser@example.com',
+        password: 'password123'
+      })
+    });
+
+    assert.equal(register.status, 201);
+    assert.equal(sessionCookieFrom(register), '');
+    const text = await register.text();
+    assert.doesNotMatch(text, /token/i);
+    const body = JSON.parse(text);
+    assert.equal('token' in body, false);
+    assert.equal('token' in body.user, false);
+    assert.equal('tokenHash' in body.user, false);
+    assert.equal(body.user.status, 'pending_email');
+    assert.match(body.message, /验证邮箱/);
+    assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
+
+    const login = await loginResponse(baseUrl, 'newuser', 'password123');
+    assert.equal(login.status, 403);
+    assert.equal(sessionCookieFrom(login), '');
+    assert.equal((await login.json()).error, '请先验证邮箱。');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('email verification route consumes token and moves user to admin review', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'verifyme',
+      email: 'verifyme@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    });
+
+    const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
+    assert.equal(missing.status, 400);
+    assert.equal(sessionCookieFrom(missing), '');
+
+    const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
+    assert.equal(invalid.status, 400);
+    assert.equal(sessionCookieFrom(invalid), '');
+
+    const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
+    assert.equal(response.status, 200);
+    assert.equal(sessionCookieFrom(response), '');
+    const body = await response.json();
+    assert.equal(body.user.id, created.user.id);
+    assert.equal(body.user.status, 'pending_review');
+    assert.match(body.message, /管理员审核/);
+
+    const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
+    assert.equal(reused.status, 400);
+    assert.equal(sessionCookieFrom(reused), '');
+
+    const login = await loginResponse(baseUrl, 'verifyme', 'password123');
+    assert.equal(login.status, 403);
+    assert.equal(sessionCookieFrom(login), '');
+    assert.equal((await login.json()).error, '账号正在等待管理员审核。');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin users can approve pending review users with an audit log', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
+      { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
+      { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
+      { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
+    ]);
+
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(usersResponse.status, 200);
+    const users = (await usersResponse.json()).users;
+    const target = users.find((user) => user.username === 'reviewme');
+    const pendingEmail = users.find((user) => user.username === 'emailonly');
+    const disabled = users.find((user) => user.username === 'disabledreview');
+    assert.ok(target);
+    assert.ok(pendingEmail);
+    assert.ok(disabled);
+
+    const memberCookie = await login(baseUrl, 'member', 'password123');
+    const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
+      method: 'POST',
+      headers: { Cookie: memberCookie }
+    });
+    assert.equal(nonAdmin.status, 403);
+
+    const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(missing.status, 404);
+
+    const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(pendingEmailResponse.status, 400);
+    assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
+
+    const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(disabledResponse.status, 400);
+    assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
+
+    const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
+      method: 'POST',
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(response.status, 200);
+    const body = await response.json();
+    assert.equal(body.user.id, target.id);
+    assert.equal(body.user.status, 'active');
+
+    const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
+    assert.ok(approvedCookie);
+
+    const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(auditResponse.status, 200);
+    const [entry] = (await auditResponse.json()).logs;
+    assert.equal(entry.action, 'admin.approve_user');
+    assert.equal(entry.targetType, 'user');
+    assert.equal(entry.targetId, String(target.id));
+    assert.equal(entry.targetUserId, target.id);
+    assert.equal(entry.summary.username, 'reviewme');
+    assert.equal(entry.summary.status, 'active');
+    assert.equal(entry.summary.password, undefined);
+    assert.equal(entry.summary.token, undefined);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('login returns account status restrictions only after password verification', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
+      { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
+      { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
+      { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
+    ]);
+
+    await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
+    await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
+    await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
+
+    const active = await loginResponse(baseUrl, 'activeuser', 'password123');
+    assert.equal(active.status, 200);
+    assert.ok(sessionCookieFrom(active));
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin audit log actor filter rejects non-decimal user ids', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedAuditLogs(dataDir, sessionSecret);
+    const cookie = await login(baseUrl, 'admin', 'password123');
+
+    assert.deepEqual(
+      await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
+      ['audit.actor-one']
+    );
+    assert.deepEqual(
+      await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
+      ['audit.actor-one-hundred', 'audit.actor-one']
+    );
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin audit log date filter ignores invalid dates', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedAuditLogs(dataDir, sessionSecret);
+    const cookie = await login(baseUrl, 'admin', 'password123');
+
+    assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
+    assert.deepEqual(
+      await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
+      ['audit.actor-one-hundred', 'audit.actor-one']
+    );
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+async function startTestServer() {
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
+  const sessionSecret = 'test-session-secret';
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      ADMIN_PASSWORD: 'password123',
+      SESSION_SECRET: sessionSecret,
+      SUBMISSION_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+  await waitForOutput(child, 'MailHub listening');
+  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
+}
+
+async function login(baseUrl, username, password) {
+  const response = await loginResponse(baseUrl, username, password);
+  assert.equal(response.status, 200);
+  const cookie = sessionCookieFrom(response);
+  assert.ok(cookie);
+  return cookie;
+}
+
+function loginResponse(baseUrl, username, password) {
+  return fetch(`${baseUrl}/api/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password })
+  });
+}
+
+async function assertLoginDeniedByStatus(baseUrl, username, message) {
+  const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
+  assert.equal(wrongPassword.status, 401);
+  assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
+  assert.equal(sessionCookieFrom(wrongPassword), '');
+
+  const correctPassword = await loginResponse(baseUrl, username, 'password123');
+  assert.equal(correctPassword.status, 403);
+  assert.equal((await correctPassword.json()).error, message);
+  assert.equal(sessionCookieFrom(correctPassword), '');
+}
+
+function sessionCookieFrom(response) {
+  return response.headers.get('set-cookie')?.split(';')[0] || '';
+}
+
+function seedUsers(dataDir, sessionSecret, users) {
+  const script = `
+    import { initDatabase, createUser } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    for (const user of JSON.parse(process.env.SEED_USERS)) {
+      createUser(user);
+    }
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      SEED_USERS: JSON.stringify(users)
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+}
+
+function seedTransferResources(dataDir, sessionSecret) {
+  const script = `
+    import {
+      initDatabase,
+      createApiToken,
+      createDomain,
+      createUser,
+      saveDnsCredential
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
+    const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
+    const credential = saveDnsCredential(alice.id, {
+      name: 'Alice DNS',
+      provider: 'cloudflare',
+      zoneName: 'alice.example',
+      credentials: { apiToken: 'secret-token' }
+    });
+    const standaloneCredential = saveDnsCredential(alice.id, {
+      name: 'Standalone DNS',
+      provider: 'cloudflare',
+      zoneName: 'standalone.example',
+      credentials: { apiToken: 'standalone-secret-token' }
+    });
+    const domain = createDomain(alice.id, {
+      dnsCredentialId: credential.id,
+      domain: 'alice.example',
+      selector: 'mh202607',
+      verificationToken: 'token',
+      dkimPublic: 'public',
+      dkimPrivate: 'private',
+      senderHost: 'mail.alice.example',
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const apiToken = createApiToken(alice.id, 'primary');
+    console.log(JSON.stringify({
+      aliceId: alice.id,
+      bobId: bob.id,
+      domainId: domain.id,
+      credentialId: credential.id,
+      standaloneCredentialId: standaloneCredential.id,
+      apiTokenId: apiToken.id
+    }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+function seedMergeResources(dataDir, sessionSecret) {
+  const script = `
+    import {
+      initDatabase,
+      createApiToken,
+      createDomain,
+      createUser,
+      logSendEvent,
+      saveDnsCredential
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
+    const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
+    const credential = saveDnsCredential(source.id, {
+      name: 'Merge DNS',
+      provider: 'cloudflare',
+      zoneName: 'merge.example',
+      credentials: { apiToken: 'merge-secret-token' }
+    });
+    const domain = createDomain(source.id, {
+      dnsCredentialId: credential.id,
+      domain: 'merge.example',
+      selector: 'mh202607',
+      verificationToken: 'token',
+      dkimPublic: 'public',
+      dkimPrivate: 'private',
+      senderHost: 'mail.merge.example',
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    createApiToken(source.id, 'primary');
+    logSendEvent({
+      userId: source.id,
+      domainId: domain.id,
+      sender: 'noreply@merge.example',
+      recipients: ['a@example.com'],
+      subject: 'Queued',
+      status: 'queued'
+    });
+    console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
+  const script = `
+    import { initDatabase, createUser, createAccountToken } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const user = createUser(JSON.parse(process.env.SEED_USER));
+    const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
+    console.log(JSON.stringify({ user, token: token.token }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      SEED_USER: JSON.stringify(user)
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+function createPasswordResetToken(dataDir, sessionSecret, username) {
+  const script = `
+    import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const user = getUserByLogin(process.env.TOKEN_USERNAME);
+    const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
+    console.log(token.token);
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      TOKEN_USERNAME: username
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return result.stdout.trim();
+}
+
+function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
+  return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
+}
+
+function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
+  return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
+}
+
+function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
+  const script = `
+    import path from 'node:path';
+    import { DatabaseSync } from 'node:sqlite';
+    import { initDatabase, getUserByLogin } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const user = getUserByLogin(process.env.TOKEN_USERNAME);
+    const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
+    database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
+    const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
+    const row = user
+      ? database
+          .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
+          .get(user.id, process.env.TOKEN_PURPOSE)
+      : { count: 0 };
+    console.log(String(row.count));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      TOKEN_USERNAME: username,
+      TOKEN_PURPOSE: purpose,
+      TOKEN_UNUSED_ONLY: String(unusedOnly)
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return Number(result.stdout.trim());
+}
+
+function seedAuditLogs(dataDir, sessionSecret) {
+  const script = `
+    import path from 'node:path';
+    import { DatabaseSync } from 'node:sqlite';
+    import { initDatabase, logAudit } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const actorOneId = logAudit({
+      actorUserId: 1,
+      action: 'audit.actor-one',
+      targetType: 'system',
+      summary: { label: 'actor-one' }
+    });
+    const actorOneHundredId = logAudit({
+      actorUserId: 100,
+      action: 'audit.actor-one-hundred',
+      targetType: 'system',
+      summary: { label: 'actor-one-hundred' }
+    });
+    const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
+    db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
+    const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
+    update.run('2026-02-01T00:00:00.000Z', actorOneId);
+    update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+}
+
+async function auditLogActions(baseUrl, cookie, query) {
+  const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
+    headers: { Cookie: cookie }
+  });
+  assert.equal(response.status, 200);
+  const body = await response.json();
+  return body.logs.map((log) => log.action);
+}
+
+async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
+  const response = await fetch(`${baseUrl}/api/admin/system-email`, {
+    method: 'PATCH',
+    headers: {
+      'Content-Type': 'application/json',
+      Cookie: cookie
+    },
+    body: JSON.stringify({
+      host: '127.0.0.1',
+      port: smtpPort,
+      secure: false,
+      username: 'mailer@example.com',
+      password: 'smtp-password-123',
+      helo: 'mail.example.com',
+      fromEmail: 'notify@example.com',
+      fromName: 'MailHub Notify',
+      testRecipient: 'admin@example.com'
+    })
+  });
+  assert.equal(response.status, 200);
+}
+
+function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
+  const commands = [];
+  const messages = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
+    let buffer = '';
+    let dataMode = false;
+    let messageLines = [];
+
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            messages.push(messageLines.join('\n'));
+            messageLines = [];
+            writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
+          } else {
+            messageLines.push(line);
+          }
+          continue;
+        }
+
+        commands.push(line);
+        if (line.startsWith('EHLO')) {
+          writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
+        } else if (line.startsWith('AUTH PLAIN')) {
+          writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
+        } else if (line.startsWith('MAIL FROM')) {
+          writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
+        } else if (line.startsWith('RCPT TO')) {
+          writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
+        } else if (line === 'DATA') {
+          dataMode = true;
+          writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
+        } else if (line === 'QUIT') {
+          writeSmtpResponse(socket, '221 bye', responseDelayMs);
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      server.off('error', reject);
+      resolve({
+        port: server.address().port,
+        commands,
+        messages,
+        close: () => new Promise((closeResolve) => server.close(closeResolve))
+      });
+    });
+  });
+}
+
+function writeSmtpResponse(socket, response, delayMs) {
+  const write = () => socket.write(`${response}\r\n`);
+  if (delayMs > 0) setTimeout(write, delayMs);
+  else write();
+}
+
+async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
+  const startedAt = Date.now();
+  while (Date.now() - startedAt < timeoutMs) {
+    if (predicate()) return;
+    await new Promise((resolve) => setTimeout(resolve, intervalMs));
+  }
+  assert.fail('Timed out waiting for condition.');
+}
+
 function freePort() {
   return new Promise((resolve, reject) => {
     const server = net.createServer();

+ 118 - 0
test/system-mail.test.js

@@ -0,0 +1,118 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+  buildPasswordResetEmail,
+  buildVerificationEmail,
+  sendSystemEmail
+} from '../src/system-mail.js';
+
+test('builds verification email with configured sender and verification url', () => {
+  const message = buildVerificationEmail({
+    appBaseUrl: 'https://mail.example.com/',
+    to: 'alice@example.com',
+    token: 'verify-token',
+    fromEmail: 'notify@example.com',
+    fromName: 'MailHub Notify'
+  });
+
+  assert.equal(message.from, '"MailHub Notify" <notify@example.com>');
+  assert.equal(message.to, 'alice@example.com');
+  assert.match(message.subject, /验证邮箱/);
+  assert.match(message.text, /https:\/\/mail\.example\.com\/api\/auth\/verify-email\?token=verify-token/);
+});
+
+test('builds password reset email with reset url', () => {
+  const message = buildPasswordResetEmail({
+    appBaseUrl: 'https://mail.example.com',
+    to: 'alice@example.com',
+    token: 'reset-token',
+    fromEmail: 'notify@example.com',
+    fromName: ''
+  });
+
+  assert.equal(message.from, 'notify@example.com');
+  assert.match(message.subject, /重置密码/);
+  assert.match(message.text, /https:\/\/mail\.example\.com\/reset-password\?token=reset-token/);
+});
+
+test('sends system email through smtp without returning secrets', async () => {
+  let sentPayload;
+  const result = await sendSystemEmail({
+    host: 'smtp.example.com',
+    port: 465,
+    secure: true,
+    username: 'mailer@example.com',
+    password: 'smtp-password-123',
+    helo: 'mail.example.com',
+    fromEmail: 'notify@example.com',
+    fromName: 'MailHub Notify'
+  }, buildVerificationEmail({
+    appBaseUrl: 'https://mail.example.com',
+    to: 'alice@example.com',
+    token: 'verify-token',
+    fromEmail: 'notify@example.com',
+    fromName: 'MailHub Notify'
+  }), {
+    sendViaSmtp: async (payload) => {
+      sentPayload = payload;
+      return {
+        code: 250,
+        message: '2.0.0 queued as ABC123',
+        queueId: 'ABC123',
+        deliveryLog: [{
+          phase: 'auth',
+          direction: 'client',
+          command: 'AUTH PLAIN <redacted>'
+        }]
+      };
+    }
+  });
+
+  assert.equal(sentPayload.host, 'smtp.example.com');
+  assert.equal(sentPayload.port, 465);
+  assert.equal(sentPayload.secure, true);
+  assert.equal(sentPayload.username, 'mailer@example.com');
+  assert.equal(sentPayload.password, 'smtp-password-123');
+  assert.equal(sentPayload.helo, 'mail.example.com');
+  assert.equal(sentPayload.mailFrom, 'notify@example.com');
+  assert.deepEqual(sentPayload.recipients, ['alice@example.com']);
+  assert.match(sentPayload.rawMessage, /^From: "MailHub Notify" <notify@example.com>/);
+
+  assert.deepEqual(result, {
+    ok: true,
+    code: 250,
+    message: '2.0.0 queued as ABC123',
+    queueId: 'ABC123'
+  });
+  assert.equal(JSON.stringify(result).includes('smtp-password-123'), false);
+  assert.equal(JSON.stringify(result).includes('verify-token'), false);
+});
+
+test('normalizes array recipients before building smtp payload', async () => {
+  let sentPayload;
+  await sendSystemEmail({
+    host: 'smtp.example.com',
+    port: 25,
+    secure: false,
+    username: '',
+    password: '',
+    helo: 'mail.example.com',
+    fromEmail: 'notify@example.com',
+    fromName: 'MailHub Notify'
+  }, {
+    from: '"MailHub Notify" <notify@example.com>',
+    to: ['Alice <alice@example.com>', 'bad\r\nRCPT TO:<evil@example.com>'],
+    subject: '安全测试',
+    text: 'Hello'
+  }, {
+    sendViaSmtp: async (payload) => {
+      sentPayload = payload;
+      return { code: 250, message: 'queued', queueId: 'SAFE' };
+    }
+  });
+
+  assert.deepEqual(sentPayload.recipients, ['alice@example.com']);
+  assert.match(sentPayload.rawMessage, /^To: alice@example.com$/m);
+  assert.doesNotMatch(sentPayload.rawMessage, /^Bcc:/m);
+  assert.doesNotMatch(sentPayload.rawMessage, /RCPT TO/i);
+});

Some files were not shown because too many files changed in this diff