Просмотр исходного кода

feat: add outbound smtp relays

AI-Co-Authored-By: Codex
chendeben 1 месяц назад
Родитель
Сommit
01636ee411

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
public/assets/index-CDzCP_eL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/assets/login-BkDwyZ5o.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/assets/styles-B6ac5KuF.js


+ 2 - 2
public/index.html

@@ -4,8 +4,8 @@
     <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-CKrGl1I_.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CNZcrHZf.js">
+    <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">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 2 - 2
public/login.html

@@ -4,8 +4,8 @@
     <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-BjasTpWn.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CNZcrHZf.js">
+    <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">
   </head>
   <body>

+ 22 - 2
src/components/domain/AddDomainDrawer.tsx

@@ -3,13 +3,14 @@ import { Button, Checkbox, Drawer, Form, Input, Select, Space, Steps, Typography
 import { useEffect, useState } from 'react';
 
 import { useI18n } from '../../frontend/i18n/react';
-import type { AddDomainPayload, DnsCredential, RuntimeConfig } from '../../frontend/types';
+import type { AddDomainPayload, DnsCredential, RuntimeConfig, SmtpRelay } from '../../frontend/types';
 
 interface AddDomainDrawerProps {
   open: boolean;
   loading?: boolean;
   config: RuntimeConfig | null;
   dnsCredentials: DnsCredential[];
+  smtpRelays: SmtpRelay[];
   onClose: () => void;
   onSubmit: (values: AddDomainPayload) => Promise<void>;
 }
@@ -19,6 +20,7 @@ export function AddDomainDrawer({
   loading,
   config,
   dnsCredentials,
+  smtpRelays,
   onClose,
   onSubmit
 }: AddDomainDrawerProps) {
@@ -116,6 +118,20 @@ export function AddDomainDrawer({
             <Typography.Paragraph type="secondary">
               {t('addDomain.dnsHint')}
             </Typography.Paragraph>
+            <Form.Item
+              name="smtpRelayId"
+              label={t('smtpRelay.domainDefault')}
+              extra={t('smtpRelay.domainDefaultExtra')}
+            >
+              <Select
+                allowClear
+                placeholder={t('smtpRelay.useResolutionOrder')}
+                options={smtpRelays.map((relay) => ({
+                  value: relay.id,
+                  label: relayLabel(relay, t)
+                }))}
+              />
+            </Form.Item>
           </div>
           <div hidden={current !== 2}>
             <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
@@ -156,7 +172,7 @@ export function AddDomainDrawer({
 
 function stepFields(step: number): Array<keyof AddDomainPayload> {
   if (step === 0) return ['domain', 'senderHost', 'sendingIp'];
-  if (step === 1) return ['dnsCredentialId'];
+  if (step === 1) return ['dnsCredentialId', 'smtpRelayId'];
   if (step === 2) return ['selector', 'dmarcPolicy', 'spfExtra'];
   return [];
 }
@@ -173,3 +189,7 @@ function providerLabel(provider: string) {
     dnspod: 'Tencent DNSPod'
   }[provider] || provider;
 }
+
+function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
+  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+}

+ 134 - 5
src/db.js

@@ -90,6 +90,22 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS smtp_relays (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      name TEXT NOT NULL,
+      host TEXT NOT NULL,
+      port INTEGER NOT NULL DEFAULT 587,
+      secure TEXT NOT NULL DEFAULT 'false',
+      username TEXT NOT NULL DEFAULT '',
+      password_secret TEXT NOT NULL DEFAULT '',
+      helo TEXT NOT NULL DEFAULT '',
+      is_default TEXT NOT NULL DEFAULT 'false',
+      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,
@@ -142,7 +158,9 @@ export function initDatabase(dataDir, secret = '') {
   `);
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
+  ensureColumn('domains', 'smtp_relay_id', 'INTEGER');
   ensureColumn('send_events', 'user_id', 'INTEGER');
+  ensureColumn('send_events', 'smtp_relay_id', 'INTEGER');
   ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
   ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
@@ -150,8 +168,11 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
+    CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
     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_relays_user_id ON smtp_relays(user_id);
   `);
   normalizeSendEventQueueIds();
   normalizeDkimPublicKeys();
@@ -601,14 +622,15 @@ export function createDomain(userId, domain) {
   const result = requireDb()
     .prepare(`
       INSERT INTO domains (
-        user_id, dns_credential_id, domain, selector, verification_token,
+        user_id, dns_credential_id, smtp_relay_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
     `)
     .run(
       userId,
       domain.dnsCredentialId || null,
+      domain.smtpRelayId || null,
       domain.domain,
       domain.selector,
       domain.verificationToken,
@@ -631,6 +653,7 @@ export function updateDomain(id, userId, patch) {
   const next = {
     selector: patch.selector ?? current.selector,
     dnsCredentialId: patch.dnsCredentialId === undefined ? current.dnsCredentialId : (patch.dnsCredentialId || null),
+    smtpRelayId: patch.smtpRelayId === undefined ? current.smtpRelayId : (patch.smtpRelayId || null),
     senderHost: patch.senderHost ?? current.senderHost,
     sendingIp: patch.sendingIp ?? current.sendingIp,
     spfExtra: patch.spfExtra ?? current.spfExtra,
@@ -641,13 +664,14 @@ export function updateDomain(id, userId, patch) {
   requireDb()
     .prepare(`
       UPDATE domains
-      SET selector = ?, dns_credential_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
+      SET selector = ?, dns_credential_id = ?, smtp_relay_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
           dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
       WHERE id = ? AND user_id = ?
     `)
     .run(
       next.selector,
       next.dnsCredentialId,
+      next.smtpRelayId,
       next.senderHost,
       next.sendingIp,
       next.spfExtra,
@@ -720,14 +744,15 @@ export function logSendEvent(event) {
   const result = requireDb()
     .prepare(`
       INSERT INTO send_events (
-        user_id, domain_id, sender, recipients, subject, status, detail, queue_id,
+        user_id, domain_id, smtp_relay_id, sender, recipients, subject, status, detail, queue_id,
         delivery_log_json, delivery_attempts_json, delivered_at, created_at
       )
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       event.userId ?? null,
       event.domainId ?? null,
+      event.smtpRelayId ?? null,
       event.sender,
       JSON.stringify(event.recipients),
       event.subject,
@@ -779,6 +804,7 @@ export function listSendEvents(userId, limit = 30) {
       id: row.id,
       userId: row.user_id,
       domainId: row.domain_id,
+      smtpRelayId: row.smtp_relay_id,
       domain: row.domain,
       sender: row.sender,
       recipients: safeJson(row.recipients, []),
@@ -936,6 +962,87 @@ export function saveSmtpCredential(userId, { username, password }) {
   return getSmtpCredential(userId);
 }
 
+export function listSmtpRelays(userId, { includePassword = false, includeSecret = false } = {}) {
+  return requireDb()
+    .prepare('SELECT * FROM smtp_relays WHERE user_id = ? ORDER BY is_default DESC, created_at DESC')
+    .all(userId)
+    .map((row) => publicSmtpRelay(row, { includePassword, includeSecret }));
+}
+
+export function getSmtpRelay(id, userId, { includePassword = false, includeSecret = false } = {}) {
+  const row = requireDb()
+    .prepare('SELECT * FROM smtp_relays WHERE id = ? AND user_id = ?')
+    .get(Number(id), userId);
+  return publicSmtpRelay(row, { includePassword, includeSecret });
+}
+
+export function getDefaultSmtpRelay(userId, { includePassword = false, includeSecret = false } = {}) {
+  const row = requireDb()
+    .prepare("SELECT * FROM smtp_relays WHERE user_id = ? AND is_default = 'true' ORDER BY updated_at DESC LIMIT 1")
+    .get(userId);
+  return publicSmtpRelay(row, { includePassword, includeSecret });
+}
+
+export function saveSmtpRelay(userId, relay = {}) {
+  const current = relay.id ? getSmtpRelay(relay.id, userId, { includeSecret: true }) : null;
+  if (relay.id && !current) return null;
+  const name = String(relay.name ?? current?.name ?? '').trim() || 'SMTP Relay';
+  const host = String(relay.host ?? current?.host ?? '').trim();
+  if (!host) throw new Error('SMTP Host 不能为空。');
+  const port = normalizePort(relay.port ?? current?.port, 587);
+  const secure = boolString(relay.secure ?? current?.secure ?? false);
+  const username = String(relay.username ?? current?.username ?? '').trim();
+  const passwordSecret = Object.hasOwn(relay, 'password')
+    ? encryptSecret(relay.password)
+    : current?.passwordSecret || '';
+  const helo = String(relay.helo ?? current?.helo ?? '').trim();
+  const isDefault = boolString(relay.isDefault ?? current?.isDefault ?? false);
+  const updatedAt = now();
+
+  return withTransaction(() => {
+    if (isDefault === 'true') clearDefaultSmtpRelay(userId);
+    if (current) {
+      requireDb()
+        .prepare(`
+          UPDATE smtp_relays
+          SET name = ?, host = ?, port = ?, secure = ?, username = ?, password_secret = ?,
+              helo = ?, is_default = ?, updated_at = ?
+          WHERE id = ? AND user_id = ?
+        `)
+        .run(name, host, port, secure, username, passwordSecret, helo, isDefault, updatedAt, current.id, userId);
+      return getSmtpRelay(current.id, userId);
+    }
+    const result = requireDb()
+      .prepare(`
+        INSERT INTO smtp_relays (
+          user_id, name, host, port, secure, username, password_secret,
+          helo, is_default, created_at, updated_at
+        )
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      `)
+      .run(userId, name, host, port, secure, username, passwordSecret, helo, isDefault, updatedAt, updatedAt);
+    return getSmtpRelay(result.lastInsertRowid, userId);
+  });
+}
+
+export function deleteSmtpRelay(id, userId) {
+  return withTransaction(() => {
+    requireDb()
+      .prepare('UPDATE domains SET smtp_relay_id = NULL WHERE smtp_relay_id = ? AND user_id = ?')
+      .run(Number(id), userId);
+    const result = requireDb()
+      .prepare('DELETE FROM smtp_relays WHERE id = ? AND user_id = ?')
+      .run(Number(id), userId);
+    return result.changes > 0;
+  });
+}
+
+function clearDefaultSmtpRelay(userId) {
+  requireDb()
+    .prepare("UPDATE smtp_relays SET is_default = 'false', updated_at = ? WHERE user_id = ?")
+    .run(now(), userId);
+}
+
 export function verifySmtpCredential(username, password) {
   const row = requireDb()
     .prepare(`
@@ -1369,6 +1476,7 @@ function publicDomainRow(row) {
     id: row.id,
     userId: row.user_id,
     dnsCredentialId: row.dns_credential_id,
+    smtpRelayId: row.smtp_relay_id,
     domain: row.domain,
     selector: row.selector,
     verificationToken: row.verification_token,
@@ -1407,6 +1515,27 @@ function publicSmtpCredential(row, { includeHash = false, includePassword = fals
   };
 }
 
+function publicSmtpRelay(row, { includePassword = false, includeSecret = false } = {}) {
+  if (!row) return null;
+  const password = includePassword ? decryptSecret(row.password_secret) : '';
+  return {
+    id: row.id,
+    userId: row.user_id,
+    name: row.name,
+    host: row.host,
+    port: Number(row.port || 587),
+    secure: row.secure === 'true',
+    username: row.username,
+    passwordSet: Boolean(row.password_secret),
+    helo: row.helo,
+    isDefault: row.is_default === 'true',
+    ...(includePassword ? { password } : {}),
+    ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
 function publicApiToken(row) {
   if (!row) return null;
   return {

+ 59 - 3
src/frontend/App.tsx

@@ -1,4 +1,4 @@
-import { App as AntApp, ConfigProvider, Form, Input, Modal } from 'antd';
+import { App as AntApp, ConfigProvider, Form, Input, Modal, Select } from 'antd';
 import { useEffect, useMemo, useState } from 'react';
 
 import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
@@ -26,6 +26,8 @@ import type {
   DomainMode,
   DomainPatchPayload,
   RuntimeConfig,
+  SmtpRelay,
+  SmtpRelayPayload,
   User,
   ViewKey
 } from './types';
@@ -37,6 +39,7 @@ const emptyData: AppData = {
   events: [],
   analytics: null,
   smtpCredential: null,
+  smtpRelays: [],
   dnsCredentials: [],
   apiTokens: [],
   settings: null,
@@ -109,12 +112,13 @@ function MailHubConsole() {
     setLoading(true);
     try {
       const me = await api.me();
-      const [config, domains, events, analytics, smtpCredential, dnsCredentials, apiTokens] = await Promise.all([
+      const [config, domains, events, analytics, smtpCredential, smtpRelays, dnsCredentials, apiTokens] = await Promise.all([
         api.config(),
         api.domains(),
         api.events(),
         api.analytics(30),
         api.smtpCredential(),
+        api.smtpRelays(),
         api.dnsCredentials(),
         api.apiTokens()
       ]);
@@ -132,6 +136,7 @@ function MailHubConsole() {
         events: events.events || [],
         analytics: analytics.analytics || null,
         smtpCredential: smtpCredential.credential || null,
+        smtpRelays: smtpRelays.relays || [],
         dnsCredentials: dnsCredentials.credentials || [],
         apiTokens: apiTokens.tokens || [],
         settings,
@@ -243,7 +248,8 @@ function MailHubConsole() {
     testForm.setFieldsValue({
       from: `noreply@${domain.domain}`,
       subject: `MailHub test for ${domain.domain}`,
-      text: `This is a MailHub test message from ${domain.domain}.`
+      text: `This is a MailHub test message from ${domain.domain}.`,
+      smtpRelayId: domain.smtpRelayId || undefined
     });
   }
 
@@ -306,6 +312,36 @@ function MailHubConsole() {
     }));
   }
 
+  async function loadSmtpRelay(id: number) {
+    const result = await runAction(async () => api.smtpRelay(id));
+    return result?.relay || null;
+  }
+
+  async function saveSmtpRelay(values: SmtpRelayPayload, id?: number) {
+    const result = await runAction(
+      async () => api.saveSmtpRelay(values, id),
+      id ? t('actions.smtpRelayUpdated') : t('actions.smtpRelayCreated')
+    );
+    if (!result?.relay) return null;
+    setData((current) => ({
+      ...current,
+      smtpRelays: id
+        ? current.smtpRelays.map((item) => item.id === id ? result.relay : item)
+        : [result.relay, ...current.smtpRelays]
+    }));
+    return result.relay;
+  }
+
+  async function deleteSmtpRelay(relay: SmtpRelay) {
+    const result = await runAction(async () => api.deleteSmtpRelay(relay.id), t('actions.smtpRelayDeleted'));
+    if (!result?.deleted) return;
+    setData((current) => ({
+      ...current,
+      smtpRelays: current.smtpRelays.filter((item) => item.id !== relay.id),
+      domains: current.domains.map((domain) => domain.smtpRelayId === relay.id ? { ...domain, smtpRelayId: null } : domain)
+    }));
+  }
+
   async function createApiToken(name: string) {
     const result = await runAction(async () => api.createApiToken(name), t('tokens.createdSuccess'));
     if (!result?.token) return null;
@@ -364,6 +400,7 @@ function MailHubConsole() {
         loading={actionLoading}
         config={data.config}
         dnsCredentials={data.dnsCredentials}
+        smtpRelays={data.smtpRelays}
         onClose={() => setAddOpen(false)}
         onSubmit={createDomain}
       />
@@ -387,6 +424,16 @@ function MailHubConsole() {
           <Form.Item name="text" label="Text">
             <Input.TextArea rows={5} />
           </Form.Item>
+          <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
+            <Select
+              allowClear
+              placeholder={t('smtpRelay.useResolutionOrder')}
+              options={data.smtpRelays.map((relay) => ({
+                value: relay.id,
+                label: relayLabel(relay, t)
+              }))}
+            />
+          </Form.Item>
         </Form>
       </Modal>
     </>
@@ -415,6 +462,7 @@ function MailHubConsole() {
             apiTokens={data.apiTokens}
             events={data.events}
             dnsCredentials={data.dnsCredentials}
+            smtpRelays={data.smtpRelays}
             actionLoading={actionLoading}
             initialTab={initialDomainTab}
             onBack={() => setDomainMode('list')}
@@ -458,9 +506,13 @@ function MailHubConsole() {
         <SmtpCredentials
           config={data.config}
           credential={data.smtpCredential}
+          relays={data.smtpRelays}
           loading={actionLoading}
           onCopy={copy}
           onSave={saveSmtpCredential}
+          onLoadRelay={loadSmtpRelay}
+          onSaveRelay={saveSmtpRelay}
+          onDeleteRelay={deleteSmtpRelay}
         />
       );
     }
@@ -496,3 +548,7 @@ function MailHubConsole() {
     return <PlaceholderPage title={t(viewTitleKeys[activeView])} />;
   }
 }
+
+function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
+  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+}

+ 50 - 2
src/frontend/i18n/index.js

@@ -195,10 +195,31 @@ const messages = {
     'smtp.connectionTitle': 'SMTP 连接信息',
     'smtp.updateTitle': '更新 SMTP 凭据',
     'smtp.usernameRequired': '请输入 SMTP Username',
-    'smtp.passwordExtra': '留空保存时会保留原密码;旧密码无法回显时请重新生成。',
+    'smtp.passwordExtra': '留空保存时会保留原密码;当前密码可在上方连接信息中复制。',
     'smtp.regenerate': '重新生成密码',
     'smtp.save': '保存 SMTP 凭据',
     'smtp.resetToCopy': '请重新设置后复制',
+    'smtpRelay.title': '发信 SMTP 出口',
+    'smtpRelay.create': '新增出口',
+    'smtpRelay.createTitle': '新增发信 SMTP 出口',
+    'smtpRelay.editTitle': '编辑发信 SMTP 出口',
+    'smtpRelay.name': '名称',
+    'smtpRelay.nameRequired': '请输入出口名称',
+    'smtpRelay.server': '服务器',
+    'smtpRelay.hostRequired': '请输入 SMTP Host',
+    'smtpRelay.portRequired': '请输入 SMTP Port',
+    'smtpRelay.username': '用户名',
+    'smtpRelay.password': '密码',
+    'smtpRelay.passwordSet': '已设置',
+    'smtpRelay.passwordEmpty': '未设置',
+    'smtpRelay.passwordExtra': '编辑时会回显已保存密码;清空该字段保存会移除密码。',
+    'smtpRelay.generatePassword': '生成密码',
+    'smtpRelay.heloExtra': '留空时使用系统默认 HELO。',
+    'smtpRelay.default': '默认',
+    'smtpRelay.domainDefault': '默认发信 SMTP 出口',
+    'smtpRelay.domainDefaultExtra': '未选择时按请求指定、用户默认出口、系统全局 SMTP 的顺序选择。',
+    'smtpRelay.useResolutionOrder': '按默认选择顺序',
+    'smtpRelay.deleteConfirm': '确认删除该 SMTP 出口?绑定到该出口的域名会改为按默认顺序选择。',
     'dnsApi.title': 'DNS API 凭据',
     'dnsApi.createTitle': '新增 DNS API',
     'dnsApi.editTitle': '编辑',
@@ -284,6 +305,9 @@ const messages = {
     'actions.dnsApiDeleted': 'DNS API 已删除',
     'actions.dnsApiTestCompleted': '连接测试完成',
     'actions.smtpSaved': 'SMTP 凭据已保存',
+    'actions.smtpRelayCreated': 'SMTP 出口已新增',
+    'actions.smtpRelayUpdated': 'SMTP 出口已更新',
+    'actions.smtpRelayDeleted': 'SMTP 出口已删除',
     'actions.settingsSaved': '系统设置已保存',
     'nav.dashboard': '仪表盘',
     'nav.domains': '发信域名',
@@ -488,10 +512,31 @@ const messages = {
     'smtp.connectionTitle': 'SMTP connection',
     'smtp.updateTitle': 'Update SMTP credential',
     'smtp.usernameRequired': 'Enter SMTP username',
-    'smtp.passwordExtra': 'Leave empty to keep the current password. Reset old hidden passwords before copying.',
+    '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.create': 'New relay',
+    'smtpRelay.createTitle': 'New outbound SMTP relay',
+    'smtpRelay.editTitle': 'Edit outbound SMTP relay',
+    'smtpRelay.name': 'Name',
+    'smtpRelay.nameRequired': 'Enter the relay name',
+    'smtpRelay.server': 'Server',
+    'smtpRelay.hostRequired': 'Enter SMTP Host',
+    'smtpRelay.portRequired': 'Enter SMTP Port',
+    'smtpRelay.username': 'Username',
+    'smtpRelay.password': 'Password',
+    'smtpRelay.passwordSet': 'Set',
+    'smtpRelay.passwordEmpty': 'Empty',
+    'smtpRelay.passwordExtra': 'Saved passwords are shown when editing. Save an empty value to remove the password.',
+    'smtpRelay.generatePassword': 'Generate password',
+    'smtpRelay.heloExtra': 'Leave empty to use the system default HELO.',
+    'smtpRelay.default': 'Default',
+    'smtpRelay.domainDefault': 'Default outbound SMTP relay',
+    'smtpRelay.domainDefaultExtra': 'When empty, MailHub uses request relay, user default relay, then system SMTP.',
+    'smtpRelay.useResolutionOrder': 'Use default selection order',
+    'smtpRelay.deleteConfirm': 'Delete this SMTP relay? Bound domains will fall back to the default selection order.',
     'dnsApi.title': 'DNS API credentials',
     'dnsApi.createTitle': 'New DNS API',
     'dnsApi.editTitle': 'Edit',
@@ -577,6 +622,9 @@ const messages = {
     'actions.dnsApiDeleted': 'DNS API deleted',
     'actions.dnsApiTestCompleted': 'Connection test completed',
     'actions.smtpSaved': 'SMTP credential saved',
+    'actions.smtpRelayCreated': 'SMTP relay created',
+    'actions.smtpRelayUpdated': 'SMTP relay updated',
+    'actions.smtpRelayDeleted': 'SMTP relay deleted',
     'actions.settingsSaved': 'System settings saved',
     'nav.dashboard': 'Dashboard',
     'nav.domains': 'Domains',

+ 12 - 1
src/frontend/services/api.ts

@@ -11,6 +11,8 @@ import type {
   RuntimeConfig,
   SendEvent,
   SmtpCredential,
+  SmtpRelay,
+  SmtpRelayPayload,
   SystemEmailSettings,
   User,
   UserMergeOptions,
@@ -91,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 }),
+  smtpRelays: () => request<{ relays: SmtpRelay[] }>('/api/smtp-relays'),
+  smtpRelay: (id: number) => request<{ relay: SmtpRelay }>(`/api/smtp-relays/${id}`),
+  saveSmtpRelay: (data: SmtpRelayPayload, id?: number) =>
+    request<{ relay: SmtpRelay }>(id ? `/api/smtp-relays/${id}` : '/api/smtp-relays', {
+      method: id ? 'PATCH' : 'POST',
+      data
+    }),
+  deleteSmtpRelay: (id: number) =>
+    request<{ deleted: boolean }>(`/api/smtp-relays/${id}`, { method: 'DELETE' }),
   dnsCredentials: () => request<{ credentials: DnsCredential[] }>('/api/dns-credentials'),
   saveDnsCredential: (data: Record<string, unknown>, id?: number) =>
     request<{ credential: DnsCredential }>(id ? `/api/dns-credentials/${id}` : '/api/dns-credentials', {
@@ -115,7 +126,7 @@ export const api = {
     request<{ domain: Domain; apply?: Domain['status']['apply'] }>(`/api/domains/${id}/apply-dns`, { method: 'POST' }),
   rotateDkim: (id: number, selector?: string) =>
     request<{ domain: Domain }>(`/api/domains/${id}/rotate-dkim`, { method: 'POST', data: { selector } }),
-  sendTest: (id: number, data: { from?: string; to: string; subject?: string; text?: string }) =>
+  sendTest: (id: number, data: { from?: string; to: string; subject?: string; text?: string; smtpRelayId?: number | string | null }) =>
     request<{ queued: boolean }>(`/api/domains/${id}/test-send`, { method: 'POST', data }),
   deleteDomain: (id: number) => request<{ deleted: boolean }>(`/api/domains/${id}`, { method: 'DELETE' }),
   adminSettings: () => request<{ settings: RuntimeConfig }>('/api/admin/settings'),

+ 32 - 0
src/frontend/types.ts

@@ -94,6 +94,7 @@ export interface Domain {
   id: number;
   userId: number;
   dnsCredentialId: number | null;
+  smtpRelayId: number | null;
   domain: string;
   selector: string;
   verificationToken: string;
@@ -131,6 +132,33 @@ export interface SmtpCredential {
   updatedAt?: string;
 }
 
+export interface SmtpRelay {
+  id: number;
+  userId?: number;
+  name: string;
+  host: string;
+  port: number;
+  secure: boolean;
+  username: string;
+  password?: string;
+  passwordSet: boolean;
+  helo: string;
+  isDefault: boolean;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface SmtpRelayPayload {
+  name: string;
+  host: string;
+  port: number | string;
+  secure?: boolean;
+  username?: string;
+  password?: string;
+  helo?: string;
+  isDefault?: boolean;
+}
+
 export interface SystemEmailSettings {
   host: string;
   port: number;
@@ -249,6 +277,7 @@ export interface SendEvent {
   id: number;
   userId: number;
   domainId: number | null;
+  smtpRelayId: number | null;
   domain?: string;
   sender: string;
   recipients: string[];
@@ -302,6 +331,7 @@ export interface AppData {
   events: SendEvent[];
   analytics: Analytics | null;
   smtpCredential: SmtpCredential | null;
+  smtpRelays: SmtpRelay[];
   dnsCredentials: DnsCredential[];
   apiTokens: ApiToken[];
   settings: RuntimeConfig | null;
@@ -313,6 +343,7 @@ export interface AddDomainPayload {
   senderHost?: string;
   sendingIp?: string;
   dnsCredentialId?: number | string;
+  smtpRelayId?: number | string | null;
   selector?: string;
   dmarcPolicy?: string;
   spfExtra?: string;
@@ -322,6 +353,7 @@ export interface AddDomainPayload {
 export interface DomainPatchPayload {
   selector?: string;
   dnsCredentialId?: number | string | null;
+  smtpRelayId?: number | string | null;
   senderHost?: string;
   sendingIp?: string;
   spfExtra?: string;

+ 38 - 4
src/pages/Domains/DomainDetail.tsx

@@ -33,7 +33,8 @@ import type {
   DomainPatchPayload,
   RuntimeConfig,
   SendEvent,
-  SmtpCredential
+  SmtpCredential,
+  SmtpRelay
 } from '../../frontend/types';
 
 interface DomainDetailProps {
@@ -43,6 +44,7 @@ interface DomainDetailProps {
   apiTokens: ApiToken[];
   events: SendEvent[];
   dnsCredentials: DnsCredential[];
+  smtpRelays: SmtpRelay[];
   actionLoading?: boolean;
   initialTab?: string;
   onBack: () => void;
@@ -61,6 +63,7 @@ export default function DomainDetail({
   apiTokens,
   events,
   dnsCredentials,
+  smtpRelays,
   actionLoading,
   initialTab,
   onBack,
@@ -76,6 +79,7 @@ export default function DomainDetail({
   const [editOpen, setEditOpen] = useState(false);
   const [form] = Form.useForm<DomainPatchPayload>();
   const dnsApiName = dnsCredentials.find((item) => item.id === domain.dnsCredentialId)?.name;
+  const smtpRelayName = smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name;
   const domainEvents = events.filter((event) => event.domain === domain.domain);
   const records = useMemo(() => orderedRecords(domain.status?.records || []), [domain.status?.records]);
 
@@ -83,6 +87,7 @@ export default function DomainDetail({
     form.setFieldsValue({
       selector: domain.selector,
       dnsCredentialId: domain.dnsCredentialId,
+      smtpRelayId: domain.smtpRelayId,
       senderHost: domain.senderHost,
       sendingIp: domain.sendingIp,
       spfExtra: domain.spfExtra,
@@ -94,7 +99,11 @@ export default function DomainDetail({
 
   async function saveEdit() {
     const values = await form.validateFields();
-    await onPatchDomain(domain, values);
+    await onPatchDomain(domain, {
+      ...values,
+      dnsCredentialId: values.dnsCredentialId ?? null,
+      smtpRelayId: values.smtpRelayId ?? null
+    });
     setEditOpen(false);
   }
 
@@ -117,7 +126,7 @@ export default function DomainDetail({
         activeKey={activeTab}
         onChange={setActiveTab}
         items={[
-          { key: 'overview', label: 'Overview', children: <OverviewTab domain={domain} events={domainEvents} onDelete={() => onDelete(domain)} /> },
+          { key: 'overview', label: 'Overview', children: <OverviewTab domain={domain} events={domainEvents} smtpRelayName={smtpRelayName} onDelete={() => onDelete(domain)} /> },
           {
             key: 'dns',
             label: 'DNS Records',
@@ -161,6 +170,16 @@ export default function DomainDetail({
               }))}
             />
           </Form.Item>
+          <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
+            <Select
+              allowClear
+              placeholder={t('smtpRelay.useResolutionOrder')}
+              options={smtpRelays.map((relay) => ({
+                value: relay.id,
+                label: relayLabel(relay, t)
+              }))}
+            />
+          </Form.Item>
           <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
             <Input />
           </Form.Item>
@@ -185,7 +204,17 @@ export default function DomainDetail({
   );
 }
 
-function OverviewTab({ domain, events, onDelete }: { domain: Domain; events: SendEvent[]; onDelete: () => void }) {
+function OverviewTab({
+  domain,
+  events,
+  smtpRelayName,
+  onDelete
+}: {
+  domain: Domain;
+  events: SendEvent[];
+  smtpRelayName?: string;
+  onDelete: () => void;
+}) {
   const { t } = useI18n();
   return (
     <Row gutter={[16, 16]}>
@@ -195,6 +224,7 @@ function OverviewTab({ domain, events, onDelete }: { domain: Domain; events: Sen
             <Descriptions.Item label={t('domains.domain')}>{domain.domain}</Descriptions.Item>
             <Descriptions.Item label={t('domains.senderHost')}>{domain.senderHost}</Descriptions.Item>
             <Descriptions.Item label={t('domains.sendingIp')}>{domain.sendingIp}</Descriptions.Item>
+            <Descriptions.Item label={t('smtpRelay.domainDefault')}>{smtpRelayName || t('smtpRelay.useResolutionOrder')}</Descriptions.Item>
             <Descriptions.Item label="DKIM selector">{domain.selector}</Descriptions.Item>
             <Descriptions.Item label={t('domains.lastSent')}>{events[0] ? new Date(events[0].createdAt).toLocaleString() : t('common.notFound')}</Descriptions.Item>
           </Descriptions>
@@ -418,6 +448,10 @@ function liveLabel(key: string, t: (key: string) => string) {
   }[key] || key;
 }
 
+function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
+  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+}
+
 function copyable(value: string, onCopy: (value: string) => void) {
   return (
     <Space>

+ 190 - 4
src/pages/SmtpCredentials.tsx

@@ -1,20 +1,44 @@
-import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
-import { Button, Card, Descriptions, Form, Input, Space, Tag, Typography } from 'antd';
+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 { useI18n } from '../frontend/i18n/react';
-import type { RuntimeConfig, SmtpCredential } from '../frontend/types';
+import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
 
 interface SmtpCredentialsProps {
   config: RuntimeConfig | null;
   credential: SmtpCredential | null;
+  relays: SmtpRelay[];
   loading?: boolean;
   onCopy: (value: string) => void;
   onSave: (values: { username: string; password?: string }) => Promise<void>;
+  onLoadRelay: (id: number) => Promise<SmtpRelay | null>;
+  onSaveRelay: (values: SmtpRelayPayload, id?: number) => Promise<SmtpRelay | null>;
+  onDeleteRelay: (relay: SmtpRelay) => Promise<void>;
 }
 
-export default function SmtpCredentials({ config, credential, loading, onCopy, onSave }: SmtpCredentialsProps) {
+export default function SmtpCredentials({
+  config,
+  credential,
+  relays,
+  loading,
+  onCopy,
+  onSave,
+  onLoadRelay,
+  onSaveRelay,
+  onDeleteRelay
+}: SmtpCredentialsProps) {
   const { t } = useI18n();
   const [form] = Form.useForm();
+  const [relayForm] = Form.useForm<SmtpRelayPayload>();
+  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);
@@ -23,6 +47,111 @@ export default function SmtpCredentials({ config, credential, loading, onCopy, o
     form.setFieldValue('password', password);
   }
 
+  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);
+  }
+
+  function openCreateRelay() {
+    setEditingRelay(null);
+    relayForm.setFieldsValue({
+      name: '',
+      host: '',
+      port: 587,
+      secure: false,
+      username: '',
+      password: '',
+      helo: '',
+      isDefault: relays.length === 0
+    });
+    setRelayOpen(true);
+  }
+
+  async function openEditRelay(relay: SmtpRelay) {
+    setEditingRelay(relay);
+    setRelayOpen(true);
+    setRelayLoading(true);
+    try {
+      const detail = await onLoadRelay(relay.id);
+      if (!detail) {
+        setRelayOpen(false);
+        return;
+      }
+      relayForm.setFieldsValue({
+        name: detail.name,
+        host: detail.host,
+        port: detail.port,
+        secure: detail.secure,
+        username: detail.username,
+        password: detail.password || '',
+        helo: detail.helo,
+        isDefault: detail.isDefault
+      });
+    } finally {
+      setRelayLoading(false);
+    }
+  }
+
+  async function saveRelay() {
+    const values = await relayForm.validateFields();
+    setRelayLoading(true);
+    try {
+      const saved = await onSaveRelay(values, editingRelay?.id);
+      if (!saved) return;
+      setRelayOpen(false);
+      relayForm.resetFields();
+    } finally {
+      setRelayLoading(false);
+    }
+  }
+
+  const relayColumns: ColumnsType<SmtpRelay> = [
+    {
+      title: t('smtpRelay.name'),
+      dataIndex: 'name',
+      width: 190,
+      render: (value, relay) => (
+        <Space wrap>
+          <Typography.Text strong>{value}</Typography.Text>
+          {relay.isDefault ? <Tag color="success">{t('smtpRelay.default')}</Tag> : null}
+        </Space>
+      )
+    },
+    {
+      title: t('smtpRelay.server'),
+      width: 220,
+      render: (_, relay) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text code>{relay.host}:{relay.port}</Typography.Text>
+          <Typography.Text type="secondary">{relay.secure ? 'SSL/TLS' : 'STARTTLS / Plain'}</Typography.Text>
+        </Space>
+      )
+    },
+    { title: t('smtpRelay.username'), dataIndex: 'username', width: 180, render: (value) => value || '-' },
+    {
+      title: t('smtpRelay.password'),
+      dataIndex: 'passwordSet',
+      width: 120,
+      render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}</Tag>
+    },
+    { title: 'HELO', dataIndex: 'helo', width: 180, render: (value) => value || '-' },
+    {
+      title: t('domains.actions'),
+      fixed: 'right',
+      width: 150,
+      render: (_, relay) => (
+        <Space>
+          <Button icon={<EditOutlined />} onClick={() => openEditRelay(relay)} />
+          <Popconfirm title={t('smtpRelay.deleteConfirm')} onConfirm={() => onDeleteRelay(relay)}>
+            <Button danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
   return (
     <Space direction="vertical" size={16} className="full-width">
       <Card title={t('smtp.connectionTitle')}>
@@ -61,6 +190,63 @@ export default function SmtpCredentials({ config, credential, loading, onCopy, o
           </Space>
         </Form>
       </Card>
+      <Card
+        title={t('smtpRelay.title')}
+        extra={
+          <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay}>
+            {t('smtpRelay.create')}
+          </Button>
+        }
+      >
+        <Table
+          rowKey="id"
+          columns={relayColumns}
+          dataSource={relays}
+          scroll={{ x: 1040 }}
+          pagination={false}
+        />
+      </Card>
+      <Modal
+        title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
+        open={relayOpen}
+        confirmLoading={loading || relayLoading}
+        onCancel={() => setRelayOpen(false)}
+        onOk={saveRelay}
+        width={640}
+        destroyOnHidden
+      >
+        <Form form={relayForm} layout="vertical">
+          <Form.Item name="name" label={t('smtpRelay.name')} rules={[{ required: true, message: t('smtpRelay.nameRequired') }]}>
+            <Input autoComplete="off" placeholder="Amazon SES" />
+          </Form.Item>
+          <Form.Item name="host" label="SMTP Host" rules={[{ required: true, message: t('smtpRelay.hostRequired') }]}>
+            <Input autoComplete="off" placeholder="email-smtp.us-east-1.amazonaws.com" />
+          </Form.Item>
+          <Form.Item name="port" label="SMTP Port" rules={[{ required: true, message: t('smtpRelay.portRequired') }]}>
+            <InputNumber min={1} max={65535} className="full-width" />
+          </Form.Item>
+          <Form.Item name="secure" label="SSL/TLS" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+          <Form.Item name="username" label={t('smtpRelay.username')}>
+            <Input autoComplete="off" />
+          </Form.Item>
+          <Form.Item name="password" label={t('smtpRelay.password')} extra={t('smtpRelay.passwordExtra')}>
+            <Input autoComplete="new-password" />
+          </Form.Item>
+          <Form.Item>
+            <Button icon={<ReloadOutlined />} onClick={generateRelayPassword}>
+              {t('smtpRelay.generatePassword')}
+            </Button>
+          </Form.Item>
+          <Form.Item name="helo" label="HELO" extra={t('smtpRelay.heloExtra')}>
+            <Input autoComplete="off" placeholder={config?.mailHostname || 'mail.example.com'} />
+          </Form.Item>
+          <Form.Item name="isDefault" label={t('smtpRelay.default')} valuePropName="checked">
+            <Switch />
+          </Form.Item>
+        </Form>
+      </Modal>
     </Space>
   );
 }

+ 112 - 8
src/server.js

@@ -23,6 +23,8 @@ import {
   getDomainByName,
   getSendAnalytics,
   getSettings,
+  getDefaultSmtpRelay,
+  getSmtpRelay,
   getSmtpCredential,
   getSystemEmailSettings,
   getUser,
@@ -34,6 +36,7 @@ import {
   listDnsCredentials,
   listDomains,
   listSendEvents,
+  listSmtpRelays,
   listUsersWithResourceCounts,
   logAudit,
   logSendEvent,
@@ -42,10 +45,12 @@ import {
   saveDnsCredential,
   saveDomainStatus,
   saveSettings,
+  saveSmtpRelay,
   saveSmtpCredential,
   saveSystemEmailSettings,
   seedAdminUser,
   seedSmtpCredential,
+  deleteSmtpRelay,
   transferApiTokens,
   transferDnsCredential,
   transferDomain,
@@ -216,12 +221,17 @@ async function handleApi(req, res, url, user) {
     if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
       return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
     }
+    const smtpRelayId = Number(body.smtpRelayId || 0) || null;
+    if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
+      return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
+    }
     const keys = createDkimKeyPair();
     try {
       const row = createDomain(user.id, {
         domain,
         selector,
         dnsCredentialId,
+        smtpRelayId,
         verificationToken: crypto.randomBytes(18).toString('hex'),
         dkimPublic: keys.publicKey,
         dkimPrivate: keys.privateKey,
@@ -259,8 +269,37 @@ async function handleApi(req, res, url, user) {
     }
     return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
   }
+  if (method === 'GET' && pathname === '/api/smtp-relays') {
+    return sendJson(res, 200, { relays: listSmtpRelays(user.id) });
+  }
+  if (method === 'POST' && pathname === '/api/smtp-relays') {
+    const body = await readJson(req);
+    const relay = saveSmtpRelay(user.id, smtpRelayPatch(body));
+    return sendJson(res, 201, { relay });
+  }
+  const smtpRelayMatch = pathname.match(/^\/api\/smtp-relays\/(\d+)$/);
+  if (smtpRelayMatch) {
+    const id = Number(smtpRelayMatch[1]);
+    if (method === 'GET') {
+      const relay = getSmtpRelay(id, user.id, { includePassword: true });
+      return sendJson(res, relay ? 200 : 404, { relay });
+    }
+    if (method === 'PATCH' || method === 'PUT') {
+      const body = await readJson(req);
+      const relay = saveSmtpRelay(user.id, { ...smtpRelayPatch(body), id });
+      return sendJson(res, relay ? 200 : 404, { relay });
+    }
+    if (method === 'DELETE') {
+      const deleted = deleteSmtpRelay(id, user.id);
+      return sendJson(res, deleted ? 200 : 404, { deleted });
+    }
+  }
   if (method === 'POST' && pathname === '/api/send') {
     const body = await readJson(req);
+    const smtpRelayId = Number(body.smtpRelayId || 0) || null;
+    if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
+      return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
+    }
     const result = await sendMailFromBody(body, user);
     return sendJson(res, 202, result);
   }
@@ -326,9 +365,14 @@ async function handleApi(req, res, url, user) {
       if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
         return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
       }
+      const smtpRelayId = body.smtpRelayId !== undefined ? Number(body.smtpRelayId || 0) || null : undefined;
+      if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
+        return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
+      }
       const row = updateDomain(id, user.id, {
         selector: body.selector ? normalizeSelector(body.selector) : undefined,
         dnsCredentialId,
+        smtpRelayId,
         senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
         sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
         spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
@@ -378,12 +422,17 @@ async function handleApi(req, res, url, user) {
       const row = getDomain(id, { userId: user.id });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
       const body = await readJson(req);
+      const smtpRelayId = Number(body.smtpRelayId || 0) || null;
+      if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
+        return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
+      }
       const from = body.from || `noreply@${row.domain}`;
       const result = await sendMailFromBody({
         from,
         to: body.to,
         subject: body.subject || `MailHub test for ${row.domain}`,
-        text: body.text || `This is a MailHub test message from ${row.domain}.`
+        text: body.text || `This is a MailHub test message from ${row.domain}.`,
+        smtpRelayId: body.smtpRelayId
       }, user);
       return sendJson(res, 202, result);
     }
@@ -746,14 +795,15 @@ async function sendMailFromBody(body, user) {
     baseUrl: settings.appBaseUrl
   });
   const signed = signMessageForDomain(rawMessage, domain);
+  const smtpTransport = smtpTransportForSend(body, domain, user);
   try {
     const smtpResult = await sendViaSmtp({
-      host: envConfig.smtpHost,
-      port: envConfig.smtpPort,
-      secure: envConfig.smtpSecure,
-      username: envConfig.smtpUser,
-      password: envConfig.smtpPassword,
-      helo: envConfig.smtpHelo,
+      host: smtpTransport.host,
+      port: smtpTransport.port,
+      secure: smtpTransport.secure,
+      username: smtpTransport.username,
+      password: smtpTransport.password,
+      helo: smtpTransport.helo,
       mailFrom: from,
       recipients,
       rawMessage: signed
@@ -761,6 +811,7 @@ async function sendMailFromBody(body, user) {
     logSendEvent({
       userId: user.id,
       domainId: domain.id,
+      smtpRelayId: smtpTransport.smtpRelayId,
       sender: from,
       recipients,
       subject: body.subject || '(no subject)',
@@ -769,11 +820,19 @@ async function sendMailFromBody(body, user) {
       queueId: smtpResult.queueId,
       deliveryLog: smtpResult.deliveryLog
     });
-    return { queued: true, domain: domain.domain, recipients, smtp: smtpResult.message, queueId: smtpResult.queueId };
+    return {
+      queued: true,
+      domain: domain.domain,
+      recipients,
+      smtp: smtpResult.message,
+      queueId: smtpResult.queueId,
+      smtpRelayId: smtpTransport.smtpRelayId
+    };
   } catch (error) {
     logSendEvent({
       userId: user.id,
       domainId: domain.id,
+      smtpRelayId: smtpTransport.smtpRelayId,
       sender: from,
       recipients,
       subject: body.subject || '(no subject)',
@@ -796,6 +855,43 @@ function deliveryLogFromError(error) {
   }];
 }
 
+function smtpTransportForSend(body, domain, user) {
+  const requestedRelayId = Number(body.smtpRelayId || 0) || null;
+  if (requestedRelayId) {
+    const relay = getSmtpRelay(requestedRelayId, user.id, { includePassword: true });
+    if (!relay) throw new Error('SMTP 出口不存在。');
+    return smtpTransportFromRelay(relay);
+  }
+  if (domain.smtpRelayId) {
+    const relay = getSmtpRelay(domain.smtpRelayId, user.id, { includePassword: true });
+    if (!relay) throw new Error('SMTP 出口不存在。');
+    return smtpTransportFromRelay(relay);
+  }
+  const defaultRelay = getDefaultSmtpRelay(user.id, { includePassword: true });
+  if (defaultRelay) return smtpTransportFromRelay(defaultRelay);
+  return {
+    smtpRelayId: null,
+    host: envConfig.smtpHost,
+    port: envConfig.smtpPort,
+    secure: envConfig.smtpSecure,
+    username: envConfig.smtpUser,
+    password: envConfig.smtpPassword,
+    helo: envConfig.smtpHelo
+  };
+}
+
+function smtpTransportFromRelay(relay) {
+  return {
+    smtpRelayId: relay.id,
+    host: relay.host,
+    port: relay.port,
+    secure: relay.secure,
+    username: relay.username,
+    password: relay.password || '',
+    helo: relay.helo || envConfig.smtpHelo
+  };
+}
+
 function runBackground(promise) {
   promise.catch((error) => console.error(error));
 }
@@ -1175,6 +1271,14 @@ function normalizeDmarcPolicy(input) {
   return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
 }
 
+function smtpRelayPatch(body) {
+  const patch = {};
+  for (const key of ['name', 'host', 'port', 'secure', 'username', 'password', 'helo', 'isDefault']) {
+    if (Object.hasOwn(body, key)) patch[key] = body[key];
+  }
+  return patch;
+}
+
 function boolString(value) {
   return String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
 }

+ 87 - 0
test/db.test.js

@@ -14,9 +14,11 @@ import {
   createDomain,
   createUser,
   createUserWithAccountToken,
+  deleteSmtpRelay,
   getDnsCredential,
   getDomain,
   getSendAnalytics,
+  getSmtpRelay,
   getSmtpCredential,
   getAdminResourceInventory,
   getSystemEmailSettings,
@@ -25,6 +27,7 @@ import {
   listAuditLogs,
   listDomains,
   listSendEvents,
+  listSmtpRelays,
   listUsersWithResourceCounts,
   invalidateAccountTokens,
   logAudit,
@@ -33,6 +36,7 @@ import {
   markUserEmailVerified,
   previewUserMerge,
   saveDnsCredential,
+  saveSmtpRelay,
   saveSmtpCredential,
   saveSystemEmailSettings,
   seedAdminUser,
@@ -151,6 +155,89 @@ test('isolates domains, smtp credentials, and api tokens by user', () => {
   assert.equal(verifyApiToken(token.token).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' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+
+  const primary = saveSmtpRelay(alice.id, {
+    name: 'Primary relay',
+    host: 'smtp.primary.example',
+    port: 587,
+    secure: false,
+    username: 'primary-user',
+    password: 'primary-secret',
+    helo: 'mail.alice.example',
+    isDefault: true
+  });
+  const backup = saveSmtpRelay(alice.id, {
+    name: 'Backup relay',
+    host: 'smtp.backup.example',
+    port: 465,
+    secure: true,
+    username: 'backup-user',
+    password: 'backup-secret',
+    isDefault: true
+  });
+  saveSmtpRelay(bob.id, {
+    name: 'Bob relay',
+    host: 'smtp.bob.example',
+    port: 25,
+    secure: false,
+    username: '',
+    password: '',
+    isDefault: true
+  });
+
+  assert.equal(primary.passwordSet, true);
+  assert.equal('password' in primary, false);
+  assert.deepEqual(
+    listSmtpRelays(alice.id).map((relay) => [relay.name, relay.isDefault, 'password' in relay]),
+    [
+      ['Backup relay', true, false],
+      ['Primary relay', false, false]
+    ]
+  );
+  assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
+  assert.equal(getSmtpRelay(backup.id, bob.id), null);
+  assert.equal(JSON.stringify(listSmtpRelays(alice.id)).includes('backup-secret'), false);
+
+  const stored = database.prepare('SELECT password_secret FROM smtp_relays WHERE id = ?').get(backup.id);
+  assert.ok(stored.password_secret);
+  assert.equal(stored.password_secret.includes('backup-secret'), false);
+
+  const updated = saveSmtpRelay(alice.id, {
+    id: backup.id,
+    name: 'Backup relay updated',
+    host: 'smtp.backup.example',
+    port: 2525,
+    secure: false,
+    username: 'backup-user',
+    helo: 'helo.backup.example',
+    isDefault: false
+  });
+  assert.equal(updated.port, 2525);
+  assert.equal(updated.isDefault, false);
+  assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
+  const cleared = saveSmtpRelay(alice.id, {
+    id: backup.id,
+    name: 'Backup relay without auth',
+    host: 'smtp.backup.example',
+    username: '',
+    password: ''
+  });
+  assert.equal(cleared.passwordSet, false);
+  assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, '');
+  assert.equal(saveSmtpRelay(bob.id, {
+    id: backup.id,
+    name: 'Should not create',
+    host: 'smtp.invalid.example'
+  }), null);
+  assert.equal(deleteSmtpRelay(backup.id, bob.id), false);
+  assert.equal(deleteSmtpRelay(backup.id, alice.id), true);
+  assert.equal(getSmtpRelay(backup.id, alice.id), null);
+});
+
 test('stores account tokens as hashes and enforces token lifecycle', () => {
   const database = initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });

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

@@ -97,6 +97,250 @@ test('auth pages preserve query messages instead of redirecting them away', asyn
   }
 });
 
+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();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    const domainResponse = await fetch(`${baseUrl}/api/domains`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        domain: 'relay.example',
+        selector: 'mh',
+        senderHost: 'mail.relay.example',
+        sendingIp: '127.0.0.1'
+      })
+    });
+    assert.equal(domainResponse.status, 201);
+
+    const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        name: 'Primary outbound',
+        host: '127.0.0.1',
+        port: relayServer.port,
+        secure: false,
+        username: 'relay-user',
+        password: 'relay-password',
+        helo: 'helo.relay.example',
+        isDefault: true
+      })
+    });
+    assert.equal(createRelay.status, 201);
+    const created = await createRelay.json();
+    assert.equal(created.relay.passwordSet, true);
+    assert.equal('password' in created.relay, false);
+
+    const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } });
+    assert.equal(list.status, 200);
+    const listed = await list.json();
+    assert.equal(listed.relays.length, 1);
+    assert.equal('password' in listed.relays[0], false);
+
+    const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
+    assert.equal(detail.status, 200);
+    const detailBody = await detail.json();
+    assert.equal(detailBody.relay.password, 'relay-password');
+    assert.equal('passwordSecret' in detailBody.relay, false);
+
+    const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        name: 'Missing relay',
+        host: '127.0.0.1',
+        port: relayServer.port,
+        secure: false,
+        username: 'missing-user',
+        password: 'missing-password'
+      })
+    });
+    assert.equal(missingPatch.status, 404);
+
+    const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        name: 'Primary outbound renamed',
+        host: '127.0.0.1',
+        port: relayServer.port,
+        secure: false,
+        username: 'relay-user'
+      })
+    });
+    assert.equal(updateWithoutPassword.status, 200);
+    assert.equal((await updateWithoutPassword.json()).relay.isDefault, true);
+    const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
+    assert.equal((await detailAfterPatch.json()).relay.password, 'relay-password');
+
+    const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        from: 'noreply@relay.example',
+        to: 'user@example.com',
+        subject: 'Invalid relay',
+        text: 'hello',
+        smtpRelayId: 999999
+      })
+    });
+    assert.equal(invalidRelaySend.status, 400);
+
+    const send = await fetch(`${baseUrl}/api/send`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        from: 'noreply@relay.example',
+        to: 'user@example.com',
+        subject: 'Relay send',
+        text: 'hello',
+        smtpRelayId: created.relay.id
+      })
+    });
+    assert.equal(send.status, 202);
+    assert.equal((await send.json()).smtpRelayId, created.relay.id);
+    await waitForCondition(() => relayServer.messages.length === 1);
+    const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
+    assert.ok(authCommand);
+    assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password');
+
+    const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
+    assert.equal(events.status, 200);
+    assert.equal((await events.json()).events[0].smtpRelayId, created.relay.id);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await relayServer.close();
+  }
+});
+
+test('smtp relay selection prefers request relay then domain relay then user default relay', async () => {
+  const requestRelayServer = await startFakeSmtpServer();
+  const domainRelayServer = await startFakeSmtpServer();
+  const defaultRelayServer = await startFakeSmtpServer();
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
+      name: 'Default relay',
+      host: '127.0.0.1',
+      port: defaultRelayServer.port,
+      username: 'default-user',
+      password: 'default-password',
+      isDefault: true
+    });
+    const domainRelay = await createSmtpRelay(baseUrl, cookie, {
+      name: 'Domain relay',
+      host: '127.0.0.1',
+      port: domainRelayServer.port,
+      username: 'domain-user',
+      password: 'domain-password'
+    });
+    const requestRelay = await createSmtpRelay(baseUrl, cookie, {
+      name: 'Request relay',
+      host: '127.0.0.1',
+      port: requestRelayServer.port,
+      username: 'request-user',
+      password: 'request-password'
+    });
+
+    const domain = await createSendingDomain(baseUrl, cookie, {
+      domain: 'relay-order.example',
+      smtpRelayId: domainRelay.id
+    });
+    assert.equal(domain.smtpRelayId, domainRelay.id);
+
+    const domainSend = await sendApiMail(baseUrl, cookie, {
+      from: 'noreply@relay-order.example',
+      to: 'domain@example.com',
+      subject: 'Domain relay'
+    });
+    assert.equal(domainSend.smtpRelayId, domainRelay.id);
+    await waitForCondition(() => domainRelayServer.messages.length === 1);
+    assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password');
+
+    const requestSend = await sendApiMail(baseUrl, cookie, {
+      from: 'noreply@relay-order.example',
+      to: 'request@example.com',
+      subject: 'Request relay',
+      smtpRelayId: requestRelay.id
+    });
+    assert.equal(requestSend.smtpRelayId, requestRelay.id);
+    await waitForCondition(() => requestRelayServer.messages.length === 1);
+    assertRelayAuth(requestRelayServer, 'request-user', 'request-password');
+
+    const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        to: 'test-send@example.com',
+        subject: 'Selected relay test send',
+        smtpRelayId: requestRelay.id
+      })
+    });
+    assert.equal(testSend.status, 202);
+    assert.equal((await testSend.json()).smtpRelayId, requestRelay.id);
+    await waitForCondition(() => requestRelayServer.messages.length === 2);
+
+    const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        to: 'invalid-test-send@example.com',
+        smtpRelayId: 999999
+      })
+    });
+    assert.equal(invalidTestSend.status, 400);
+
+    const defaultDomain = await createSendingDomain(baseUrl, cookie, {
+      domain: 'default-relay.example'
+    });
+    assert.equal(defaultDomain.smtpRelayId, null);
+    const defaultSend = await sendApiMail(baseUrl, cookie, {
+      from: 'noreply@default-relay.example',
+      to: 'default@example.com',
+      subject: 'Default relay'
+    });
+    assert.equal(defaultSend.smtpRelayId, defaultRelay.id);
+    await waitForCondition(() => defaultRelayServer.messages.length === 1);
+    assertRelayAuth(defaultRelayServer, 'default-user', 'default-password');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await requestRelayServer.close();
+    await domainRelayServer.close();
+    await defaultRelayServer.close();
+  }
+});
+
 test('admin users can list audit logs', async () => {
   const { child, baseUrl } = await startTestServer();
 
@@ -1336,6 +1580,73 @@ async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
   assert.equal(response.status, 200);
 }
 
+async function createSendingDomain(baseUrl, cookie, data = {}) {
+  const domain = data.domain || 'send.example';
+  const response = await fetch(`${baseUrl}/api/domains`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Cookie: cookie
+    },
+    body: JSON.stringify({
+      domain,
+      selector: data.selector || 'mh',
+      senderHost: data.senderHost || `mail.${domain}`,
+      sendingIp: data.sendingIp || '127.0.0.1',
+      smtpRelayId: data.smtpRelayId
+    })
+  });
+  assert.equal(response.status, 201);
+  return (await response.json()).domain;
+}
+
+async function createSmtpRelay(baseUrl, cookie, data = {}) {
+  const response = await fetch(`${baseUrl}/api/smtp-relays`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Cookie: cookie
+    },
+    body: JSON.stringify({
+      name: data.name || 'Relay',
+      host: data.host || '127.0.0.1',
+      port: data.port,
+      secure: data.secure || false,
+      username: data.username || '',
+      password: data.password || '',
+      helo: data.helo || '',
+      isDefault: data.isDefault || false
+    })
+  });
+  assert.equal(response.status, 201);
+  return (await response.json()).relay;
+}
+
+async function sendApiMail(baseUrl, cookie, data) {
+  const response = await fetch(`${baseUrl}/api/send`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Cookie: cookie
+    },
+    body: JSON.stringify({
+      from: data.from,
+      to: data.to,
+      subject: data.subject,
+      text: data.text || 'hello',
+      smtpRelayId: data.smtpRelayId
+    })
+  });
+  assert.equal(response.status, 202);
+  return response.json();
+}
+
+function assertRelayAuth(relayServer, username, password) {
+  const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
+  assert.ok(authCommand);
+  assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
+}
+
 function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
   const commands = [];
   const messages = [];

Некоторые файлы не были показаны из-за большого количества измененных файлов