فهرست منبع

feat: add DNS auto-check and multiple SMTP login credentials

AI-Co-Authored-By: Codex
chendeben 1 ماه پیش
والد
کامیت
4d27bed9ad

+ 3 - 0
.env.example

@@ -26,6 +26,9 @@ SENDING_IP=203.0.113.10
 # Examples: include:spf.mailjet.com include:_netblocks.m.feishu.cn
 DEFAULT_SPF_MECHANISMS=
 DNS_RESOLVERS=1.1.1.1,8.8.8.8
+DNS_AUTO_CHECK_ENABLED=true
+DNS_AUTO_CHECK_INTERVAL_MS=60000
+DNS_AUTO_CHECK_LIMIT=25
 
 # SMTP service used by the web API. In docker-compose this is the internal Postfix service.
 SMTP_HOST=postfix

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
public/assets/index-DCOXBks-.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/login-BngjuMPw.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/styles-BBgHuOwj.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/styles-CWLk_28-.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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-CDzCP_eL.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-B6ac5KuF.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-CeDdzaWW.css">
+    <script type="module" crossorigin src="/assets/index-DCOXBks-.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BBgHuOwj.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-CWLk_28-.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-BkDwyZ5o.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-B6ac5KuF.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-CeDdzaWW.css">
+    <script type="module" crossorigin src="/assets/login-BngjuMPw.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BBgHuOwj.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-CWLk_28-.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 87 - 23
src/db.js

@@ -81,7 +81,7 @@ export function initDatabase(dataDir, secret = '') {
 
     CREATE TABLE IF NOT EXISTS smtp_credentials (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
-      user_id INTEGER NOT NULL UNIQUE,
+      user_id INTEGER NOT NULL,
       username TEXT NOT NULL UNIQUE,
       password_hash TEXT NOT NULL,
       password_secret TEXT NOT NULL DEFAULT '',
@@ -165,6 +165,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('send_events', 'delivered_at', 'TEXT');
+  migrateSmtpCredentialsToMultiplePerUser();
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
@@ -172,6 +173,7 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
     CREATE INDEX IF NOT EXISTS idx_events_smtp_relay_id ON send_events(smtp_relay_id);
     CREATE INDEX IF NOT EXISTS idx_events_queue_id ON send_events(queue_id);
+    CREATE INDEX IF NOT EXISTS idx_smtp_credentials_user_id ON smtp_credentials(user_id);
     CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
   `);
   normalizeSendEventQueueIds();
@@ -292,7 +294,7 @@ export function listUsersWithResourceCounts() {
         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
+        smtpCredential: Number(row.smtp_credentials_count || 0)
       }
     }));
 }
@@ -444,22 +446,23 @@ export function transferApiTokens({ actorUserId, tokenIds, targetUserId }) {
 
 export function previewUserMerge({ sourceUserId, targetUserId }) {
   const { source, target } = requireMergeUsers(sourceUserId, targetUserId);
-  const sourceSmtp = getSmtpCredential(source.id);
-  const targetSmtp = getSmtpCredential(target.id);
+  const sourceSmtpCredentials = listSmtpCredentials(source.id);
+  const targetSmtpCredentials = listSmtpCredentials(target.id);
+  const sourceSmtp = sourceSmtpCredentials[0] || null;
+  const targetSmtp = targetSmtpCredentials[0] || null;
   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
+    smtpCredential: sourceSmtpCredentials.length
   };
-  const smtpConflict = Boolean(sourceSmtp && targetSmtp);
   const defaultOptions = {
     transferDomains: true,
     transferDnsCredentials: true,
     transferApiTokens: true,
     transferSendEvents: true,
-    transferSmtpCredential: Boolean(sourceSmtp && !targetSmtp),
+    transferSmtpCredential: sourceSmtpCredentials.length > 0,
     disableSource: true
   };
   const selectedCounts = {
@@ -483,12 +486,9 @@ export function previewUserMerge({ sourceUserId, targetUserId }) {
     smtp: {
       sourceCredential: sourceSmtp,
       targetCredential: targetSmtp,
-      conflict: smtpConflict
+      conflict: false
     },
-    warnings: smtpConflict ? [{
-      type: 'smtp_credential_conflict',
-      message: '目标用户已有 SMTP 凭据,源用户 SMTP 凭据需要手动处理。'
-    }] : []
+    warnings: []
   };
 }
 
@@ -505,7 +505,7 @@ export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, opti
       sendEvents: options.transferSendEvents === false ? 0 : moveRows('send_events', sourceId, targetId),
       smtpCredential: 0
     };
-    if (options.transferSmtpCredential !== false && preview.smtp.sourceCredential && !preview.smtp.targetCredential) {
+    if (options.transferSmtpCredential !== false && preview.counts.smtpCredential > 0) {
       counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
     }
     if (options.disableSource !== false) {
@@ -603,6 +603,13 @@ export function listDomains(userId) {
     .map(publicDomainRow);
 }
 
+export function listDomainsForDnsAutoCheck() {
+  return requireDb()
+    .prepare('SELECT * FROM domains ORDER BY updated_at ASC')
+    .all()
+    .map(publicDomainRow);
+}
+
 export function getDomain(id, { userId, includePrivate = false } = {}) {
   const row = requireDb()
     .prepare('SELECT * FROM domains WHERE id = ? AND (? IS NULL OR user_id = ?)')
@@ -931,16 +938,31 @@ export function getSendAnalytics(userId, { days = 30 } = {}) {
   };
 }
 
-export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
-  const row = requireDb()
-    .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
-    .get(userId);
+export function listSmtpCredentials(userId, { includePassword = false, includeSecret = false } = {}) {
+  return requireDb()
+    .prepare('SELECT * FROM smtp_credentials WHERE user_id = ? ORDER BY created_at DESC, id DESC')
+    .all(userId)
+    .map((row) => publicSmtpCredential(row, { includePassword, includeSecret }));
+}
+
+export function getSmtpCredential(idOrUserId, userIdOrOptions = {}, maybeOptions = {}) {
+  const scopedLookup = typeof userIdOrOptions === 'number';
+  const options = scopedLookup ? maybeOptions : userIdOrOptions;
+  const { includeHash = false, includePassword = false, includeSecret = false } = options || {};
+  const row = scopedLookup
+    ? requireDb()
+      .prepare('SELECT * FROM smtp_credentials WHERE id = ? AND user_id = ?')
+      .get(Number(idOrUserId), Number(userIdOrOptions))
+    : requireDb()
+      .prepare('SELECT * FROM smtp_credentials WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT 1')
+      .get(Number(idOrUserId));
   if (!row) return null;
   return publicSmtpCredential(row, { includeHash, includePassword, includeSecret });
 }
 
-export function saveSmtpCredential(userId, { username, password }) {
-  const current = getSmtpCredential(userId, { includeHash: true, includeSecret: true });
+export function saveSmtpCredential(userId, { id = null, username, password }) {
+  const current = id ? getSmtpCredential(id, userId, { includeHash: true, includeSecret: true }) : null;
+  if (id && !current) return null;
   const nextUsername = String(username || current?.username || '').trim();
   if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
   const nextHash = password ? hashPassword(password) : current?.passwordHash;
@@ -949,17 +971,25 @@ export function saveSmtpCredential(userId, { username, password }) {
   const updatedAt = now();
   if (current) {
     requireDb()
-      .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE user_id = ?')
-      .run(nextUsername, nextHash, nextSecret, updatedAt, userId);
+      .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE id = ? AND user_id = ?')
+      .run(nextUsername, nextHash, nextSecret, updatedAt, current.id, userId);
+    return getSmtpCredential(current.id, userId);
   } else {
-    requireDb()
+    const result = requireDb()
       .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(result.lastInsertRowid, userId);
   }
-  return getSmtpCredential(userId);
+}
+
+export function deleteSmtpCredential(id, userId) {
+  const result = requireDb()
+    .prepare('DELETE FROM smtp_credentials WHERE id = ? AND user_id = ?')
+    .run(Number(id), userId);
+  return result.changes > 0;
 }
 
 export function listSmtpRelays(userId, { includePassword = false, includeSecret = false } = {}) {
@@ -1404,6 +1434,40 @@ function migrateLegacySmtpTable() {
   }
 }
 
+function migrateSmtpCredentialsToMultiplePerUser() {
+  if (!tableExists('smtp_credentials') || !smtpCredentialsHasUserIdUniqueConstraint()) return;
+  requireDb().exec(`
+    ALTER TABLE smtp_credentials RENAME TO smtp_credentials_single_user;
+    CREATE TABLE smtp_credentials (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      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
+    );
+    INSERT INTO smtp_credentials (id, user_id, username, password_hash, password_secret, created_at, updated_at)
+    SELECT id, user_id, username, password_hash, COALESCE(password_secret, ''), created_at, updated_at
+    FROM smtp_credentials_single_user;
+    DROP TABLE smtp_credentials_single_user;
+  `);
+}
+
+function smtpCredentialsHasUserIdUniqueConstraint() {
+  const row = requireDb()
+    .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'smtp_credentials'")
+    .get();
+  if (/user_id\s+INTEGER\s+NOT\s+NULL\s+UNIQUE/i.test(String(row?.sql || ''))) return true;
+  const indexes = requireDb().prepare('PRAGMA index_list(smtp_credentials)').all();
+  return indexes.some((index) => {
+    if (!index.unique) return false;
+    const columns = requireDb().prepare(`PRAGMA index_info(${index.name})`).all().map((item) => item.name);
+    return columns.length === 1 && columns[0] === 'user_id';
+  });
+}
+
 function requireDb() {
   if (!db) throw new Error('Database is not initialized.');
   return db;

+ 92 - 0
src/dns-auto-checker.js

@@ -0,0 +1,92 @@
+import { listDomainsForDnsAutoCheck, saveDomainStatus } from './db.js';
+import { buildDnsGuide } from './dns-guide.js';
+
+const defaultIntervalMs = 60000;
+const defaultLimit = 25;
+
+export function shouldAutoCheckDomain(domain, { now = new Date(), minIntervalMs = defaultIntervalMs } = {}) {
+  const status = domain?.status || {};
+  if (status.verified === true) return false;
+  const checkedAt = Date.parse(status.checkedAt || '');
+  if (!Number.isFinite(checkedAt)) return true;
+  return now.getTime() - checkedAt >= minIntervalMs;
+}
+
+export async function runDnsAutoCheck({
+  listDomains = listDomainsForDnsAutoCheck,
+  buildGuide = buildDnsGuide,
+  saveStatus = saveDomainStatus,
+  logger = console,
+  now = () => new Date(),
+  minIntervalMs = defaultIntervalMs,
+  limit = defaultLimit
+} = {}) {
+  const referenceTime = typeof now === 'function' ? now() : now;
+  const domains = listDomains();
+  const candidates = domains
+    .filter((domain) => shouldAutoCheckDomain(domain, { now: referenceTime, minIntervalMs }))
+    .slice(0, safePositiveInt(limit, defaultLimit));
+  let checked = 0;
+  let failed = 0;
+
+  for (const domain of candidates) {
+    try {
+      const guide = await buildGuide(domain);
+      saveStatus(domain.id, domain.userId, guide);
+      checked += 1;
+    } catch (error) {
+      failed += 1;
+      logger.warn?.(`DNS auto-check failed for ${domain.domain}: ${error.message}`);
+    }
+  }
+
+  return {
+    checked,
+    failed,
+    skipped: domains.length - candidates.length
+  };
+}
+
+export function startDnsAutoChecker({
+  enabled = true,
+  intervalMs = defaultIntervalMs,
+  limit = defaultLimit,
+  logger = console
+} = {}) {
+  if (!enabled) return null;
+  const state = {
+    stopped: false,
+    checking: false
+  };
+  const checkIntervalMs = safePositiveInt(intervalMs, defaultIntervalMs);
+
+  async function poll() {
+    if (state.stopped || state.checking) return;
+    state.checking = true;
+    try {
+      await runDnsAutoCheck({
+        minIntervalMs: checkIntervalMs,
+        limit,
+        logger
+      });
+    } finally {
+      state.checking = false;
+    }
+  }
+
+  const timer = setInterval(poll, checkIntervalMs);
+  timer.unref?.();
+  setTimeout(poll, 5000).unref?.();
+  return {
+    stop() {
+      state.stopped = true;
+      clearInterval(timer);
+    },
+    poll
+  };
+}
+
+function safePositiveInt(value, fallback) {
+  const number = Number(value);
+  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
+}

+ 65 - 18
src/frontend/App.tsx

@@ -26,6 +26,7 @@ import type {
   DomainMode,
   DomainPatchPayload,
   RuntimeConfig,
+  SmtpCredential,
   SmtpRelay,
   SmtpRelayPayload,
   User,
@@ -39,6 +40,7 @@ const emptyData: AppData = {
   events: [],
   analytics: null,
   smtpCredential: null,
+  smtpCredentials: [],
   smtpRelays: [],
   dnsCredentials: [],
   apiTokens: [],
@@ -112,12 +114,13 @@ function MailHubConsole() {
     setLoading(true);
     try {
       const me = await api.me();
-      const [config, domains, events, analytics, smtpCredential, smtpRelays, dnsCredentials, apiTokens] = await Promise.all([
+      const [config, domains, events, analytics, smtpCredential, smtpCredentials, smtpRelays, dnsCredentials, apiTokens] = await Promise.all([
         api.config(),
         api.domains(),
         api.events(),
         api.analytics(30),
         api.smtpCredential(),
+        api.smtpCredentials(),
         api.smtpRelays(),
         api.dnsCredentials(),
         api.apiTokens()
@@ -136,6 +139,7 @@ function MailHubConsole() {
         events: events.events || [],
         analytics: analytics.analytics || null,
         smtpCredential: smtpCredential.credential || null,
+        smtpCredentials: smtpCredentials.credentials || [],
         smtpRelays: smtpRelays.relays || [],
         dnsCredentials: dnsCredentials.credentials || [],
         apiTokens: apiTokens.tokens || [],
@@ -293,23 +297,63 @@ function MailHubConsole() {
     }));
   }
 
-  async function saveSmtpCredential(values: { username: string; password?: string }) {
-    const result = await runAction(async () => api.saveSmtpCredential(values), t('actions.smtpSaved'));
-    if (!result?.credential) return;
-    setData((current) => ({
-      ...current,
-      smtpCredential: result.credential,
-      config: current.config?.submission
-        ? {
-            ...current.config,
-            submission: {
-              ...current.config.submission,
-              username: result.credential.username,
-              passwordSet: Boolean(result.credential.passwordSet)
+  async function loadSmtpLoginCredential(id: number) {
+    const result = await runAction(async () => api.smtpCredentialDetail(id));
+    return result?.credential || null;
+  }
+
+  async function saveSmtpLoginCredential(values: { username: string; password?: string }, id?: number) {
+    const result = await runAction(
+      async () => api.saveSmtpLoginCredential(values, id),
+      id ? t('actions.smtpUpdated') : t('actions.smtpCreated')
+    );
+    if (!result?.credential) return null;
+    setData((current) => {
+      const credentials = id
+        ? current.smtpCredentials.map((item) => item.id === id ? result.credential : item)
+        : [result.credential, ...current.smtpCredentials];
+      return {
+        ...current,
+        smtpCredential: credentials[0] || null,
+        smtpCredentials: credentials,
+        config: current.config?.submission
+          ? {
+              ...current.config,
+              submission: {
+                ...current.config.submission,
+                username: credentials[0]?.username || '',
+                passwordSet: Boolean(credentials[0]?.passwordSet)
+              }
             }
-          }
-        : current.config
-    }));
+          : current.config
+      };
+    });
+    return result.credential;
+  }
+
+  async function deleteSmtpLoginCredential(credential: SmtpCredential) {
+    const credentialId = credential.id;
+    if (!credentialId) return;
+    const result = await runAction(async () => api.deleteSmtpCredential(credentialId), t('actions.smtpDeleted'));
+    if (!result?.deleted) return;
+    setData((current) => {
+      const credentials = current.smtpCredentials.filter((item) => item.id !== credentialId);
+      return {
+        ...current,
+        smtpCredential: credentials[0] || null,
+        smtpCredentials: credentials,
+        config: current.config?.submission
+          ? {
+              ...current.config,
+              submission: {
+                ...current.config.submission,
+                username: credentials[0]?.username || '',
+                passwordSet: Boolean(credentials[0]?.passwordSet)
+              }
+            }
+          : current.config
+      };
+    });
   }
 
   async function loadSmtpRelay(id: number) {
@@ -506,10 +550,13 @@ function MailHubConsole() {
         <SmtpCredentials
           config={data.config}
           credential={data.smtpCredential}
+          credentials={data.smtpCredentials}
           relays={data.smtpRelays}
           loading={actionLoading}
           onCopy={copy}
-          onSave={saveSmtpCredential}
+          onLoadCredential={loadSmtpLoginCredential}
+          onSaveCredential={saveSmtpLoginCredential}
+          onDeleteCredential={deleteSmtpLoginCredential}
           onLoadRelay={loadSmtpRelay}
           onSaveRelay={saveSmtpRelay}
           onDeleteRelay={deleteSmtpRelay}

+ 30 - 8
src/frontend/i18n/index.js

@@ -193,13 +193,22 @@ const messages = {
     'logs.statusBounced': '已退信',
     'logs.statusFailed': '失败',
     'smtp.connectionTitle': 'SMTP 连接信息',
-    'smtp.updateTitle': '更新 SMTP 凭据',
+    'smtp.loginCredentialsTitle': '发信登录凭据',
+    'smtp.username': '用户名',
+    'smtp.password': '密码',
     'smtp.usernameRequired': '请输入 SMTP Username',
+    'smtp.passwordRequired': '请输入 SMTP Password',
+    'smtp.passwordSet': '已设置',
+    'smtp.passwordEmpty': '未设置',
+    'smtp.passwordUnavailable': '请重新设置后复制',
+    'smtp.create': '新增凭据',
+    'smtp.createTitle': '新增发信登录凭据',
+    'smtp.editTitle': '编辑发信登录凭据',
+    'smtp.deleteConfirm': '确认删除该 SMTP 登录凭据?',
     'smtp.passwordExtra': '留空保存时会保留原密码;当前密码可在上方连接信息中复制。',
     'smtp.regenerate': '重新生成密码',
-    'smtp.save': '保存 SMTP 凭据',
     'smtp.resetToCopy': '请重新设置后复制',
-    'smtpRelay.title': '发信 SMTP 出口',
+    'smtpRelay.title': '高级:外部 SMTP 出口',
     'smtpRelay.create': '新增出口',
     'smtpRelay.createTitle': '新增发信 SMTP 出口',
     'smtpRelay.editTitle': '编辑发信 SMTP 出口',
@@ -304,7 +313,9 @@ const messages = {
     'actions.dnsApiUpdated': 'DNS API 已更新',
     'actions.dnsApiDeleted': 'DNS API 已删除',
     'actions.dnsApiTestCompleted': '连接测试完成',
-    'actions.smtpSaved': 'SMTP 凭据已保存',
+    'actions.smtpCreated': 'SMTP 登录凭据已新增',
+    'actions.smtpUpdated': 'SMTP 登录凭据已更新',
+    'actions.smtpDeleted': 'SMTP 登录凭据已删除',
     'actions.smtpRelayCreated': 'SMTP 出口已新增',
     'actions.smtpRelayUpdated': 'SMTP 出口已更新',
     'actions.smtpRelayDeleted': 'SMTP 出口已删除',
@@ -510,13 +521,22 @@ const messages = {
     'logs.statusBounced': 'Bounced',
     'logs.statusFailed': 'Failed',
     'smtp.connectionTitle': 'SMTP connection',
-    'smtp.updateTitle': 'Update SMTP credential',
+    'smtp.loginCredentialsTitle': 'Sending login credentials',
+    'smtp.username': 'Username',
+    'smtp.password': 'Password',
     'smtp.usernameRequired': 'Enter SMTP username',
+    'smtp.passwordRequired': 'Enter SMTP password',
+    'smtp.passwordSet': 'Set',
+    'smtp.passwordEmpty': 'Empty',
+    'smtp.passwordUnavailable': 'Reset before copying',
+    'smtp.create': 'New credential',
+    'smtp.createTitle': 'New sending login credential',
+    'smtp.editTitle': 'Edit sending login credential',
+    'smtp.deleteConfirm': 'Delete this SMTP login credential?',
     'smtp.passwordExtra': 'Leave empty to keep the current password. Copy the current password from the connection section above.',
     'smtp.regenerate': 'Regenerate password',
-    'smtp.save': 'Save SMTP credential',
     'smtp.resetToCopy': 'Reset before copying',
-    'smtpRelay.title': 'Outbound SMTP relays',
+    'smtpRelay.title': 'Advanced: external SMTP relays',
     'smtpRelay.create': 'New relay',
     'smtpRelay.createTitle': 'New outbound SMTP relay',
     'smtpRelay.editTitle': 'Edit outbound SMTP relay',
@@ -621,7 +641,9 @@ const messages = {
     'actions.dnsApiUpdated': 'DNS API updated',
     'actions.dnsApiDeleted': 'DNS API deleted',
     'actions.dnsApiTestCompleted': 'Connection test completed',
-    'actions.smtpSaved': 'SMTP credential saved',
+    'actions.smtpCreated': 'SMTP login credential created',
+    'actions.smtpUpdated': 'SMTP login credential updated',
+    'actions.smtpDeleted': 'SMTP login credential deleted',
     'actions.smtpRelayCreated': 'SMTP relay created',
     'actions.smtpRelayUpdated': 'SMTP relay updated',
     'actions.smtpRelayDeleted': 'SMTP relay deleted',

+ 9 - 0
src/frontend/services/api.ts

@@ -93,6 +93,15 @@ export const api = {
   smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
   saveSmtpCredential: (data: { username: string; password?: string }) =>
     request<{ credential: SmtpCredential }>('/api/smtp-credential', { method: 'PUT', data }),
+  smtpCredentials: () => request<{ credentials: SmtpCredential[] }>('/api/smtp-credentials'),
+  smtpCredentialDetail: (id: number) => request<{ credential: SmtpCredential }>(`/api/smtp-credentials/${id}`),
+  saveSmtpLoginCredential: (data: { username: string; password?: string }, id?: number) =>
+    request<{ credential: SmtpCredential }>(id ? `/api/smtp-credentials/${id}` : '/api/smtp-credentials', {
+      method: id ? 'PATCH' : 'POST',
+      data
+    }),
+  deleteSmtpCredential: (id: number) =>
+    request<{ deleted: boolean }>(`/api/smtp-credentials/${id}`, { method: 'DELETE' }),
   smtpRelays: () => request<{ relays: SmtpRelay[] }>('/api/smtp-relays'),
   smtpRelay: (id: number) => request<{ relay: SmtpRelay }>(`/api/smtp-relays/${id}`),
   saveSmtpRelay: (data: SmtpRelayPayload, id?: number) =>

+ 12 - 0
src/frontend/styles.css

@@ -21,6 +21,12 @@ body {
 .admin-sider {
   background: #111827 !important;
   border-right: 1px solid rgba(255, 255, 255, 0.06);
+  height: 100vh;
+  max-height: 100vh;
+  overflow-y: auto;
+  position: sticky !important;
+  top: 0;
+  z-index: 20;
 }
 
 .brand {
@@ -447,6 +453,12 @@ body {
     position: static;
   }
 
+  .admin-sider {
+    height: auto;
+    max-height: none;
+    position: static !important;
+  }
+
   .admin-content {
     padding: 16px;
   }

+ 1 - 0
src/frontend/types.ts

@@ -331,6 +331,7 @@ export interface AppData {
   events: SendEvent[];
   analytics: Analytics | null;
   smtpCredential: SmtpCredential | null;
+  smtpCredentials: SmtpCredential[];
   smtpRelays: SmtpRelay[];
   dnsCredentials: DnsCredential[];
   apiTokens: ApiToken[];

+ 169 - 42
src/pages/SmtpCredentials.tsx

@@ -1,7 +1,7 @@
 import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import { Button, Card, Descriptions, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Tag, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
 
 import { useI18n } from '../frontend/i18n/react';
 import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
@@ -9,49 +9,101 @@ import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from
 interface SmtpCredentialsProps {
   config: RuntimeConfig | null;
   credential: SmtpCredential | null;
+  credentials: SmtpCredential[];
   relays: SmtpRelay[];
   loading?: boolean;
   onCopy: (value: string) => void;
-  onSave: (values: { username: string; password?: string }) => Promise<void>;
+  onLoadCredential: (id: number) => Promise<SmtpCredential | null>;
+  onSaveCredential: (values: { username: string; password?: string }, id?: number) => Promise<SmtpCredential | null>;
+  onDeleteCredential: (credential: SmtpCredential) => Promise<void>;
   onLoadRelay: (id: number) => Promise<SmtpRelay | null>;
   onSaveRelay: (values: SmtpRelayPayload, id?: number) => Promise<SmtpRelay | null>;
   onDeleteRelay: (relay: SmtpRelay) => Promise<void>;
 }
 
+interface CredentialFormValues {
+  username: string;
+  password?: string;
+}
+
 export default function SmtpCredentials({
   config,
   credential,
+  credentials,
   relays,
   loading,
   onCopy,
-  onSave,
+  onLoadCredential,
+  onSaveCredential,
+  onDeleteCredential,
   onLoadRelay,
   onSaveRelay,
   onDeleteRelay
 }: SmtpCredentialsProps) {
   const { t } = useI18n();
-  const [form] = Form.useForm();
+  const [credentialForm] = Form.useForm<CredentialFormValues>();
   const [relayForm] = Form.useForm<SmtpRelayPayload>();
+  const [credentialOpen, setCredentialOpen] = useState(false);
+  const [credentialLoading, setCredentialLoading] = useState(false);
+  const [editingCredential, setEditingCredential] = useState<SmtpCredential | null>(null);
   const [relayOpen, setRelayOpen] = useState(false);
   const [relayLoading, setRelayLoading] = useState(false);
   const [editingRelay, setEditingRelay] = useState<SmtpRelay | null>(null);
 
-  useEffect(() => {
-    form.setFieldsValue({ username: credential?.username || config?.submission?.username || '' });
-  }, [config?.submission?.username, credential?.username, form]);
-
-  function generatePassword() {
-    const bytes = new Uint8Array(24);
-    crypto.getRandomValues(bytes);
-    const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
-    form.setFieldValue('password', password);
+  function generateCredentialPassword() {
+    credentialForm.setFieldValue('password', randomPassword());
   }
 
   function generateRelayPassword() {
-    const bytes = new Uint8Array(24);
-    crypto.getRandomValues(bytes);
-    const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
-    relayForm.setFieldValue('password', password);
+    relayForm.setFieldValue('password', randomPassword());
+  }
+
+  function openCreateCredential() {
+    setEditingCredential(null);
+    credentialForm.setFieldsValue({
+      username: credentials.length === 0 ? credential?.username || config?.submission?.username || '' : '',
+      password: ''
+    });
+    setCredentialOpen(true);
+  }
+
+  async function openEditCredential(item: SmtpCredential) {
+    if (!item.id) return;
+    setEditingCredential(item);
+    setCredentialOpen(true);
+    setCredentialLoading(true);
+    try {
+      const detail = await onLoadCredential(item.id);
+      if (!detail) {
+        setCredentialOpen(false);
+        return;
+      }
+      credentialForm.setFieldsValue({
+        username: detail.username,
+        password: detail.password || ''
+      });
+    } finally {
+      setCredentialLoading(false);
+    }
+  }
+
+  async function saveCredential() {
+    const values = await credentialForm.validateFields();
+    setCredentialLoading(true);
+    try {
+      const saved = await onSaveCredential(values, editingCredential?.id);
+      if (!saved) return;
+      setCredentialOpen(false);
+      credentialForm.resetFields();
+    } finally {
+      setCredentialLoading(false);
+    }
+  }
+
+  function closeCredentialModal() {
+    setCredentialOpen(false);
+    setEditingCredential(null);
+    credentialForm.resetFields();
   }
 
   function openCreateRelay() {
@@ -107,6 +159,50 @@ export default function SmtpCredentials({
     }
   }
 
+  const credentialColumns: ColumnsType<SmtpCredential> = [
+    {
+      title: t('smtp.username'),
+      dataIndex: 'username',
+      width: 240,
+      render: (value: string) => copyable(value, onCopy)
+    },
+    {
+      title: t('smtp.password'),
+      dataIndex: 'password',
+      width: 280,
+      render: (value: string | undefined) => (
+        value
+          ? copyable(value, onCopy)
+          : <Typography.Text type="secondary">{t('smtp.passwordUnavailable')}</Typography.Text>
+      )
+    },
+    {
+      title: t('common.status'),
+      dataIndex: 'passwordSet',
+      width: 120,
+      render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}</Tag>
+    },
+    {
+      title: t('tokens.createdAt'),
+      dataIndex: 'createdAt',
+      width: 190,
+      render: formatDate
+    },
+    {
+      title: t('domains.actions'),
+      fixed: 'right',
+      width: 150,
+      render: (_, item) => (
+        <Space>
+          <Button icon={<EditOutlined />} onClick={() => openEditCredential(item)} disabled={!item.id} />
+          <Popconfirm title={t('smtp.deleteConfirm')} onConfirm={() => onDeleteCredential(item)} disabled={!item.id}>
+            <Button danger icon={<DeleteOutlined />} disabled={!item.id} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
   const relayColumns: ColumnsType<SmtpRelay> = [
     {
       title: t('smtpRelay.name'),
@@ -161,34 +257,27 @@ export default function SmtpCredentials({
             {(config?.submission?.ports || []).map((item) => <Tag key={item.port}>{item.port} · {item.protocol}</Tag>)}
           </Descriptions.Item>
           <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
-          <Descriptions.Item label="Username">{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
-          <Descriptions.Item label="Password">
+          <Descriptions.Item label={t('smtp.username')}>{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
+          <Descriptions.Item label={t('smtp.password')}>
             {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
           </Descriptions.Item>
         </Descriptions>
       </Card>
-      <Card title={t('smtp.updateTitle')}>
-        <Form
-          form={form}
-          layout="vertical"
-          onFinish={onSave}
-          initialValues={{ username: credential?.username || config?.submission?.username || '' }}
-        >
-          <Form.Item name="username" label="Username" rules={[{ required: true, message: t('smtp.usernameRequired') }]}>
-            <Input autoComplete="off" />
-          </Form.Item>
-          <Form.Item name="password" label="Password" extra={t('smtp.passwordExtra')}>
-            <Input.Password autoComplete="new-password" />
-          </Form.Item>
-          <Space wrap>
-            <Button icon={<ReloadOutlined />} onClick={generatePassword}>
-              {t('smtp.regenerate')}
-            </Button>
-            <Button type="primary" htmlType="submit" loading={loading}>
-              {t('smtp.save')}
-            </Button>
-          </Space>
-        </Form>
+      <Card
+        title={t('smtp.loginCredentialsTitle')}
+        extra={
+          <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential}>
+            {t('smtp.create')}
+          </Button>
+        }
+      >
+        <Table
+          rowKey={(item) => item.id || item.username}
+          columns={credentialColumns}
+          dataSource={credentials}
+          scroll={{ x: 980 }}
+          pagination={credentials.length > 10 ? { pageSize: 10 } : false}
+        />
       </Card>
       <Card
         title={t('smtpRelay.title')}
@@ -206,6 +295,34 @@ export default function SmtpCredentials({
           pagination={false}
         />
       </Card>
+      <Modal
+        title={editingCredential ? t('smtp.editTitle') : t('smtp.createTitle')}
+        open={credentialOpen}
+        confirmLoading={loading || credentialLoading}
+        onCancel={closeCredentialModal}
+        onOk={saveCredential}
+        width={560}
+        destroyOnHidden
+      >
+        <Form form={credentialForm} layout="vertical">
+          <Form.Item name="username" label={t('smtp.username')} rules={[{ required: true, message: t('smtp.usernameRequired') }]}>
+            <Input autoComplete="off" />
+          </Form.Item>
+          <Form.Item
+            name="password"
+            label={t('smtp.password')}
+            extra={editingCredential ? t('smtp.passwordExtra') : undefined}
+            rules={[{ required: !editingCredential, message: t('smtp.passwordRequired') }]}
+          >
+            <Input autoComplete="new-password" />
+          </Form.Item>
+          <Form.Item>
+            <Button icon={<ReloadOutlined />} onClick={generateCredentialPassword}>
+              {t('smtp.regenerate')}
+            </Button>
+          </Form.Item>
+        </Form>
+      </Modal>
       <Modal
         title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
         open={relayOpen}
@@ -251,11 +368,21 @@ export default function SmtpCredentials({
   );
 }
 
+function randomPassword() {
+  const bytes = new Uint8Array(24);
+  crypto.getRandomValues(bytes);
+  return btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
+}
+
 function copyable(value: string, onCopy: (value: string) => void) {
   return (
     <Space>
-      <Typography.Text code>{value}</Typography.Text>
+      <Typography.Text code className="inline-code-value">{value}</Typography.Text>
       <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
     </Space>
   );
 }
+
+function formatDate(value?: string) {
+  return value ? new Date(value).toLocaleString() : '-';
+}

+ 58 - 0
src/server.js

@@ -16,6 +16,7 @@ import {
   deleteApiToken,
   deleteDnsCredential,
   deleteDomain,
+  deleteSmtpCredential,
   getAdminResourceInventory,
   getAdminUser,
   getDnsCredential,
@@ -36,6 +37,7 @@ import {
   listDnsCredentials,
   listDomains,
   listSendEvents,
+  listSmtpCredentials,
   listSmtpRelays,
   listUsersWithResourceCounts,
   logAudit,
@@ -62,6 +64,7 @@ import {
   verifyUserCredentials
 } from './db.js';
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
+import { startDnsAutoChecker } from './dns-auto-checker.js';
 import { startPostfixDeliveryTracker } from './delivery-tracker.js';
 import { buildDnsGuide } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
@@ -103,6 +106,9 @@ const envConfig = {
   postfixLogFile: process.env.POSTFIX_LOG_FILE || path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'postfix-logs', 'mail.log'),
   postfixLogPollIntervalMs: Number(process.env.POSTFIX_LOG_POLL_INTERVAL_MS || 5000),
   deliveryTrackingEnabled: String(process.env.DELIVERY_TRACKING_ENABLED || 'true').toLowerCase() !== 'false',
+  dnsAutoCheckEnabled: String(process.env.DNS_AUTO_CHECK_ENABLED || 'true').toLowerCase() !== 'false',
+  dnsAutoCheckIntervalMs: Number(process.env.DNS_AUTO_CHECK_INTERVAL_MS || 60000),
+  dnsAutoCheckLimit: Number(process.env.DNS_AUTO_CHECK_LIMIT || 25),
   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),
@@ -145,6 +151,12 @@ startPostfixDeliveryTracker({
   pollIntervalMs: envConfig.postfixLogPollIntervalMs
 });
 
+startDnsAutoChecker({
+  enabled: envConfig.dnsAutoCheckEnabled,
+  intervalMs: envConfig.dnsAutoCheckIntervalMs,
+  limit: envConfig.dnsAutoCheckLimit
+});
+
 const server = http.createServer(async (req, res) => {
   try {
     setSecurityHeaders(res);
@@ -259,7 +271,9 @@ async function handleApi(req, res, url, user) {
   if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
     const body = await readJson(req);
     try {
+      const current = getSmtpCredential(user.id);
       saveSmtpCredential(user.id, {
+        id: current?.id || null,
         username: String(body.username || '').trim(),
         password: String(body.password || '')
       });
@@ -269,6 +283,50 @@ async function handleApi(req, res, url, user) {
     }
     return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
   }
+  if (method === 'GET' && pathname === '/api/smtp-credentials') {
+    return sendJson(res, 200, { credentials: listSmtpCredentials(user.id, { includePassword: true }) });
+  }
+  if (method === 'POST' && pathname === '/api/smtp-credentials') {
+    const body = await readJson(req);
+    try {
+      const credential = saveSmtpCredential(user.id, {
+        username: String(body.username || '').trim(),
+        password: String(body.password || '')
+      });
+      return sendJson(res, 201, { credential: getSmtpCredential(credential.id, user.id, { includePassword: true }) });
+    } catch (error) {
+      if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
+      throw error;
+    }
+  }
+  const smtpCredentialMatch = pathname.match(/^\/api\/smtp-credentials\/(\d+)$/);
+  if (smtpCredentialMatch) {
+    const id = Number(smtpCredentialMatch[1]);
+    if (method === 'GET') {
+      const credential = getSmtpCredential(id, user.id, { includePassword: true });
+      return sendJson(res, credential ? 200 : 404, { credential });
+    }
+    if (method === 'PATCH' || method === 'PUT') {
+      const body = await readJson(req);
+      try {
+        const credential = saveSmtpCredential(user.id, {
+          id,
+          username: String(body.username || '').trim(),
+          password: String(body.password || '')
+        });
+        return sendJson(res, credential ? 200 : 404, {
+          credential: credential ? getSmtpCredential(credential.id, user.id, { includePassword: true }) : null
+        });
+      } catch (error) {
+        if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
+        throw error;
+      }
+    }
+    if (method === 'DELETE') {
+      const deleted = deleteSmtpCredential(id, user.id);
+      return sendJson(res, deleted ? 200 : 404, { deleted });
+    }
+  }
   if (method === 'GET' && pathname === '/api/smtp-relays') {
     return sendJson(res, 200, { relays: listSmtpRelays(user.id) });
   }

+ 35 - 9
test/db.test.js

@@ -14,6 +14,7 @@ import {
   createDomain,
   createUser,
   createUserWithAccountToken,
+  deleteSmtpCredential,
   deleteSmtpRelay,
   getDnsCredential,
   getDomain,
@@ -27,6 +28,7 @@ import {
   listAuditLogs,
   listDomains,
   listSendEvents,
+  listSmtpCredentials,
   listSmtpRelays,
   listUsersWithResourceCounts,
   invalidateAccountTokens,
@@ -155,6 +157,28 @@ test('isolates domains, smtp credentials, and api tokens by user', () => {
   assert.equal(verifyApiToken(token.token).id, alice.id);
 });
 
+test('stores multiple smtp login credentials per user', () => {
+  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 first = saveSmtpCredential(alice.id, { username: 'alice-main', password: 'first-secret' });
+  const second = saveSmtpCredential(alice.id, { username: 'alice-app', password: 'second-secret' });
+  saveSmtpCredential(bob.id, { username: 'bob-main', password: 'bob-secret' });
+
+  assert.equal(first.passwordSet, true);
+  assert.equal(second.passwordSet, true);
+  assert.deepEqual(listSmtpCredentials(alice.id).map((credential) => credential.username), ['alice-app', 'alice-main']);
+  assert.equal(getSmtpCredential(first.id, alice.id, { includePassword: true }).password, 'first-secret');
+  assert.equal(getSmtpCredential(first.id, bob.id), null);
+  assert.equal(verifySmtpCredential('alice-main', 'first-secret').user.id, alice.id);
+  assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
+  assert.equal(verifySmtpCredential('alice-app', 'wrong'), null);
+  assert.equal(deleteSmtpCredential(first.id, alice.id), true);
+  assert.equal(verifySmtpCredential('alice-main', 'first-secret'), null);
+  assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
+});
+
 test('stores multiple outbound smtp relays with encrypted recoverable passwords', () => {
   const database = initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
@@ -419,6 +443,7 @@ test('lists users with owned resource counts', () => {
   createApiToken(alice.id, 'secondary');
   createApiToken(bob.id, 'primary');
   saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
+  saveSmtpCredential(alice.id, { username: 'smtp-alice-app', password: 'smtp-secret-456' });
 
   logSendEvent({
     userId: alice.id,
@@ -454,7 +479,7 @@ test('lists users with owned resource counts', () => {
     dnsCredentials: 2,
     apiTokens: 2,
     sendEvents: 2,
-    smtpCredential: 1
+    smtpCredential: 2
   });
   assert.deepEqual(bobWithCounts.resourceCounts, {
     domains: 1,
@@ -621,7 +646,7 @@ test('transfers individual resources with audit logs', () => {
   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', () => {
+test('previews and executes user merge with resource counts and multiple smtp credentials', () => {
   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' });
@@ -635,6 +660,7 @@ test('previews and executes user merge with resource counts and smtp conflict ha
   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(source.id, { username: 'smtp-source-app', password: 'source-secret-456' });
   saveSmtpCredential(target.id, { username: 'smtp-target', password: 'target-secret-123' });
   logSendEvent({
     userId: source.id,
@@ -652,7 +678,7 @@ test('previews and executes user merge with resource counts and smtp conflict ha
     dnsCredentials: 1,
     apiTokens: 1,
     sendEvents: 1,
-    smtpCredential: 1
+    smtpCredential: 2
   });
   assert.equal(preview.resources.source.domains[0].domain, 'source.example');
   assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
@@ -664,10 +690,10 @@ test('previews and executes user merge with resource counts and smtp conflict ha
     dnsCredentials: 1,
     apiTokens: 1,
     sendEvents: 1,
-    smtpCredential: 0
+    smtpCredential: 2
   });
-  assert.equal(preview.smtp.conflict, true);
-  assert.ok(preview.warnings.some((warning) => warning.type === 'smtp_credential_conflict'));
+  assert.equal(preview.smtp.conflict, false);
+  assert.deepEqual(preview.warnings, []);
 
   assert.throws(
     () => executeUserMerge({
@@ -692,14 +718,14 @@ test('previews and executes user merge with resource counts and smtp conflict ha
     dnsCredentials: 1,
     apiTokens: 1,
     sendEvents: 1,
-    smtpCredential: 0
+    smtpCredential: 2
   });
   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.deepEqual(listSmtpCredentials(target.id).map((item) => item.username).sort(), ['smtp-source', 'smtp-source-app', 'smtp-target']);
+  assert.deepEqual(listSmtpCredentials(source.id), []);
   assert.equal(getUser(source.id).status, 'disabled');
 
   const [audit] = listAuditLogs({ action: 'admin.user_merge' });

+ 55 - 0
test/dns-auto-checker.test.js

@@ -0,0 +1,55 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { runDnsAutoCheck, shouldAutoCheckDomain } from '../src/dns-auto-checker.js';
+
+test('dns auto-check selects unchecked, stale, and unverified domains only', () => {
+  const now = new Date('2026-07-09T03:00:00.000Z');
+
+  assert.equal(shouldAutoCheckDomain({ status: {} }, { now, minIntervalMs: 60000 }), true);
+  assert.equal(shouldAutoCheckDomain({
+    status: { verified: false, checkedAt: '2026-07-09T02:58:30.000Z' }
+  }, { now, minIntervalMs: 60000 }), true);
+  assert.equal(shouldAutoCheckDomain({
+    status: { verified: false, checkedAt: '2026-07-09T02:59:30.000Z' }
+  }, { now, minIntervalMs: 60000 }), false);
+  assert.equal(shouldAutoCheckDomain({
+    status: { verified: true, checkedAt: '2026-07-01T00:00:00.000Z' }
+  }, { now, minIntervalMs: 60000 }), false);
+});
+
+test('dns auto-check refreshes eligible domains and continues after failures', async () => {
+  const saved = [];
+  const warnings = [];
+  const domains = [
+    { id: 1, userId: 10, domain: 'ready.example', status: {} },
+    {
+      id: 2,
+      userId: 10,
+      domain: 'fresh.example',
+      status: { verified: false, checkedAt: '2026-07-09T02:59:30.000Z' }
+    },
+    { id: 3, userId: 11, domain: 'broken.example', status: {} }
+  ];
+
+  const result = await runDnsAutoCheck({
+    listDomains: () => domains,
+    buildGuide: async (domain) => {
+      if (domain.domain === 'broken.example') throw new Error('DNS timeout');
+      return { checkedAt: '2026-07-09T03:00:00.000Z', verified: true, records: [] };
+    },
+    saveStatus: (id, userId, status) => saved.push({ id, userId, status }),
+    logger: { warn: (message) => warnings.push(message) },
+    now: () => new Date('2026-07-09T03:00:00.000Z'),
+    minIntervalMs: 60000,
+    limit: 10
+  });
+
+  assert.deepEqual(saved.map((item) => item.id), [1]);
+  assert.equal(saved[0].userId, 10);
+  assert.equal(saved[0].status.verified, true);
+  assert.equal(result.checked, 1);
+  assert.equal(result.failed, 1);
+  assert.equal(result.skipped, 1);
+  assert.equal(warnings.length, 1);
+  assert.match(warnings[0], /broken\.example/);
+});

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

@@ -54,3 +54,13 @@ test('translates admin panel labels', () => {
   assert.equal(zh('admin.auditLogs'), '审计日志');
   assert.equal(en('admin.title'), 'Admin Panel');
 });
+
+test('translates smtp login credential labels separately from outbound relays', () => {
+  const zh = createTranslator('zh-CN');
+  const en = createTranslator('en-US');
+
+  assert.equal(zh('smtp.loginCredentialsTitle'), '发信登录凭据');
+  assert.equal(zh('smtpRelay.title'), '高级:外部 SMTP 出口');
+  assert.equal(en('smtp.loginCredentialsTitle'), 'Sending login credentials');
+  assert.equal(en('smtpRelay.title'), 'Advanced: external SMTP relays');
+});

+ 61 - 0
test/server-admin-api.test.js

@@ -97,6 +97,53 @@ test('auth pages preserve query messages instead of redirecting them away', asyn
   }
 });
 
+test('users can manage multiple smtp login credentials', async () => {
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    const first = await createSmtpCredential(baseUrl, cookie, {
+      username: 'admin-smtp-main',
+      password: 'main-secret'
+    });
+    const second = await createSmtpCredential(baseUrl, cookie, {
+      username: 'admin-smtp-app',
+      password: 'app-secret'
+    });
+
+    assert.equal(first.username, 'admin-smtp-main');
+    assert.equal(first.password, 'main-secret');
+    assert.equal(second.username, 'admin-smtp-app');
+
+    const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
+    assert.equal(list.status, 200);
+    const listPayload = await list.json();
+    assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
+    assert.equal(listPayload.credentials[0].password, 'app-secret');
+
+    const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
+    });
+    assert.equal(update.status, 200);
+    assert.equal((await update.json()).credential.password, 'app-secret');
+
+    const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
+      method: 'DELETE',
+      headers: { Cookie: cookie }
+    });
+    assert.equal(deleted.status, 200);
+    assert.equal((await deleted.json()).deleted, true);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
 test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
   const relayServer = await startFakeSmtpServer();
   const { child, baseUrl } = await startTestServer();
@@ -1244,6 +1291,7 @@ async function startTestServer() {
       DATA_DIR: dataDir,
       ADMIN_PASSWORD: 'password123',
       SESSION_SECRET: sessionSecret,
+      DNS_AUTO_CHECK_ENABLED: 'false',
       SUBMISSION_ENABLED: 'false'
     },
     stdio: ['ignore', 'pipe', 'pipe']
@@ -1622,6 +1670,19 @@ async function createSmtpRelay(baseUrl, cookie, data = {}) {
   return (await response.json()).relay;
 }
 
+async function createSmtpCredential(baseUrl, cookie, data = {}) {
+  const response = await fetch(`${baseUrl}/api/smtp-credentials`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Cookie: cookie
+    },
+    body: JSON.stringify(data)
+  });
+  assert.equal(response.status, 201);
+  return (await response.json()).credential;
+}
+
 async function sendApiMail(baseUrl, cookie, data) {
   const response = await fetch(`${baseUrl}/api/send`, {
     method: 'POST',

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است