فهرست منبع

feat: add multi-user backend isolation

Codex 1 ماه پیش
والد
کامیت
a2ecd8d130
4فایلهای تغییر یافته به همراه1297 افزوده شده و 356 حذف شده
  1. 548 146
      src/db.js
  2. 373 0
      src/dns-providers.js
  3. 365 208
      src/server.js
  4. 11 2
      src/submission.js

+ 548 - 146
src/db.js

@@ -4,19 +4,30 @@ import path from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 
 let db;
-let credentialSecret = '';
+let secretKey = '';
 
 export function initDatabase(dataDir, secret = '') {
-  credentialSecret = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
+  secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
   mkdirSync(dataDir, { recursive: true });
-  const dbPath = path.join(dataDir, 'mailhub.sqlite');
-  db = new DatabaseSync(dbPath);
+  db = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
+  db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
+  migrateLegacySmtpTable();
   db.exec(`
-    PRAGMA journal_mode = WAL;
-    PRAGMA foreign_keys = ON;
+    CREATE TABLE IF NOT EXISTS users (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      username TEXT NOT NULL UNIQUE,
+      email TEXT NOT NULL UNIQUE,
+      password_hash TEXT NOT NULL,
+      role TEXT NOT NULL DEFAULT 'user',
+      status TEXT NOT NULL DEFAULT 'active',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
 
     CREATE TABLE IF NOT EXISTS domains (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER,
+      dns_credential_id INTEGER,
       domain TEXT NOT NULL UNIQUE,
       selector TEXT NOT NULL,
       verification_token TEXT NOT NULL,
@@ -34,6 +45,7 @@ export function initDatabase(dataDir, secret = '') {
 
     CREATE TABLE IF NOT EXISTS send_events (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER,
       domain_id INTEGER,
       sender TEXT NOT NULL,
       recipients TEXT NOT NULL,
@@ -45,119 +57,199 @@ export function initDatabase(dataDir, secret = '') {
     );
 
     CREATE TABLE IF NOT EXISTS smtp_credentials (
-      id INTEGER PRIMARY KEY CHECK (id = 1),
-      username TEXT NOT NULL,
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL UNIQUE,
+      username TEXT NOT NULL UNIQUE,
       password_hash TEXT NOT NULL,
       password_secret TEXT NOT NULL DEFAULT '',
       created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+    );
+
+    CREATE TABLE IF NOT EXISTS api_tokens (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      name TEXT NOT NULL,
+      token_hash TEXT NOT NULL UNIQUE,
+      token_prefix TEXT NOT NULL,
+      last_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,
+      name TEXT NOT NULL,
+      provider TEXT NOT NULL,
+      zone_name TEXT NOT NULL DEFAULT '',
+      default_ttl INTEGER NOT NULL DEFAULT 600,
+      credentials_secret TEXT NOT NULL,
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+    );
+
+    CREATE TABLE IF NOT EXISTS app_settings (
+      key TEXT PRIMARY KEY,
+      value TEXT NOT NULL,
       updated_at TEXT NOT NULL
     );
+
+    CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
+    CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
+    CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
+    CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
   `);
+  ensureColumn('domains', 'user_id', 'INTEGER');
+  ensureColumn('domains', 'dns_credential_id', 'INTEGER');
+  ensureColumn('send_events', 'user_id', 'INTEGER');
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
   return db;
 }
 
-export function seedSmtpCredential(username, password) {
-  if (!username || !password) return null;
-  const existing = getSmtpCredential({ includeHash: true });
-  if (existing) return existing;
+export function seedAdminUser({ username, password, email }) {
+  const normalizedUsername = normalizeUsername(username || 'admin');
+  const normalizedEmail = normalizeEmail(email || `${normalizedUsername}@mailhub.local`);
+  const existing = getUserByLogin(normalizedUsername) || getUserByLogin(normalizedEmail);
+  if (existing) {
+    requireDb()
+      .prepare('UPDATE users SET role = ?, status = ?, updated_at = ? WHERE id = ?')
+      .run('admin', 'active', now(), existing.id);
+    return getUser(existing.id);
+  }
+  return createUser({
+    username: normalizedUsername,
+    email: normalizedEmail,
+    password,
+    role: 'admin'
+  });
+}
+
+export function claimLegacyData(userId) {
+  requireDb().prepare('UPDATE domains SET user_id = ? WHERE user_id IS NULL').run(userId);
+  requireDb().prepare(`
+    UPDATE send_events
+    SET user_id = COALESCE((SELECT user_id FROM domains WHERE domains.id = send_events.domain_id), ?)
+    WHERE user_id IS NULL
+  `).run(userId);
+  if (!tableExists('smtp_credentials_legacy') || getSmtpCredential(userId)) return;
+  const legacy = requireDb().prepare('SELECT * FROM smtp_credentials_legacy WHERE id = 1').get();
+  if (!legacy?.username || !legacy?.password_hash) return;
   const createdAt = now();
   requireDb()
     .prepare(`
-      INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
-      VALUES (1, ?, ?, ?, ?, ?)
+      INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?)
     `)
-    .run(username, hashPassword(password), encryptPassword(password), createdAt, createdAt);
-  return getSmtpCredential();
+    .run(userId, legacy.username, legacy.password_hash, legacy.password_secret || '', createdAt, createdAt);
 }
 
-function requireDb() {
-  if (!db) throw new Error('Database is not initialized.');
-  return db;
+export function seedSmtpCredential(userId, username, password) {
+  if (!userId || !username || !password || getSmtpCredential(userId)) return null;
+  return saveSmtpCredential(userId, { username, password });
 }
 
-function now() {
-  return new Date().toISOString();
+export function createUser({ username, email, password, role = 'user' }) {
+  const cleanUsername = normalizeUsername(username);
+  const cleanEmail = normalizeEmail(email);
+  if (!cleanUsername) throw new Error('用户名格式不正确。');
+  if (!cleanEmail) throw new Error('邮箱格式不正确。');
+  if (String(password || '').length < 8) throw new Error('密码至少需要 8 位。');
+  const createdAt = now();
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO users (username, email, password_hash, role, status, created_at, updated_at)
+      VALUES (?, ?, ?, ?, 'active', ?, ?)
+    `)
+    .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', createdAt, createdAt);
+  return getUser(result.lastInsertRowid);
 }
 
-function ensureColumn(table, column, definition) {
-  const exists = requireDb()
-    .prepare(`PRAGMA table_info(${table})`)
+export function authenticateUser(login, password) {
+  const user = getUserByLogin(login, { includeHash: true });
+  if (!user || user.status !== 'active' || !verifyPassword(password, user.passwordHash)) return null;
+  return publicUser(user);
+}
+
+export function listUsers() {
+  return requireDb()
+    .prepare('SELECT * FROM users ORDER BY created_at DESC')
     .all()
-    .some((row) => row.name === column);
-  if (!exists) {
-    requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
-  }
+    .map(publicUser);
 }
 
-function publicDomainRow(row) {
-  if (!row) return null;
-  return {
-    id: row.id,
-    domain: row.domain,
-    selector: row.selector,
-    verificationToken: row.verification_token,
-    dkimPublic: row.dkim_public,
-    senderHost: row.sender_host,
-    sendingIp: row.sending_ip,
-    spfExtra: row.spf_extra,
-    dmarcPolicy: row.dmarc_policy,
-    dmarcRua: row.dmarc_rua,
-    status: safeJson(row.status_json, {}),
-    createdAt: row.created_at,
-    updatedAt: row.updated_at
-  };
+export function getUser(id, { includeHash = false } = {}) {
+  const row = requireDb().prepare('SELECT * FROM users WHERE id = ?').get(id);
+  return includeHash ? privateUser(row) : publicUser(row);
 }
 
-function privateDomainRow(row) {
-  const publicRow = publicDomainRow(row);
-  if (!publicRow) return null;
-  return {
-    ...publicRow,
-    dkimPrivate: row.dkim_private
+export function getUserByLogin(login, { includeHash = false } = {}) {
+  const value = String(login || '').trim().toLowerCase();
+  if (!value) return null;
+  const row = requireDb()
+    .prepare('SELECT * FROM users WHERE lower(username) = ? OR lower(email) = ?')
+    .get(value, value);
+  return includeHash ? privateUser(row) : publicUser(row);
+}
+
+export function updateUser(id, patch) {
+  const current = getUser(id, { includeHash: true });
+  if (!current) return null;
+  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,
+    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);
+  return getUser(id);
 }
 
-function safeJson(value, fallback) {
-  try {
-    return JSON.parse(value);
-  } catch {
-    return fallback;
-  }
+export function getAdminUser() {
+  const row = requireDb()
+    .prepare("SELECT * FROM users WHERE role = 'admin' AND status = 'active' ORDER BY id LIMIT 1")
+    .get();
+  return publicUser(row);
 }
 
-export function listDomains() {
-  const rows = requireDb()
-    .prepare('SELECT * FROM domains ORDER BY created_at DESC')
-    .all();
-  return rows.map(publicDomainRow);
+export function listDomains(userId) {
+  return requireDb()
+    .prepare('SELECT * FROM domains WHERE user_id = ? ORDER BY created_at DESC')
+    .all(userId)
+    .map(publicDomainRow);
 }
 
-export function getDomain(id, { includePrivate = false } = {}) {
+export function getDomain(id, { userId, includePrivate = false } = {}) {
   const row = requireDb()
-    .prepare('SELECT * FROM domains WHERE id = ?')
-    .get(id);
+    .prepare('SELECT * FROM domains WHERE id = ? AND (? IS NULL OR user_id = ?)')
+    .get(id, userId ?? null, userId ?? null);
   return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
 }
 
-export function getDomainByName(domain, { includePrivate = false } = {}) {
+export function getDomainByName(domain, { userId, includePrivate = false } = {}) {
   const row = requireDb()
-    .prepare('SELECT * FROM domains WHERE domain = ?')
-    .get(domain);
+    .prepare('SELECT * FROM domains WHERE domain = ? AND (? IS NULL OR user_id = ?)')
+    .get(String(domain || '').toLowerCase(), userId ?? null, userId ?? null);
   return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
 }
 
-export function createDomain(domain) {
+export function createDomain(userId, domain) {
   const createdAt = now();
   const result = requireDb()
     .prepare(`
       INSERT INTO domains (
-        domain, selector, verification_token, dkim_public, dkim_private,
-        sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua,
-        status_json, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
+        user_id, dns_credential_id, domain, selector, verification_token,
+        dkim_public, dkim_private, sender_host, sending_ip, spf_extra,
+        dmarc_policy, dmarc_rua, status_json, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
     `)
     .run(
+      userId,
+      domain.dnsCredentialId || null,
       domain.domain,
       domain.selector,
       domain.verificationToken,
@@ -171,14 +263,15 @@ export function createDomain(domain) {
       createdAt,
       createdAt
     );
-  return getDomain(result.lastInsertRowid);
+  return getDomain(result.lastInsertRowid, { userId });
 }
 
-export function updateDomain(id, patch) {
-  const current = getDomain(id, { includePrivate: true });
+export function updateDomain(id, userId, patch) {
+  const current = getDomain(id, { userId, includePrivate: true });
   if (!current) return null;
   const next = {
     selector: patch.selector ?? current.selector,
+    dnsCredentialId: patch.dnsCredentialId === undefined ? current.dnsCredentialId : (patch.dnsCredentialId || null),
     senderHost: patch.senderHost ?? current.senderHost,
     sendingIp: patch.sendingIp ?? current.sendingIp,
     spfExtra: patch.spfExtra ?? current.spfExtra,
@@ -189,52 +282,51 @@ export function updateDomain(id, patch) {
   requireDb()
     .prepare(`
       UPDATE domains
-      SET selector = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
+      SET selector = ?, dns_credential_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
           dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
-      WHERE id = ?
+      WHERE id = ? AND user_id = ?
     `)
     .run(
       next.selector,
+      next.dnsCredentialId,
       next.senderHost,
       next.sendingIp,
       next.spfExtra,
       next.dmarcPolicy,
       next.dmarcRua,
       next.updatedAt,
-      id
+      id,
+      userId
     );
-  return getDomain(id);
+  return getDomain(id, { userId });
 }
 
-export function updateDkim(id, keys, selector) {
+export function updateDkim(id, userId, keys, selector) {
   requireDb()
-    .prepare(`
-      UPDATE domains
-      SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ?
-      WHERE id = ?
-    `)
-    .run(selector, keys.publicKey, keys.privateKey, now(), id);
-  return getDomain(id);
+    .prepare('UPDATE domains SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ? WHERE id = ? AND user_id = ?')
+    .run(selector, keys.publicKey, keys.privateKey, now(), id, userId);
+  return getDomain(id, { userId });
 }
 
-export function saveDomainStatus(id, status) {
+export function saveDomainStatus(id, userId, status) {
   requireDb()
-    .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ?')
-    .run(JSON.stringify(status), now(), id);
+    .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ? AND user_id = ?')
+    .run(JSON.stringify(status), now(), id, userId);
 }
 
-export function deleteDomain(id) {
-  const result = requireDb().prepare('DELETE FROM domains WHERE id = ?').run(id);
+export function deleteDomain(id, userId) {
+  const result = requireDb().prepare('DELETE FROM domains WHERE id = ? AND user_id = ?').run(id, userId);
   return result.changes > 0;
 }
 
 export function logSendEvent(event) {
   const result = requireDb()
     .prepare(`
-      INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
-      VALUES (?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
+      event.userId ?? null,
       event.domainId ?? null,
       event.sender,
       JSON.stringify(event.recipients),
@@ -246,18 +338,20 @@ export function logSendEvent(event) {
   return result.lastInsertRowid;
 }
 
-export function listSendEvents(limit = 30) {
+export function listSendEvents(userId, limit = 30) {
   return requireDb()
     .prepare(`
       SELECT e.*, d.domain
       FROM send_events e
       LEFT JOIN domains d ON d.id = e.domain_id
+      WHERE e.user_id = ?
       ORDER BY e.created_at DESC
       LIMIT ?
     `)
-    .all(limit)
+    .all(userId, limit)
     .map((row) => ({
       id: row.id,
+      userId: row.user_id,
       domainId: row.domain_id,
       domain: row.domain,
       sender: row.sender,
@@ -269,50 +363,364 @@ export function listSendEvents(limit = 30) {
     }));
 }
 
-export function getSmtpCredential({ includeHash = false, includePassword = false, includeSecret = false } = {}) {
+export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
   const row = requireDb()
-    .prepare('SELECT * FROM smtp_credentials WHERE id = 1')
-    .get();
+    .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
+    .get(userId);
   if (!row) return null;
-  const password = includePassword ? decryptPassword(row.password_secret) : '';
-  const passwordRecoverable = Boolean(row.password_secret && (password || decryptPassword(row.password_secret)));
-  return {
-    username: row.username,
-    passwordSet: Boolean(row.password_hash),
-    passwordRecoverable,
-    ...(includePassword ? { password } : {}),
-    ...(includeHash ? { passwordHash: row.password_hash } : {}),
-    ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
-    createdAt: row.created_at,
-    updatedAt: row.updated_at
-  };
+  return publicSmtpCredential(row, { includeHash, includePassword, includeSecret });
 }
 
-export function saveSmtpCredential({ username, password }) {
-  const current = getSmtpCredential({ includeHash: true, includeSecret: true });
+export function saveSmtpCredential(userId, { username, password }) {
+  const current = getSmtpCredential(userId, { includeHash: true, includeSecret: true });
   const nextUsername = String(username || current?.username || '').trim();
   if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
   const nextHash = password ? hashPassword(password) : current?.passwordHash;
   if (!nextHash) throw new Error('SMTP 密码不能为空。');
-  const nextSecret = password ? encryptPassword(password) : current?.passwordSecret || '';
+  const nextSecret = password ? encryptSecret(password) : current?.passwordSecret || '';
   const updatedAt = now();
   if (current) {
     requireDb()
-      .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE id = 1')
-      .run(nextUsername, nextHash, nextSecret, updatedAt);
+      .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE user_id = ?')
+      .run(nextUsername, nextHash, nextSecret, updatedAt, userId);
   } else {
     requireDb()
-      .prepare('INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?)')
-      .run(nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
+      .prepare(`
+        INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
+        VALUES (?, ?, ?, ?, ?, ?)
+      `)
+      .run(userId, nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
   }
-  return getSmtpCredential();
+  return getSmtpCredential(userId);
 }
 
 export function verifySmtpCredential(username, password) {
-  const credential = getSmtpCredential({ includeHash: true });
-  if (!credential?.passwordHash) return false;
-  if (!safeEqual(username, credential.username)) return false;
-  return verifyPassword(password, credential.passwordHash);
+  const row = requireDb()
+    .prepare(`
+      SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
+      FROM smtp_credentials c
+      JOIN users u ON u.id = c.user_id
+      WHERE c.username = ?
+    `)
+    .get(String(username || '').trim());
+  if (!row || row.status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
+  return {
+    user: {
+      id: row.auth_user_id,
+      username: row.auth_username,
+      email: row.email,
+      role: row.role,
+      status: row.status
+    },
+    credential: publicSmtpCredential(row)
+  };
+}
+
+export function createApiToken(userId, name) {
+  const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
+  const createdAt = now();
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
+      VALUES (?, ?, ?, ?, ?)
+    `)
+    .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
+  return {
+    ...getApiToken(result.lastInsertRowid, userId),
+    token
+  };
+}
+
+export function listApiTokens(userId) {
+  return requireDb()
+    .prepare('SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC')
+    .all(userId)
+    .map(publicApiToken);
+}
+
+export function getApiToken(id, userId) {
+  const row = requireDb()
+    .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
+    .get(id, userId);
+  return publicApiToken(row);
+}
+
+export function deleteApiToken(id, userId) {
+  const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
+  return result.changes > 0;
+}
+
+export function verifyApiToken(token) {
+  const hash = tokenHash(token);
+  const row = requireDb()
+    .prepare(`
+      SELECT t.*, u.id AS auth_user_id, u.username, u.email, u.role, u.status
+      FROM api_tokens t
+      JOIN users u ON u.id = t.user_id
+      WHERE t.token_hash = ?
+    `)
+    .get(hash);
+  if (!row || row.status !== 'active') return null;
+  requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
+  return {
+    id: row.auth_user_id,
+    username: row.username,
+    email: row.email,
+    role: row.role,
+    status: row.status
+  };
+}
+
+export function listDnsCredentials(userId) {
+  return requireDb()
+    .prepare('SELECT * FROM dns_credentials WHERE user_id = ? ORDER BY created_at DESC')
+    .all(userId)
+    .map(publicDnsCredential);
+}
+
+export function getDnsCredential(id, userId, { includeCredentials = false } = {}) {
+  const row = requireDb()
+    .prepare('SELECT * FROM dns_credentials WHERE id = ? AND user_id = ?')
+    .get(id, userId);
+  if (!row) return null;
+  const publicRow = publicDnsCredential(row);
+  if (!includeCredentials) return publicRow;
+  return {
+    ...publicRow,
+    credentials: safeJson(decryptSecret(row.credentials_secret), {})
+  };
+}
+
+export function saveDnsCredential(userId, credential) {
+  const provider = normalizeProvider(credential.provider);
+  if (!provider) throw new Error('DNS 服务商不支持。');
+  const name = String(credential.name || provider).trim();
+  const zoneName = String(credential.zoneName || credential.zone || '').trim().toLowerCase();
+  const defaultTtl = clampTtl(credential.defaultTtl);
+  const credentials = credential.credentials || pickCredentialFields(credential);
+  const updatedAt = now();
+  if (credential.id) {
+    const current = getDnsCredential(credential.id, userId, { includeCredentials: true });
+    if (!current) return null;
+    const nextCredentials = Object.keys(credentials).length ? credentials : current.credentials;
+    requireDb()
+      .prepare(`
+        UPDATE dns_credentials
+        SET name = ?, provider = ?, zone_name = ?, default_ttl = ?, credentials_secret = ?, updated_at = ?
+        WHERE id = ? AND user_id = ?
+      `)
+      .run(name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(nextCredentials)), updatedAt, credential.id, userId);
+    return getDnsCredential(credential.id, userId);
+  }
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO dns_credentials (user_id, name, provider, zone_name, default_ttl, credentials_secret, created_at, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+    `)
+    .run(userId, name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(credentials)), updatedAt, updatedAt);
+  return getDnsCredential(result.lastInsertRowid, userId);
+}
+
+export function deleteDnsCredential(id, userId) {
+  requireDb().prepare('UPDATE domains SET dns_credential_id = NULL WHERE dns_credential_id = ? AND user_id = ?').run(id, userId);
+  const result = requireDb().prepare('DELETE FROM dns_credentials WHERE id = ? AND user_id = ?').run(id, userId);
+  return result.changes > 0;
+}
+
+export function getSettings(defaults = {}) {
+  const rows = requireDb().prepare('SELECT * FROM app_settings').all();
+  const values = { ...defaults };
+  for (const row of rows) values[row.key] = row.value;
+  return values;
+}
+
+export function saveSettings(patch) {
+  const allowed = new Set([
+    'appBaseUrl',
+    'mailHostname',
+    'sendingIp',
+    'defaultSpfMechanisms',
+    'dmarcPolicy',
+    'dmarcRua',
+    'sendRequiresVerified'
+  ]);
+  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);
+  }
+  return getSettings();
+}
+
+function migrateLegacySmtpTable() {
+  if (!tableExists('smtp_credentials') || columnExists('smtp_credentials', 'user_id')) return;
+  if (!tableExists('smtp_credentials_legacy')) {
+    requireDb().exec('ALTER TABLE smtp_credentials RENAME TO smtp_credentials_legacy;');
+  } else {
+    requireDb().exec('DROP TABLE smtp_credentials;');
+  }
+}
+
+function requireDb() {
+  if (!db) throw new Error('Database is not initialized.');
+  return db;
+}
+
+function tableExists(table) {
+  return Boolean(requireDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
+}
+
+function columnExists(table, column) {
+  if (!tableExists(table)) return false;
+  return requireDb()
+    .prepare(`PRAGMA table_info(${table})`)
+    .all()
+    .some((row) => row.name === column);
+}
+
+function ensureColumn(table, column, definition) {
+  if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
+}
+
+function publicUser(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    username: row.username,
+    email: row.email,
+    role: row.role,
+    status: row.status,
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function privateUser(row) {
+  const user = publicUser(row);
+  return user ? { ...user, passwordHash: row.password_hash } : null;
+}
+
+function publicDomainRow(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    dnsCredentialId: row.dns_credential_id,
+    domain: row.domain,
+    selector: row.selector,
+    verificationToken: row.verification_token,
+    dkimPublic: row.dkim_public,
+    senderHost: row.sender_host,
+    sendingIp: row.sending_ip,
+    spfExtra: row.spf_extra,
+    dmarcPolicy: row.dmarc_policy,
+    dmarcRua: row.dmarc_rua,
+    status: safeJson(row.status_json, {}),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function privateDomainRow(row) {
+  const publicRow = publicDomainRow(row);
+  return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
+}
+
+function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
+  if (!row) return null;
+  const password = includePassword ? decryptSecret(row.password_secret) : '';
+  const passwordRecoverable = Boolean(row.password_secret && (password || decryptSecret(row.password_secret)));
+  return {
+    id: row.id,
+    userId: row.user_id,
+    username: row.username,
+    passwordSet: Boolean(row.password_hash),
+    passwordRecoverable,
+    ...(includePassword ? { password } : {}),
+    ...(includeHash ? { passwordHash: row.password_hash } : {}),
+    ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function publicApiToken(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    name: row.name,
+    tokenPrefix: row.token_prefix,
+    lastUsedAt: row.last_used_at,
+    createdAt: row.created_at
+  };
+}
+
+function publicDnsCredential(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    name: row.name,
+    provider: row.provider,
+    zoneName: row.zone_name,
+    defaultTtl: row.default_ttl,
+    credentialSet: Boolean(row.credentials_secret),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function normalizeUsername(value) {
+  const username = String(value || '').trim().toLowerCase();
+  return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
+}
+
+function normalizeEmail(value) {
+  const email = String(value || '').trim().toLowerCase();
+  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
+}
+
+function normalizeProvider(value) {
+  const provider = String(value || '').trim().toLowerCase();
+  return ['cloudflare', 'aliyun', 'dnspod'].includes(provider) ? provider : '';
+}
+
+function pickCredentialFields(source) {
+  const output = {};
+  for (const key of ['apiToken', 'zoneId', 'accessKeyId', 'accessKeySecret', 'secretId', 'secretKey']) {
+    if (source[key]) output[key] = String(source[key]).trim();
+  }
+  return output;
+}
+
+function clampTtl(value) {
+  const ttl = Number(value || 600);
+  if (!Number.isInteger(ttl) || ttl < 60) return 600;
+  if (ttl > 86400) return 86400;
+  return ttl;
+}
+
+function safeJson(value, fallback) {
+  try {
+    return JSON.parse(value);
+  } catch {
+    return fallback;
+  }
+}
+
+function now() {
+  return new Date().toISOString();
+}
+
+function tokenHash(token) {
+  return crypto.createHash('sha256').update(String(token || '')).digest('hex');
 }
 
 function hashPassword(password) {
@@ -321,30 +729,31 @@ function hashPassword(password) {
   return `scrypt$${salt}$${hash}`;
 }
 
-function encryptPassword(password) {
-  if (!password) return '';
-  const key = credentialKey();
+function verifyPassword(password, stored) {
+  const [scheme, salt, hash] = String(stored || '').split('$');
+  if (scheme !== 'scrypt' || !salt || !hash) return false;
+  const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
+  return safeEqual(actual, hash);
+}
+
+function encryptSecret(value) {
+  if (!value) return '';
   const iv = crypto.randomBytes(12);
-  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
-  const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()]);
-  const tag = cipher.getAuthTag();
+  const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
+  const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
   return [
     'v1',
     iv.toString('base64url'),
-    tag.toString('base64url'),
+    cipher.getAuthTag().toString('base64url'),
     encrypted.toString('base64url')
   ].join('$');
 }
 
-function decryptPassword(secret) {
+function decryptSecret(secret) {
   const [version, ivRaw, tagRaw, encryptedRaw] = String(secret || '').split('$');
   if (version !== 'v1' || !ivRaw || !tagRaw || !encryptedRaw) return '';
   try {
-    const decipher = crypto.createDecipheriv(
-      'aes-256-gcm',
-      credentialKey(),
-      Buffer.from(ivRaw, 'base64url')
-    );
+    const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivRaw, 'base64url'));
     decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
     return Buffer.concat([
       decipher.update(Buffer.from(encryptedRaw, 'base64url')),
@@ -355,20 +764,13 @@ function decryptPassword(secret) {
   }
 }
 
-function credentialKey() {
+function encryptionKey() {
   return crypto
     .createHash('sha256')
-    .update(credentialSecret || 'mailhub-local-credential-secret')
+    .update(secretKey || 'mailhub-local-secret')
     .digest();
 }
 
-function verifyPassword(password, stored) {
-  const [scheme, salt, hash] = String(stored || '').split('$');
-  if (scheme !== 'scrypt' || !salt || !hash) return false;
-  const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
-  return safeEqual(actual, hash);
-}
-
 function safeEqual(actual, expected) {
   const a = Buffer.from(String(actual || ''));
   const b = Buffer.from(String(expected || ''));

+ 373 - 0
src/dns-providers.js

@@ -0,0 +1,373 @@
+import crypto from 'node:crypto';
+
+const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
+const ALIYUN_ENDPOINT = 'https://alidns.aliyuncs.com/';
+const TENCENT_ENDPOINT = 'https://dnspod.tencentcloudapi.com';
+
+export async function testDnsCredential(credential) {
+  try {
+    const provider = createProvider(credential);
+    const result = await provider.test();
+    return { ok: true, provider: credential.provider, detail: result };
+  } catch (error) {
+    return { ok: false, provider: credential.provider, error: error.message };
+  }
+}
+
+export async function applyDnsSetup(domain, credential, guide) {
+  const provider = createProvider(credential);
+  const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc', 'sender-a'].includes(record.key));
+  const results = [];
+  for (const record of records) {
+    try {
+      const result = await provider.upsert(record, domain);
+      results.push({ key: record.key, host: record.host, type: record.type, ok: true, detail: result });
+    } catch (error) {
+      results.push({ key: record.key, host: record.host, type: record.type, ok: false, error: error.message });
+    }
+  }
+  return {
+    ok: results.every((result) => result.ok),
+    provider: credential.provider,
+    appliedAt: new Date().toISOString(),
+    results
+  };
+}
+
+function createProvider(credential) {
+  if (credential.provider === 'cloudflare') return new CloudflareProvider(credential);
+  if (credential.provider === 'aliyun') return new AliyunProvider(credential);
+  if (credential.provider === 'dnspod') return new DnspodProvider(credential);
+  throw new Error('DNS 服务商不支持。');
+}
+
+class CloudflareProvider {
+  constructor(credential) {
+    this.credential = credential;
+    this.credentials = credential.credentials || {};
+    this.zoneName = credential.zoneName;
+    this.ttl = credential.defaultTtl || 600;
+  }
+
+  async test() {
+    const zoneId = await this.zoneId();
+    const zone = await this.request(`/zones/${zoneId}`);
+    return zone.result?.name || this.zoneName || zoneId;
+  }
+
+  async upsert(record) {
+    const zoneId = await this.zoneId();
+    const existing = await this.listRecords(zoneId, record);
+    const match = pickExisting(record, existing);
+    const payload = {
+      type: record.type,
+      name: record.host,
+      content: record.value,
+      ttl: this.ttl,
+      proxied: false
+    };
+    if (match) {
+      await this.request(`/zones/${zoneId}/dns_records/${match.id}`, {
+        method: 'PUT',
+        body: payload
+      });
+      await this.deleteExtras(zoneId, existing, match, record);
+      return 'updated';
+    }
+    await this.request(`/zones/${zoneId}/dns_records`, { method: 'POST', body: payload });
+    await this.deleteExtras(zoneId, existing, null, record);
+    return 'created';
+  }
+
+  async deleteExtras(zoneId, records, kept, desired) {
+    if (!['spf', 'dmarc'].includes(desired.key)) return;
+    const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.content));
+    for (const record of extras) await this.request(`/zones/${zoneId}/dns_records/${record.id}`, { method: 'DELETE' });
+  }
+
+  async listRecords(zoneId, record) {
+    const params = new URLSearchParams({ type: record.type, name: record.host });
+    const response = await this.request(`/zones/${zoneId}/dns_records?${params}`);
+    return response.result || [];
+  }
+
+  async zoneId() {
+    if (this.credentials.zoneId) return this.credentials.zoneId;
+    if (!this.zoneName) throw new Error('Cloudflare 需要 zoneName 或 zoneId。');
+    const response = await this.request(`/zones?name=${encodeURIComponent(this.zoneName)}`);
+    const zone = response.result?.[0];
+    if (!zone?.id) throw new Error(`Cloudflare 未找到 Zone ${this.zoneName}。`);
+    return zone.id;
+  }
+
+  async request(path, options = {}) {
+    if (!this.credentials.apiToken) throw new Error('Cloudflare API Token 不能为空。');
+    const response = await fetch(`${CLOUDFLARE_API}${path}`, {
+      method: options.method || 'GET',
+      headers: {
+        Authorization: `Bearer ${this.credentials.apiToken}`,
+        'Content-Type': 'application/json'
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined
+    });
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok || data.success === false) {
+      const message = data.errors?.map((error) => error.message).join('; ') || `Cloudflare HTTP ${response.status}`;
+      throw new Error(message);
+    }
+    return data;
+  }
+}
+
+class AliyunProvider {
+  constructor(credential) {
+    this.credential = credential;
+    this.credentials = credential.credentials || {};
+    this.zoneName = credential.zoneName;
+    this.ttl = credential.defaultTtl || 600;
+  }
+
+  async test() {
+    const response = await this.request('DescribeDomainRecords', { DomainName: this.zoneName, PageSize: 1 });
+    return response.DomainRecords?.Record?.length >= 0 ? this.zoneName : 'ok';
+  }
+
+  async upsert(record) {
+    const rr = relativeName(record.host, this.zoneName);
+    const existing = await this.listRecords(record.type, rr);
+    const match = pickExisting(record, existing);
+    const params = {
+      RR: rr,
+      Type: record.type,
+      Value: record.value,
+      TTL: this.ttl
+    };
+    if (match) {
+      await this.request('UpdateDomainRecord', { ...params, RecordId: match.id });
+      await this.deleteExtras(existing, match, record);
+      return 'updated';
+    }
+    await this.request('AddDomainRecord', { DomainName: this.zoneName, ...params });
+    await this.deleteExtras(existing, null, record);
+    return 'created';
+  }
+
+  async deleteExtras(records, kept, desired) {
+    if (!['spf', 'dmarc'].includes(desired.key)) return;
+    const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
+    for (const record of extras) await this.request('DeleteDomainRecord', { RecordId: record.id });
+  }
+
+  async listRecords(type, rr) {
+    const response = await this.request('DescribeDomainRecords', {
+      DomainName: this.zoneName,
+      RRKeyWord: rr === '@' ? '' : rr,
+      TypeKeyWord: type,
+      PageSize: 100
+    });
+    return (response.DomainRecords?.Record || [])
+      .filter((record) => record.RR === rr && record.Type === type)
+      .map((record) => ({
+        id: String(record.RecordId),
+        type: record.Type,
+        name: record.RR,
+        value: record.Value
+      }));
+  }
+
+  async request(action, params) {
+    if (!this.zoneName) throw new Error('阿里云 DNS 需要 zoneName。');
+    if (!this.credentials.accessKeyId || !this.credentials.accessKeySecret) {
+      throw new Error('阿里云 AccessKeyId 和 AccessKeySecret 不能为空。');
+    }
+    const common = {
+      Action: action,
+      Version: '2015-01-09',
+      Format: 'JSON',
+      AccessKeyId: this.credentials.accessKeyId,
+      SignatureMethod: 'HMAC-SHA1',
+      Timestamp: new Date().toISOString(),
+      SignatureVersion: '1.0',
+      SignatureNonce: crypto.randomUUID()
+    };
+    const signed = signAliyun({ ...common, ...params }, this.credentials.accessKeySecret);
+    const response = await fetch(`${ALIYUN_ENDPOINT}?${signed}`);
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok || data.Code) throw new Error(data.Message || data.Code || `Aliyun HTTP ${response.status}`);
+    return data;
+  }
+}
+
+class DnspodProvider {
+  constructor(credential) {
+    this.credential = credential;
+    this.credentials = credential.credentials || {};
+    this.zoneName = credential.zoneName;
+    this.ttl = credential.defaultTtl || 600;
+  }
+
+  async test() {
+    await this.request('DescribeRecordList', { Domain: this.zoneName, Limit: 1 });
+    return this.zoneName;
+  }
+
+  async upsert(record) {
+    const subDomain = relativeName(record.host, this.zoneName);
+    const existing = await this.listRecords(record.type, subDomain);
+    const match = pickExisting(record, existing);
+    const params = {
+      Domain: this.zoneName,
+      SubDomain: subDomain,
+      RecordType: record.type,
+      RecordLine: '默认',
+      Value: record.value,
+      TTL: this.ttl
+    };
+    if (match) {
+      await this.request('ModifyRecord', { ...params, RecordId: Number(match.id) });
+      await this.deleteExtras(existing, match, record);
+      return 'updated';
+    }
+    await this.request('CreateRecord', params);
+    await this.deleteExtras(existing, null, record);
+    return 'created';
+  }
+
+  async deleteExtras(records, kept, desired) {
+    if (!['spf', 'dmarc'].includes(desired.key)) return;
+    const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
+    for (const record of extras) {
+      await this.request('DeleteRecord', { Domain: this.zoneName, RecordId: Number(record.id) });
+    }
+  }
+
+  async listRecords(type, subDomain) {
+    const response = await this.request('DescribeRecordList', {
+      Domain: this.zoneName,
+      Subdomain: subDomain,
+      RecordType: type,
+      Limit: 100
+    });
+    return (response.RecordList || [])
+      .filter((record) => record.Name === subDomain && record.Type === type)
+      .map((record) => ({
+        id: String(record.RecordId),
+        type: record.Type,
+        name: record.Name,
+        value: record.Value
+      }));
+  }
+
+  async request(action, payload) {
+    if (!this.zoneName) throw new Error('腾讯云 DNSPod 需要 zoneName。');
+    if (!this.credentials.secretId || !this.credentials.secretKey) throw new Error('腾讯云 SecretId 和 SecretKey 不能为空。');
+    const timestamp = Math.floor(Date.now() / 1000);
+    const body = JSON.stringify(payload);
+    const headers = signTencent({
+      action,
+      body,
+      secretId: this.credentials.secretId,
+      secretKey: this.credentials.secretKey,
+      timestamp
+    });
+    const response = await fetch(TENCENT_ENDPOINT, {
+      method: 'POST',
+      headers,
+      body
+    });
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok || data.Response?.Error) {
+      throw new Error(data.Response?.Error?.Message || `Tencent Cloud HTTP ${response.status}`);
+    }
+    return data.Response;
+  }
+}
+
+function pickExisting(desired, existing) {
+  if (desired.key === 'spf' || desired.key === 'dmarc') {
+    return existing.find((record) => recordMatchesKind(desired, record.content || record.value));
+  }
+  return existing.find((record) => normalizeValue(record.content || record.value) === normalizeValue(desired.value)) || existing[0] || null;
+}
+
+function recordMatchesKind(desired, value) {
+  if (desired.key === 'spf') return /^v=spf1(?:\s|$)/i.test(String(value || '').trim());
+  if (desired.key === 'dmarc') return /^v=DMARC1(?:;|\s|$)/i.test(String(value || '').trim());
+  return normalizeValue(value) === normalizeValue(desired.value);
+}
+
+function normalizeValue(value) {
+  return String(value || '').replace(/\s+/g, ' ').trim();
+}
+
+function relativeName(host, zoneName) {
+  const cleanHost = String(host || '').replace(/\.$/, '').toLowerCase();
+  const cleanZone = String(zoneName || '').replace(/\.$/, '').toLowerCase();
+  if (!cleanZone) throw new Error('DNS 凭据缺少 zoneName。');
+  if (cleanHost === cleanZone) return '@';
+  if (cleanHost.endsWith(`.${cleanZone}`)) return cleanHost.slice(0, -cleanZone.length - 1) || '@';
+  return cleanHost;
+}
+
+function signAliyun(params, accessKeySecret) {
+  const encoded = Object.keys(params)
+    .sort()
+    .map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
+    .join('&');
+  const stringToSign = `GET&%2F&${percentEncode(encoded)}`;
+  const signature = crypto
+    .createHmac('sha1', `${accessKeySecret}&`)
+    .update(stringToSign)
+    .digest('base64');
+  return `${encoded}&Signature=${percentEncode(signature)}`;
+}
+
+function percentEncode(value) {
+  return encodeURIComponent(String(value))
+    .replace(/\+/g, '%20')
+    .replace(/\*/g, '%2A')
+    .replace(/%7E/g, '~');
+}
+
+function signTencent({ action, body, secretId, secretKey, timestamp }) {
+  const service = 'dnspod';
+  const host = 'dnspod.tencentcloudapi.com';
+  const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
+  const hashedPayload = sha256(body, 'hex');
+  const canonicalRequest = [
+    'POST',
+    '/',
+    '',
+    `content-type:application/json; charset=utf-8\nhost:${host}\n`,
+    'content-type;host',
+    hashedPayload
+  ].join('\n');
+  const credentialScope = `${date}/${service}/tc3_request`;
+  const stringToSign = [
+    'TC3-HMAC-SHA256',
+    String(timestamp),
+    credentialScope,
+    sha256(canonicalRequest, 'hex')
+  ].join('\n');
+  const secretDate = hmac(`TC3${secretKey}`, date);
+  const secretService = hmac(secretDate, service);
+  const secretSigning = hmac(secretService, 'tc3_request');
+  const signature = hmac(secretSigning, stringToSign, 'hex');
+  return {
+    Authorization: `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, SignedHeaders=content-type;host, Signature=${signature}`,
+    'Content-Type': 'application/json; charset=utf-8',
+    Host: host,
+    'X-TC-Action': action,
+    'X-TC-Version': '2021-03-23',
+    'X-TC-Timestamp': String(timestamp),
+    'X-TC-Region': 'ap-guangzhou'
+  };
+}
+
+function sha256(value, encoding) {
+  return crypto.createHash('sha256').update(value).digest(encoding);
+}
+
+function hmac(key, value, encoding) {
+  return crypto.createHmac('sha256', key).update(value).digest(encoding);
+}

+ 365 - 208
src/server.js

@@ -5,21 +5,40 @@ import http from 'node:http';
 import path from 'node:path';
 import { fileURLToPath, domainToASCII } from 'node:url';
 import {
+  authenticateUser,
+  claimLegacyData,
+  createApiToken,
   createDomain,
+  createUser,
+  deleteApiToken,
+  deleteDnsCredential,
   deleteDomain,
+  getAdminUser,
+  getDnsCredential,
   getDomain,
   getDomainByName,
+  getSettings,
   getSmtpCredential,
+  getUser,
   initDatabase,
+  listApiTokens,
+  listDnsCredentials,
   listDomains,
   listSendEvents,
+  listUsers,
   logSendEvent,
-  saveSmtpCredential,
+  saveDnsCredential,
   saveDomainStatus,
+  saveSettings,
+  saveSmtpCredential,
+  seedAdminUser,
   seedSmtpCredential,
   updateDkim,
-  updateDomain
+  updateDomain,
+  updateUser,
+  verifyApiToken
 } from './db.js';
+import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
 import { buildDnsGuide } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
 import {
@@ -39,23 +58,19 @@ import {
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 loadDotEnv();
 
-const config = {
+const envConfig = {
   port: Number(process.env.PORT || 3000),
   dataDir: process.env.DATA_DIR || path.join(process.cwd(), 'data'),
-  appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
   adminUser: process.env.ADMIN_USER || 'admin',
+  adminEmail: process.env.ADMIN_EMAIL || `${process.env.ADMIN_USER || 'admin'}@mailhub.local`,
   adminPassword: process.env.ADMIN_PASSWORD || 'change-this-admin-password',
-  apiToken: process.env.API_TOKEN || '',
-  mailHostname: process.env.MAIL_HOSTNAME || 'ali.ss5.xyz',
-  sendingIp: process.env.SENDING_IP || '',
-  defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
+  legacyApiToken: process.env.API_TOKEN || '',
   smtpHost: process.env.SMTP_HOST || '',
   smtpPort: Number(process.env.SMTP_PORT || 25),
   smtpSecure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
   smtpUser: process.env.SMTP_USERNAME || '',
   smtpPassword: process.env.SMTP_PASSWORD || '',
   smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
-  sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true',
   submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
   submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
   submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
@@ -64,16 +79,30 @@ const config = {
   submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
   submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
   submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
-  dmarcPolicy: process.env.DMARC_POLICY || 'none',
-  dmarcRua: process.env.DMARC_RUA || '',
   sessionSecret: process.env.SESSION_SECRET || crypto
     .createHash('sha256')
     .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
     .digest('hex')
 };
 
-initDatabase(config.dataDir, config.sessionSecret);
-seedSmtpCredential(config.submissionUsername, config.submissionPassword);
+const defaultSettings = {
+  appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
+  mailHostname: process.env.MAIL_HOSTNAME || 'ali.ss5.xyz',
+  sendingIp: process.env.SENDING_IP || '',
+  defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
+  dmarcPolicy: process.env.DMARC_POLICY || 'none',
+  dmarcRua: process.env.DMARC_RUA || '',
+  sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false'
+};
+
+initDatabase(envConfig.dataDir, envConfig.sessionSecret);
+const admin = seedAdminUser({
+  username: envConfig.adminUser,
+  email: envConfig.adminEmail,
+  password: envConfig.adminPassword
+});
+claimLegacyData(admin.id);
+seedSmtpCredential(admin.id, envConfig.submissionUsername, envConfig.submissionPassword);
 
 const server = http.createServer(async (req, res) => {
   try {
@@ -81,23 +110,22 @@ const server = http.createServer(async (req, res) => {
     if (req.method === 'OPTIONS') return handleOptions(res);
     const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
     if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
+    if (req.method === 'POST' && url.pathname === '/api/register') return await handleRegister(req, res);
     if (req.method === 'POST' && url.pathname === '/api/login') return await handleLogin(req, res);
     if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
+
+    const user = getRequestUser(req, url.pathname);
     if (isLoginAsset(url.pathname)) {
-      if (url.pathname === '/login' && isAuthorized(req, url.pathname)) return redirect(res, '/');
-      return await serveStatic(req, res, url, { loginPage: true });
+      if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
+      return await serveStatic(req, res, url);
     }
 
-    if (!isAuthorized(req, url.pathname)) {
-      if (url.pathname.startsWith('/api/')) {
-        return sendJson(res, 401, { error: 'Authentication required.' });
-      }
+    if (!user) {
+      if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { error: 'Authentication required.' });
       return redirect(res, '/login');
     }
 
-    if (url.pathname.startsWith('/api/')) {
-      return await handleApi(req, res, url);
-    }
+    if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, user);
     return await serveStatic(req, res, url);
   } catch (error) {
     console.error(error);
@@ -105,92 +133,152 @@ const server = http.createServer(async (req, res) => {
   }
 });
 
-server.listen(config.port, '0.0.0.0', () => {
-  console.log(`MailHub listening on 0.0.0.0:${config.port}`);
+server.listen(envConfig.port, '0.0.0.0', () => {
+  console.log(`MailHub listening on 0.0.0.0:${envConfig.port}`);
 });
 
 startSubmissionServer({
-  enabled: config.submissionEnabled && Boolean(getSmtpCredential()),
-  listeners: config.submissionListeners,
-  hostname: config.submissionHost,
-  allowInsecureAuth: config.submissionAllowInsecureAuth,
-  tlsCertPath: config.submissionTlsCert,
-  tlsKeyPath: config.submissionTlsKey,
-  relayHost: config.smtpHost,
-  relayPort: config.smtpPort,
-  relaySecure: config.smtpSecure,
-  relayUsername: config.smtpUser,
-  relayPassword: config.smtpPassword,
-  relayHelo: config.smtpHelo
+  enabled: envConfig.submissionEnabled,
+  listeners: envConfig.submissionListeners,
+  hostname: envConfig.submissionHost,
+  allowInsecureAuth: envConfig.submissionAllowInsecureAuth,
+  tlsCertPath: envConfig.submissionTlsCert,
+  tlsKeyPath: envConfig.submissionTlsKey,
+  relayHost: envConfig.smtpHost,
+  relayPort: envConfig.smtpPort,
+  relaySecure: envConfig.smtpSecure,
+  relayUsername: envConfig.smtpUser,
+  relayPassword: envConfig.smtpPassword,
+  relayHelo: envConfig.smtpHelo
 });
 
-async function handleApi(req, res, url) {
+async function handleApi(req, res, url, user) {
   const method = req.method || 'GET';
   const pathname = url.pathname;
 
+  if (method === 'GET' && pathname === '/api/me') {
+    return sendJson(res, 200, { user });
+  }
   if (method === 'GET' && pathname === '/api/config') {
-    return sendJson(res, 200, publicConfig());
+    return sendJson(res, 200, publicConfig(user));
   }
   if (method === 'GET' && pathname === '/api/domains') {
-    return sendJson(res, 200, { domains: listDomains() });
+    return sendJson(res, 200, { domains: listDomains(user.id) });
   }
   if (method === 'POST' && pathname === '/api/domains') {
     const body = await readJson(req);
+    const settings = runtimeSettings();
     const domain = normalizeDomain(body.domain);
     if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
     const selector = normalizeSelector(body.selector || defaultSelector());
     if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
     const keys = createDkimKeyPair();
-    const row = createDomain({
-      domain,
-      selector,
-      verificationToken: crypto.randomBytes(18).toString('hex'),
-      dkimPublic: keys.publicKey,
-      dkimPrivate: keys.privateKey,
-      senderHost: normalizeHostname(body.senderHost || config.mailHostname),
-      sendingIp: String(body.sendingIp || config.sendingIp).trim(),
-      spfExtra: String(body.spfExtra ?? config.defaultSpfMechanisms).trim(),
-      dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || config.dmarcPolicy),
-      dmarcRua: String(body.dmarcRua ?? config.dmarcRua).trim()
-    });
-    return sendJson(res, 201, { domain: row });
+    try {
+      const row = createDomain(user.id, {
+        domain,
+        selector,
+        dnsCredentialId: Number(body.dnsCredentialId || 0) || null,
+        verificationToken: crypto.randomBytes(18).toString('hex'),
+        dkimPublic: keys.publicKey,
+        dkimPrivate: keys.privateKey,
+        senderHost: normalizeHostname(body.senderHost || settings.mailHostname),
+        sendingIp: String(body.sendingIp || settings.sendingIp).trim(),
+        spfExtra: String(body.spfExtra ?? settings.defaultSpfMechanisms).trim(),
+        dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || settings.dmarcPolicy),
+        dmarcRua: String(body.dmarcRua ?? settings.dmarcRua).trim()
+      });
+      return sendJson(res, 201, { domain: row });
+    } catch (error) {
+      if (isUniqueError(error)) return sendJson(res, 409, { error: '该域名已被添加。' });
+      throw error;
+    }
   }
   if (method === 'GET' && pathname === '/api/events') {
-    return sendJson(res, 200, { events: listSendEvents() });
+    return sendJson(res, 200, { events: listSendEvents(user.id) });
   }
   if (method === 'GET' && pathname === '/api/smtp-credential') {
-    return sendJson(res, 200, { credential: getSmtpCredential({ includePassword: true }) });
+    return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
   }
   if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
     const body = await readJson(req);
-    saveSmtpCredential({
-      username: String(body.username || '').trim(),
-      password: String(body.password || '')
-    });
-    const credential = getSmtpCredential({ includePassword: true });
-    return sendJson(res, 200, { credential });
+    try {
+      saveSmtpCredential(user.id, {
+        username: String(body.username || '').trim(),
+        password: String(body.password || '')
+      });
+    } catch (error) {
+      if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
+      throw error;
+    }
+    return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
   }
   if (method === 'POST' && pathname === '/api/send') {
     const body = await readJson(req);
-    const result = await sendMailFromBody(body);
+    const result = await sendMailFromBody(body, user);
     return sendJson(res, 202, result);
   }
 
+  if (method === 'GET' && pathname === '/api/api-tokens') {
+    return sendJson(res, 200, { tokens: listApiTokens(user.id) });
+  }
+  if (method === 'POST' && pathname === '/api/api-tokens') {
+    const body = await readJson(req);
+    return sendJson(res, 201, { token: createApiToken(user.id, body.name) });
+  }
+  const tokenMatch = pathname.match(/^\/api\/api-tokens\/(\d+)$/);
+  if (tokenMatch && method === 'DELETE') {
+    const deleted = deleteApiToken(Number(tokenMatch[1]), user.id);
+    return sendJson(res, deleted ? 200 : 404, { deleted });
+  }
+
+  if (method === 'GET' && pathname === '/api/dns-credentials') {
+    return sendJson(res, 200, { credentials: listDnsCredentials(user.id) });
+  }
+  if (method === 'POST' && pathname === '/api/dns-credentials') {
+    const body = await readJson(req);
+    const credential = saveDnsCredential(user.id, body);
+    return sendJson(res, 201, { credential });
+  }
+  const dnsMatch = pathname.match(/^\/api\/dns-credentials\/(\d+)(?:\/([a-z-]+))?$/);
+  if (dnsMatch) {
+    const id = Number(dnsMatch[1]);
+    const action = dnsMatch[2] || '';
+    if ((method === 'PUT' || method === 'PATCH') && !action) {
+      const body = await readJson(req);
+      const credential = saveDnsCredential(user.id, { ...body, id });
+      return sendJson(res, credential ? 200 : 404, { credential });
+    }
+    if (method === 'DELETE' && !action) {
+      const deleted = deleteDnsCredential(id, user.id);
+      return sendJson(res, deleted ? 200 : 404, { deleted });
+    }
+    if (method === 'POST' && action === 'test') {
+      const credential = getDnsCredential(id, user.id, { includeCredentials: true });
+      if (!credential) return sendJson(res, 404, { error: 'DNS 凭据不存在。' });
+      const result = await testDnsCredential(credential);
+      return sendJson(res, result.ok ? 200 : 400, result);
+    }
+  }
+
+  const adminResponse = await handleAdminApi(req, res, pathname, method, user);
+  if (adminResponse) return adminResponse;
+
   const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
   if (domainMatch) {
     const id = Number(domainMatch[1]);
     const action = domainMatch[2] || '';
     if (method === 'GET' && !action) {
-      const domain = getDomain(id);
+      const domain = getDomain(id, { userId: user.id });
       if (!domain) return sendJson(res, 404, { error: '域名不存在。' });
       return sendJson(res, 200, { domain });
     }
     if (method === 'PATCH' && !action) {
       const body = await readJson(req);
-      const row = updateDomain(id, {
+      const row = updateDomain(id, user.id, {
         selector: body.selector ? normalizeSelector(body.selector) : undefined,
+        dnsCredentialId: body.dnsCredentialId !== undefined ? Number(body.dnsCredentialId || 0) || null : undefined,
         senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
-        sendingIp: body.sendingIp ? String(body.sendingIp).trim() : undefined,
+        sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
         spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
         dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
         dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
@@ -199,26 +287,43 @@ async function handleApi(req, res, url) {
       return sendJson(res, 200, { domain: row });
     }
     if (method === 'DELETE' && !action) {
-      const deleted = deleteDomain(id);
+      const deleted = deleteDomain(id, user.id);
       return sendJson(res, deleted ? 200 : 404, { deleted });
     }
     if (method === 'POST' && action === 'check') {
-      const row = getDomain(id);
+      const row = getDomain(id, { userId: user.id });
+      if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      const guide = await buildDnsGuide(row);
+      saveDomainStatus(id, user.id, guide);
+      return sendJson(res, 200, { guide, domain: getDomain(id, { userId: user.id }) });
+    }
+    if (method === 'POST' && action === 'apply-dns') {
+      const row = getDomain(id, { userId: user.id, includePrivate: true });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      const credentialId = Number(row.dnsCredentialId || 0);
+      const credential = credentialId ? getDnsCredential(credentialId, user.id, { includeCredentials: true }) : null;
+      if (!credential) return sendJson(res, 400, { error: '请先为该域名绑定 DNS API 凭据。' });
       const guide = await buildDnsGuide(row);
-      saveDomainStatus(id, guide);
-      return sendJson(res, 200, { guide, domain: getDomain(id) });
+      const applyResult = await applyDnsSetup(row, credential, guide);
+      const checkedGuide = await buildDnsGuide(row);
+      checkedGuide.apply = applyResult;
+      saveDomainStatus(id, user.id, checkedGuide);
+      return sendJson(res, applyResult.ok ? 200 : 207, {
+        apply: applyResult,
+        guide: checkedGuide,
+        domain: getDomain(id, { userId: user.id })
+      });
     }
     if (method === 'POST' && action === 'rotate-dkim') {
-      const row = getDomain(id);
+      const row = getDomain(id, { userId: user.id });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
       const body = await readJson(req).catch(() => ({}));
       const selector = normalizeSelector(body.selector || defaultSelector());
-      const next = updateDkim(id, createDkimKeyPair(), selector);
+      const next = updateDkim(id, user.id, createDkimKeyPair(), selector);
       return sendJson(res, 200, { domain: next });
     }
     if (method === 'POST' && action === 'test-send') {
-      const row = getDomain(id);
+      const row = getDomain(id, { userId: user.id });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
       const body = await readJson(req);
       const from = body.from || `noreply@${row.domain}`;
@@ -227,7 +332,7 @@ async function handleApi(req, res, url) {
         to: body.to,
         subject: body.subject || `MailHub test for ${row.domain}`,
         text: body.text || `This is a MailHub test message from ${row.domain}.`
-      });
+      }, user);
       return sendJson(res, 202, result);
     }
   }
@@ -235,15 +340,51 @@ async function handleApi(req, res, url) {
   return sendJson(res, 404, { error: 'Not found.' });
 }
 
-async function sendMailFromBody(body) {
+async function handleAdminApi(req, res, pathname, method, user) {
+  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 === 'PATCH' || method === 'PUT') && pathname === '/api/admin/settings') {
+    const body = await readJson(req);
+    saveSettings({
+      appBaseUrl: body.appBaseUrl,
+      mailHostname: body.mailHostname,
+      sendingIp: body.sendingIp,
+      defaultSpfMechanisms: body.defaultSpfMechanisms,
+      dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy),
+      dmarcRua: body.dmarcRua,
+      sendRequiresVerified: boolString(body.sendRequiresVerified)
+    });
+    return sendJson(res, 200, { settings: runtimeSettings() });
+  }
+  if (method === 'GET' && pathname === '/api/admin/users') {
+    return sendJson(res, 200, { users: listUsers() });
+  }
+  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
+    });
+    return sendJson(res, updated ? 200 : 404, { user: updated });
+  }
+  return sendJson(res, 404, { error: 'Not found.' });
+}
+
+async function sendMailFromBody(body, user) {
+  const settings = runtimeSettings();
   const from = extractAddress(body.from);
   if (!from) throw new Error('发件人地址格式不正确。');
   const recipients = parseAddressList(body.to);
   if (!recipients.length) throw new Error('收件人地址格式不正确。');
   const fromDomain = domainFromAddress(from);
-  const domain = getDomainByName(fromDomain, { includePrivate: true });
-  if (!domain) throw new Error(`发件域名 ${fromDomain} 尚未添加。`);
-  if (config.sendRequiresVerified && !domain.status?.verified) {
+  const domain = getDomainByName(fromDomain, { userId: user.id, includePrivate: true });
+  if (!domain) throw new Error(`发件域名 ${fromDomain} 不属于当前用户或尚未添加。`);
+  if (settings.sendRequiresVerified && !domain.status?.verified) {
     throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
   }
 
@@ -253,22 +394,23 @@ async function sendMailFromBody(body) {
     subject: body.subject || '(no subject)',
     text: body.text || '',
     html: body.html || '',
-    baseUrl: config.appBaseUrl
+    baseUrl: settings.appBaseUrl
   });
   const signed = signMessageForDomain(rawMessage, domain);
   try {
     const smtpResult = await sendViaSmtp({
-      host: config.smtpHost,
-      port: config.smtpPort,
-      secure: config.smtpSecure,
-      username: config.smtpUser,
-      password: config.smtpPassword,
-      helo: config.smtpHelo,
+      host: envConfig.smtpHost,
+      port: envConfig.smtpPort,
+      secure: envConfig.smtpSecure,
+      username: envConfig.smtpUser,
+      password: envConfig.smtpPassword,
+      helo: envConfig.smtpHelo,
       mailFrom: from,
       recipients,
       rawMessage: signed
     });
     logSendEvent({
+      userId: user.id,
       domainId: domain.id,
       sender: from,
       recipients,
@@ -276,14 +418,10 @@ async function sendMailFromBody(body) {
       status: 'queued',
       detail: smtpResult.message
     });
-    return {
-      queued: true,
-      domain: domain.domain,
-      recipients,
-      smtp: smtpResult.message
-    };
+    return { queued: true, domain: domain.domain, recipients, smtp: smtpResult.message };
   } catch (error) {
     logSendEvent({
+      userId: user.id,
       domainId: domain.id,
       sender: from,
       recipients,
@@ -295,6 +433,130 @@ async function sendMailFromBody(body) {
   }
 }
 
+async function handleRegister(req, res) {
+  const body = await readJson(req);
+  try {
+    const user = createUser({
+      username: body.username,
+      email: body.email,
+      password: body.password
+    });
+    const token = createSessionToken(user);
+    res.writeHead(201, {
+      'Content-Type': 'application/json; charset=utf-8',
+      'Set-Cookie': sessionCookie(token)
+    });
+    return res.end(JSON.stringify({ user }));
+  } catch (error) {
+    if (isUniqueError(error)) return sendJson(res, 409, { error: '用户名或邮箱已被注册。' });
+    return sendJson(res, 400, { error: error.message || '注册失败。' });
+  }
+}
+
+async function handleLogin(req, res) {
+  const body = await readJson(req);
+  const user = authenticateUser(body.username || body.email, body.password);
+  if (!user) return sendJson(res, 401, { error: '账号或密码不正确。' });
+  const token = createSessionToken(user);
+  res.writeHead(200, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Set-Cookie': sessionCookie(token)
+  });
+  res.end(JSON.stringify({ user }));
+}
+
+function handleLogout(res) {
+  res.writeHead(200, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
+  });
+  res.end(JSON.stringify({ ok: true }));
+}
+
+function getRequestUser(req, pathname) {
+  const sessionUser = getSessionUser(req);
+  if (sessionUser) return sessionUser;
+  const auth = req.headers.authorization || '';
+  if (auth.startsWith('Basic ')) {
+    const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
+    const index = decoded.indexOf(':');
+    const user = authenticateUser(decoded.slice(0, index), decoded.slice(index + 1));
+    if (user) return user;
+  }
+  if (pathname === '/api/send' && auth.startsWith('Bearer ')) {
+    const token = auth.slice(7);
+    const user = verifyApiToken(token);
+    if (user) return user;
+    if (envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) return getAdminUser();
+  }
+  return null;
+}
+
+function getSessionUser(req) {
+  const token = parseCookies(req.headers.cookie || '').mailhub_session;
+  if (!token || !token.includes('.')) return null;
+  const [payload, signature] = token.split('.');
+  if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return null;
+  try {
+    const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+    if (Number(data.exp) <= Date.now()) return null;
+    const user = getUser(Number(data.uid));
+    return user?.status === 'active' ? user : null;
+  } catch {
+    return null;
+  }
+}
+
+function createSessionToken(user) {
+  const payload = Buffer.from(JSON.stringify({
+    uid: user.id,
+    exp: Date.now() + 12 * 60 * 60 * 1000,
+    nonce: crypto.randomBytes(10).toString('hex')
+  })).toString('base64url');
+  return `${payload}.${signSessionPayload(payload)}`;
+}
+
+function signSessionPayload(payload) {
+  return crypto.createHmac('sha256', envConfig.sessionSecret).update(payload).digest('base64url');
+}
+
+function sessionCookie(token) {
+  return [`mailhub_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=43200'].join('; ');
+}
+
+function publicConfig(user) {
+  const settings = runtimeSettings();
+  const smtpCredential = getSmtpCredential(user.id);
+  return {
+    ...settings,
+    smtpHost: envConfig.smtpHost ? 'configured' : '',
+    submission: {
+      enabled: envConfig.submissionEnabled,
+      host: envConfig.submissionHost,
+      ports: publicSubmissionListeners(envConfig.submissionListeners),
+      username: smtpCredential?.username || '',
+      passwordSet: Boolean(smtpCredential?.passwordSet),
+      tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
+      requireTlsForAuth: !envConfig.submissionAllowInsecureAuth
+    },
+    apiTokenSet: Boolean(envConfig.legacyApiToken),
+    usingDefaultAdminPassword: user.role === 'admin' && envConfig.adminPassword === 'change-this-admin-password'
+  };
+}
+
+function runtimeSettings() {
+  const settings = getSettings(defaultSettings);
+  return {
+    appBaseUrl: settings.appBaseUrl,
+    mailHostname: settings.mailHostname,
+    sendingIp: settings.sendingIp,
+    defaultSpfMechanisms: settings.defaultSpfMechanisms,
+    dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
+    dmarcRua: settings.dmarcRua,
+    sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true'
+  };
+}
+
 async function serveStatic(req, res, url) {
   const publicDir = path.join(__dirname, '..', 'public');
   const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
@@ -322,8 +584,7 @@ async function readJson(req) {
   const chunks = [];
   for await (const chunk of req) chunks.push(chunk);
   if (!chunks.length) return {};
-  const raw = Buffer.concat(chunks).toString('utf8');
-  return JSON.parse(raw);
+  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
 }
 
 function sendJson(res, status, payload) {
@@ -345,99 +606,18 @@ function setSecurityHeaders(res) {
 function handleOptions(res) {
   res.writeHead(204, {
     'Access-Control-Allow-Origin': '*',
-    'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
+    'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
     'Access-Control-Allow-Headers': 'Content-Type, Authorization'
   });
   res.end();
 }
 
-function isAuthorized(req, pathname) {
-  const auth = req.headers.authorization || '';
-  if (hasValidSession(req)) return true;
-  if (auth.startsWith('Basic ')) {
-    const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
-    const index = decoded.indexOf(':');
-    const user = decoded.slice(0, index);
-    const password = decoded.slice(index + 1);
-    return safeEqual(user, config.adminUser) && safeEqual(password, config.adminPassword);
-  }
-  if (pathname === '/api/send' && config.apiToken && auth.startsWith('Bearer ')) {
-    return safeEqual(auth.slice(7), config.apiToken);
-  }
-  return false;
-}
-
-async function handleLogin(req, res) {
-  const body = await readJson(req);
-  const user = String(body.username || '');
-  const password = String(body.password || '');
-  if (!safeEqual(user, config.adminUser) || !safeEqual(password, config.adminPassword)) {
-    return sendJson(res, 401, { error: '账号或密码不正确。' });
-  }
-  const token = createSessionToken(user);
-  res.writeHead(200, {
-    'Content-Type': 'application/json; charset=utf-8',
-    'Set-Cookie': sessionCookie(token)
-  });
-  res.end(JSON.stringify({ ok: true }));
-}
-
-function handleLogout(res) {
-  res.writeHead(200, {
-    'Content-Type': 'application/json; charset=utf-8',
-    'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
-  });
-  res.end(JSON.stringify({ ok: true }));
-}
-
-function createSessionToken(user) {
-  const payload = Buffer.from(JSON.stringify({
-    user,
-    exp: Date.now() + 12 * 60 * 60 * 1000,
-    nonce: crypto.randomBytes(10).toString('hex')
-  })).toString('base64url');
-  const signature = signSessionPayload(payload);
-  return `${payload}.${signature}`;
-}
-
-function hasValidSession(req) {
-  const token = parseCookies(req.headers.cookie || '').mailhub_session;
-  if (!token || !token.includes('.')) return false;
-  const [payload, signature] = token.split('.');
-  if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return false;
-  try {
-    const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
-    return data.user === config.adminUser && Number(data.exp) > Date.now();
-  } catch {
-    return false;
-  }
-}
-
-function signSessionPayload(payload) {
-  return crypto
-    .createHmac('sha256', config.sessionSecret)
-    .update(payload)
-    .digest('base64url');
-}
-
-function sessionCookie(token) {
-  return [
-    `mailhub_session=${token}`,
-    'Path=/',
-    'HttpOnly',
-    'SameSite=Lax',
-    'Max-Age=43200'
-  ].join('; ');
-}
-
 function parseCookies(header) {
   const cookies = {};
   for (const part of String(header || '').split(';')) {
     const index = part.indexOf('=');
     if (index === -1) continue;
-    const key = part.slice(0, index).trim();
-    const value = part.slice(index + 1).trim();
-    cookies[key] = value;
+    cookies[part.slice(0, index).trim()] = part.slice(index + 1).trim();
   }
   return cookies;
 }
@@ -450,12 +630,7 @@ function safeEqual(actual, expected) {
 }
 
 function normalizeDomain(input) {
-  const raw = String(input || '')
-    .trim()
-    .toLowerCase()
-    .replace(/^https?:\/\//, '')
-    .replace(/\/.*$/, '')
-    .replace(/\.$/, '');
+  const raw = String(input || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/\.$/, '');
   const ascii = domainToASCII(raw);
   if (!ascii || ascii.length > 253) return '';
   if (!/^(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(ascii)) return '';
@@ -468,8 +643,7 @@ function normalizeHostname(input) {
 
 function normalizeSelector(input) {
   const value = String(input || '').trim().toLowerCase();
-  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) return '';
-  return value;
+  return /^[a-z0-9][a-z0-9-]{0,62}$/.test(value) ? value : '';
 }
 
 function normalizeDmarcPolicy(input) {
@@ -477,41 +651,26 @@ function normalizeDmarcPolicy(input) {
   return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
 }
 
+function boolString(value) {
+  return String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
+}
+
 function defaultSelector() {
   const d = new Date();
   return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
 }
 
-function publicConfig() {
-  const smtpCredential = getSmtpCredential();
-  return {
-    appBaseUrl: config.appBaseUrl,
-    mailHostname: config.mailHostname,
-    sendingIp: config.sendingIp,
-    defaultSpfMechanisms: config.defaultSpfMechanisms,
-    smtpHost: config.smtpHost ? 'configured' : '',
-    submission: {
-      enabled: config.submissionEnabled && Boolean(smtpCredential),
-      host: config.submissionHost,
-      ports: publicSubmissionListeners(config.submissionListeners),
-      username: smtpCredential?.username || '',
-      passwordSet: Boolean(smtpCredential?.passwordSet),
-      tls: Boolean(config.submissionTlsCert && config.submissionTlsKey),
-      requireTlsForAuth: !config.submissionAllowInsecureAuth
-    },
-    sendRequiresVerified: config.sendRequiresVerified,
-    apiTokenSet: Boolean(config.apiToken),
-    usingDefaultAdminPassword: config.adminPassword === 'change-this-admin-password'
-  };
+function isUniqueError(error) {
+  return /UNIQUE constraint failed/i.test(String(error?.message || ''));
 }
 
 function isLoginAsset(pathname) {
-  return ['/login', '/login.html', '/login.css', '/login.js'].includes(pathname);
+  return ['/login', '/register', '/login.html', '/login.css', '/login.js'].includes(pathname);
 }
 
 function resolveStaticPathname(pathname) {
   if (pathname === '/') return '/index.html';
-  if (pathname === '/login') return '/login.html';
+  if (pathname === '/login' || pathname === '/register') return '/login.html';
   return pathname;
 }
 
@@ -526,9 +685,7 @@ function loadDotEnv() {
     if (index === -1) continue;
     const key = trimmed.slice(0, index).trim();
     let value = trimmed.slice(index + 1).trim();
-    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
-      value = value.slice(1, -1);
-    }
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
     if (!(key in process.env)) process.env[key] = value;
   }
 }

+ 11 - 2
src/submission.js

@@ -86,6 +86,7 @@ class SubmissionSession {
     this.dataLines = [];
     this.authState = '';
     this.authUser = '';
+    this.user = null;
     this.authenticated = false;
     this.mailFrom = '';
     this.recipients = [];
@@ -166,6 +167,7 @@ class SubmissionSession {
     this.socket = secureSocket;
     this.buffer = '';
     this.authenticated = false;
+    this.user = null;
     this.authState = '';
     this.config = {
       ...this.config,
@@ -219,10 +221,13 @@ class SubmissionSession {
   }
 
   finishAuth(user, password) {
-    if (verifySmtpCredential(user, password)) {
+    const auth = verifySmtpCredential(user, password);
+    if (auth?.user) {
+      this.user = auth.user;
       this.authenticated = true;
       return this.write(235, 'Authentication successful');
     }
+    this.user = null;
     this.authenticated = false;
     return this.write(535, 'Authentication failed');
   }
@@ -261,9 +266,10 @@ class SubmissionSession {
     const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
     const sender = extractAddress(headerFrom) || this.mailFrom;
     const domainName = domainFromAddress(sender || this.mailFrom);
-    const domain = getDomainByName(domainName, { includePrivate: true });
+    const domain = getDomainByName(domainName, { userId: this.user?.id, includePrivate: true });
     if (!domain) {
       logSendEvent({
+        userId: this.user?.id || null,
         domainId: null,
         sender: sender || this.mailFrom,
         recipients: this.recipients,
@@ -288,6 +294,7 @@ class SubmissionSession {
         rawMessage: signed
       });
       logSendEvent({
+        userId: this.user.id,
         domainId: domain.id,
         sender: sender || this.mailFrom,
         recipients: this.recipients,
@@ -299,6 +306,7 @@ class SubmissionSession {
       return this.write(250, 'Message queued');
     } catch (error) {
       logSendEvent({
+        userId: this.user.id,
         domainId: domain.id,
         sender: sender || this.mailFrom,
         recipients: this.recipients,
@@ -315,6 +323,7 @@ class SubmissionSession {
     this.recipients = [];
     this.dataMode = false;
     this.dataLines = [];
+    this.user = this.authenticated ? this.user : null;
     if (reply) this.write(250, 'OK');
   }