Forráskód Böngészése

chore: initialize git baseline

Codex 1 hónapja
commit
bc997b5cda
22 módosított fájl, 4246 hozzáadás és 0 törlés
  1. 9 0
      .dockerignore
  2. 34 0
      .env.example
  3. 16 0
      .gitignore
  4. 23 0
      Dockerfile
  5. 146 0
      README.md
  6. 20 0
      certs/mail-send.ss5.xyz.crt
  7. 44 0
      docker-compose.yml
  8. 13 0
      docker/postfix/Dockerfile
  9. 24 0
      docker/postfix/entrypoint.sh
  10. 14 0
      package.json
  11. 779 0
      public/app.js
  12. 113 0
      public/index.html
  13. 214 0
      public/login.css
  14. 54 0
      public/login.html
  15. 26 0
      public/login.js
  16. 746 0
      public/styles.css
  17. 377 0
      src/db.js
  18. 126 0
      src/dkim.js
  19. 336 0
      src/dns-guide.js
  20. 227 0
      src/mailer.js
  21. 534 0
      src/server.js
  22. 371 0
      src/submission.js

+ 9 - 0
.dockerignore

@@ -0,0 +1,9 @@
+.git
+.DS_Store
+node_modules
+npm-debug.log
+data
+postfix-spool
+*.sqlite
+*.sqlite-*
+.env

+ 34 - 0
.env.example

@@ -0,0 +1,34 @@
+APP_PORT=3025
+APP_BASE_URL=http://mail-send.ss5.xyz
+ADMIN_USER=admin
+ADMIN_PASSWORD=change-this-admin-password
+API_TOKEN=change-this-api-token
+SUBMISSION_ENABLED=true
+SUBMISSION_HOST=in.ss5.xyz
+SUBMISSION_BIND=0.0.0.0
+SUBMISSION_PORTS=25:smtp,587:smtp,465:smtps,2525:smtp
+SUBMISSION_ALT_PORT=2525
+SUBMISSION_ALLOW_INSECURE_AUTH=false
+SUBMISSION_TLS_CERT=/certs/mail-send.ss5.xyz.crt
+SUBMISSION_TLS_KEY=/certs/mail-send.ss5.xyz.key
+SUBMISSION_USERNAME=change-this-smtp-user
+SUBMISSION_PASSWORD=change-this-smtp-password
+
+# Default outbound identity used in SPF, HELO, and Postfix myhostname.
+MAIL_HOSTNAME=in.ss5.xyz
+SENDING_IP=8.231.54.11
+
+# Extra SPF mechanisms to preserve coexistence with third-party senders.
+# Examples: include:spf.mailjet.com include:_netblocks.m.feishu.cn
+DEFAULT_SPF_MECHANISMS=include:spf.mailjet.com
+DNS_RESOLVERS=1.1.1.1,8.8.8.8
+
+# SMTP service used by the web API. In docker-compose this is the internal Postfix service.
+SMTP_HOST=postfix
+SMTP_PORT=25
+SMTP_HELO=in.ss5.xyz
+SEND_REQUIRES_VERIFIED=false
+
+# DMARC defaults.
+DMARC_POLICY=none
+DMARC_RUA=

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+.env
+.env.*
+!.env.example
+
+data/
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
+
+certs/*.key
+*.log
+logs/
+tmp/
+.cache/
+node_modules/
+npm-debug.log*

+ 23 - 0
Dockerfile

@@ -0,0 +1,23 @@
+FROM node:24-bookworm-slim
+
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY package.json ./
+COPY src ./src
+COPY public ./public
+
+RUN mkdir -p /data && chown -R node:node /data /app
+
+USER node
+EXPOSE 3000
+EXPOSE 25
+EXPOSE 465
+EXPOSE 587
+EXPOSE 2525
+VOLUME ["/data"]
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
+  CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+
+CMD ["node", "src/server.js"]

+ 146 - 0
README.md

@@ -0,0 +1,146 @@
+# MailHub
+
+MailHub 是一个 Docker 化的发信域名管理面板和发送 API。它负责:
+
+- 添加发信域名并生成验证 TXT。
+- 为每个域名生成 DKIM key 和 DNS 记录。
+- 读取当前公网 DNS,合并已有 SPF 和第三方发信 include。
+- 给出 SPF、DKIM、DMARC、PTR、发信主机 A 记录的状态和建议。
+- 通过内部 Postfix 出站队列发送邮件,API 会先给邮件加 DKIM 签名。
+
+## 运行
+
+```bash
+cd /www/wwwroot/mail.ss5.xyz
+docker compose up -d --build
+docker compose ps
+docker compose logs -f app postfix
+```
+
+管理面板默认监听宿主机 `127.0.0.1:3025`,nginx 已反代到 `mail-send.ss5.xyz`。
+
+面板使用 HTML 登录页和 Cookie 会话。登录账号和 API Token 在 `.env`:
+
+- `ADMIN_USER`
+- `ADMIN_PASSWORD`
+- `API_TOKEN`
+- `SESSION_SECRET` 可选;不设置时会从管理密码和 API Token 派生。
+
+## SMTP 公网发信
+
+MailHub 现在提供公网 SMTP Submission。所有发信端口都需要 SMTP AUTH,未认证不会转发邮件,避免开放中继。
+
+连接信息:
+
+```txt
+Host: in.ss5.xyz
+Port 25:   SMTP + STARTTLS + AUTH
+Port 587:  SMTP Submission + STARTTLS + AUTH
+Port 465:  SMTPS implicit TLS + AUTH
+Port 2525: SMTP + STARTTLS + AUTH
+Username: 在面板左侧“SMTP 凭据”中配置
+Password: 在面板左侧“SMTP 凭据”中配置,保存后不会回显
+```
+
+默认 `SUBMISSION_ALLOW_INSECURE_AUTH=false`,也就是 `25/587/2525` 必须先 STARTTLS 才允许 AUTH;`465` 连接建立时就是 TLS。
+
+`.env` 中的 `SUBMISSION_USERNAME` 和 `SUBMISSION_PASSWORD` 只用于首次初始化数据库。初始化后以面板保存的 SMTP 凭据为准,修改后无需重启。
+
+当前 TLS 证书位于:
+
+```txt
+certs/mail-send.ss5.xyz.crt
+certs/mail-send.ss5.xyz.key
+```
+
+这是一张自签名证书,只用于先把协议跑通。正式公网使用建议替换为包含 `in.ss5.xyz` 的可信证书,然后重启:
+
+```bash
+docker compose up -d app
+```
+
+## 当前默认配置
+
+```env
+MAIL_HOSTNAME=in.ss5.xyz
+SENDING_IP=8.231.54.11
+DEFAULT_SPF_MECHANISMS=include:spf.mailjet.com
+SMTP_HOST=postfix
+SMTP_PORT=25
+```
+
+如果实际出站 IP 不是 `8.231.54.11`,需要同步修改 `.env` 和面板里对应域名的“发信 IP”。
+
+## DNS 重点
+
+SPF 只能保留一条 `v=spf1` TXT。不要为了 Mailjet、飞书、本机发信分别添加多条 SPF;要合并为一条。
+
+## in.ss5.xyz 测试记录
+
+请为 `in.ss5.xyz` 添加或确认以下记录:
+
+```txt
+in.ss5.xyz.  A  8.231.54.11
+```
+
+```txt
+_mailhub.in.ss5.xyz.  TXT  mailhub-verification=9e45358e44624ce991c655e9c19b847f8c98
+```
+
+```txt
+mh202607._domainkey.in.ss5.xyz.  TXT  v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuaQrn0IrsNDVsbn160yOR7DMBaOkXegtlhUCvHYdKCy5Aa/rUcGvUI3gbLoo6m9QEiLs3EAJo29Y9RBbv3lQn/PvAKtfrTuT6Zo1BfcIhe9wJvipgAkH+hFmB4aNZ7MMTKE7vOztOHJooyyiJSHhgBqCVSG4fP7hr+vz5X8kAPXfasQnChHAmA1zM/m41/7qyo78C4pbqBiMAkkGV0zwNhaR1Fy1hdKx4IGpmrMrm5/sye4MK8dMD3jXooJ1Efv+Sh0HjpvKrN8zcxqvNDPusmIgZBxCN49m4R0dYsldK/v/KOeQJVfd+B6U0/2O8p2JHpd5Y1eaJjbREvFsKI30aQIDAQAB
+```
+
+```txt
+in.ss5.xyz.  TXT  v=spf1 ip4:8.231.54.11 a:in.ss5.xyz include:spf.mailjet.com ~all
+```
+
+```txt
+_dmarc.in.ss5.xyz.  TXT  v=DMARC1; p=none; rua=mailto:dmarc@in.ss5.xyz; adkim=s; aspf=s; pct=100
+```
+
+还需要在 IP 服务商控制台设置反向解析:
+
+```txt
+8.231.54.11  PTR  in.ss5.xyz
+```
+
+当前检测到 `8.231.54.11` 的 PTR 仍是 `11.54.231.8.bc.googleusercontent.com`。
+
+当前 `ss5.xyz` 已检测到两条 SPF,建议合并为:
+
+```txt
+v=spf1 include:spf.mailjet.com include:_netblocks.m.feishu.cn include:spf.ss5.xyz.com include:spf.97admin.com ip4:8.231.54.11 a:ali.ss5.xyz -all
+```
+
+当前测试发信主机使用 `in.ss5.xyz`,它应当解析到 `8.231.54.11`。二者需要保持对齐:
+
+- 如果由当前 Docker 服务器发信,把 `in.ss5.xyz` A 记录和 IP 的 PTR 都指向 `8.231.54.11`。
+- 如果实际由 `39.108.92.239` 发信,把 `.env` 和域名设置里的 `SENDING_IP` 改为 `39.108.92.239`。
+
+PTR 反向解析需要在云厂商或 IP 服务商控制台设置,普通域名 DNS 控制台不能设置。
+
+## 发送 API
+
+```bash
+curl -X POST http://mail-send.ss5.xyz/api/send \
+  -H "Authorization: Bearer <API_TOKEN>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "from": "noreply@ss5.xyz",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by Postfix."
+  }'
+```
+
+SMTP 发信时,`Host` 使用 `in.ss5.xyz`。任意已添加并完成 DNS 配置的发信域名,都可以使用该域名下任意邮箱地址作为 `From`,例如 `notice@example.com`、`billing@example.com`、`noreply@example.com`。本服务会按 `From` 所属域名查找 DKIM 私钥并签名。
+
+生产发信前确认:
+
+- 服务器出站 25 端口没有被云厂商拦截。
+- 服务器入站 `25/465/587/2525` 已在云防火墙和系统防火墙放行。
+- SPF、DKIM、DMARC 均通过。
+- 发信 IP 的 PTR 指向 `MAIL_HOSTNAME`。
+- `MAIL_HOSTNAME` 的 A 记录指回发信 IP。
+- 新 IP 先小流量预热,避免突然大批量发送。

+ 20 - 0
certs/mail-send.ss5.xyz.crt

@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDNTCCAh2gAwIBAgIUFiGiIskO26Bc9kHABTLLHnzLm9cwDQYJKoZIhvcNAQEL
+BQAwFTETMBEGA1UEAwwKaW4uc3M1Lnh5ejAeFw0yNjA3MDcwOTQ5NTNaFw0yNzA3
+MDcwOTQ5NTNaMBUxEzARBgNVBAMMCmluLnNzNS54eXowggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCCRMZU49TbyEA0IXwKpKh+/KyBxWS/DyQ7NN3YgYHw
+WcgK2E1YzLbhgL47WBfbPyX9aEwLGtDzOFfiWnheoOzcyi5cqAQA1i1nye5RVAkC
+zUTLoP0+s9eLv46p9krHqGsoKu5gMG0vPLiOrZdvjFT4rbZqNEMcfrjcihktsw+s
+XD95MJPVqkijvZ2S1QbSr8wtMlYyIko5q/whaVcEFQdQ9/kgYEM8Dq01Nb+WA9fJ
+iBDcRuxIvRC2tBdEAnWB3OyRoyYzTqYUiIAVVs1tPcHSGovgKpN2B27xYXxHjccu
+GkwKJyajcXhpcKoeestR830Gwfvm6gFpSobG1MtzEpNLAgMBAAGjfTB7MB0GA1Ud
+DgQWBBTDD8ql1lRYGy3hf5q8JN82SkWPTzAfBgNVHSMEGDAWgBTDD8ql1lRYGy3h
+f5q8JN82SkWPTzAPBgNVHRMBAf8EBTADAQH/MCgGA1UdEQQhMB+CCmluLnNzNS54
+eXqCEW1haWwtc2VuZC5zczUueHl6MA0GCSqGSIb3DQEBCwUAA4IBAQBLOkSRkRt6
+Mfeu0dCkCblVeiX4bVF84UQWtNCnl+4Jg6WqOfzrcJ6epEH/Ot0nGG/tX89Zr66b
+XrdgQDE7P+/eGtQsw1cIPY+9bRydYCqEY9ICifbuz7rMm2+IzzFZd2ZEoxeYkLvY
+M5UcPKF2Qbpuh4g5UgzVckRrDOl4pfvr2nUIYrbyauGZ3MBgmwP9cxw6ukYbaKR2
+fjJUu7HIDPWVzmtN5p/T4l2bLd86HPQf+5nIaENoEuYQDPr6fBXZy5X1oPqDQpyA
+gfLuFW7QdvIX1qafQKwwuyCJPIsl1UJka90yenjJwQnXQ5S1eapYeJz8tQDtolVl
+MHy6V9UuoQ2t
+-----END CERTIFICATE-----

+ 44 - 0
docker-compose.yml

@@ -0,0 +1,44 @@
+services:
+  app:
+    build:
+      context: .
+    container_name: mailhub-app
+    restart: unless-stopped
+    env_file:
+      - .env
+    environment:
+      PORT: 3000
+      DATA_DIR: /data
+    ports:
+      - "127.0.0.1:${APP_PORT:-3025}:3000"
+      - "${SUBMISSION_BIND:-0.0.0.0}:25:25"
+      - "${SUBMISSION_BIND:-0.0.0.0}:465:465"
+      - "${SUBMISSION_BIND:-0.0.0.0}:587:587"
+      - "${SUBMISSION_BIND:-0.0.0.0}:${SUBMISSION_ALT_PORT:-2525}:2525"
+    volumes:
+      - ./data:/data
+      - ./certs:/certs:ro
+    depends_on:
+      postfix:
+        condition: service_started
+    healthcheck:
+      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 15s
+
+  postfix:
+    build:
+      context: ./docker/postfix
+    container_name: mailhub-postfix
+    restart: unless-stopped
+    env_file:
+      - .env
+    hostname: ${MAIL_HOSTNAME:-ali.ss5.xyz}
+    healthcheck:
+      test: ["CMD-SHELL", "postfix status >/dev/null 2>&1 || exit 1"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 20s

+ 13 - 0
docker/postfix/Dockerfile

@@ -0,0 +1,13 @@
+FROM ubuntu:26.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update \
+  && apt-get install -y --no-install-recommends ca-certificates postfix libsasl2-modules \
+  && rm -rf /var/lib/apt/lists/*
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+EXPOSE 25
+ENTRYPOINT ["/entrypoint.sh"]

+ 24 - 0
docker/postfix/entrypoint.sh

@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+MAIL_HOSTNAME="${MAIL_HOSTNAME:-ali.ss5.xyz}"
+MAIL_ORIGIN_DOMAIN="${MAIL_ORIGIN_DOMAIN:-${MAIL_HOSTNAME#*.}}"
+
+postconf -e "myhostname = ${MAIL_HOSTNAME}"
+postconf -e "myorigin = ${MAIL_ORIGIN_DOMAIN}"
+postconf -e "mydestination ="
+postconf -e "inet_interfaces = all"
+postconf -e "inet_protocols = ipv4"
+postconf -e "mynetworks = 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"
+postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
+postconf -e "smtp_tls_security_level = may"
+postconf -e "smtp_tls_loglevel = 1"
+postconf -e "smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt"
+postconf -e "smtp_helo_name = ${MAIL_HOSTNAME}"
+postconf -e "disable_vrfy_command = yes"
+postconf -e "maximal_queue_lifetime = 2d"
+postconf -e "bounce_queue_lifetime = 2d"
+postconf -e "maillog_file = /dev/stdout"
+
+newaliases || true
+exec postfix start-fg

+ 14 - 0
package.json

@@ -0,0 +1,14 @@
+{
+  "name": "mailhub",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "description": "Dockerized outbound mail control panel with DNS guidance and DKIM signing.",
+  "scripts": {
+    "start": "node src/server.js",
+    "dev": "NODE_ENV=development node src/server.js"
+  },
+  "engines": {
+    "node": ">=24.0.0"
+  }
+}

+ 779 - 0
public/app.js

@@ -0,0 +1,779 @@
+const state = {
+  config: null,
+  smtpCredential: null,
+  domains: [],
+  events: [],
+  selectedId: null,
+  busy: false
+};
+
+const els = {
+  runtimeLine: document.querySelector('#runtimeLine'),
+  securityNotice: document.querySelector('#securityNotice'),
+  addDomainForm: document.querySelector('#addDomainForm'),
+  smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
+  smtpCredentialState: document.querySelector('#smtpCredentialState'),
+  smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
+  smtpUsername: document.querySelector('#smtpUsername'),
+  smtpPassword: document.querySelector('#smtpPassword'),
+  generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
+  defaultSenderHost: document.querySelector('#defaultSenderHost'),
+  defaultSendingIp: document.querySelector('#defaultSendingIp'),
+  defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
+  domainList: document.querySelector('#domainList'),
+  domainCount: document.querySelector('#domainCount'),
+  detailPanel: document.querySelector('#detailPanel'),
+  refreshButton: document.querySelector('#refreshButton'),
+  logoutButton: document.querySelector('#logoutButton')
+};
+
+init();
+
+async function init() {
+  bindEvents();
+  await refreshAll();
+}
+
+function bindEvents() {
+  els.refreshButton.addEventListener('click', refreshAll);
+  els.logoutButton.addEventListener('click', logout);
+  document.addEventListener('click', handleCopyClick);
+  els.addDomainForm.addEventListener('submit', addDomain);
+  els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
+  els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
+  els.domainList.addEventListener('click', async (event) => {
+    const item = event.target.closest('[data-domain-id]');
+    if (!item) return;
+    state.selectedId = Number(item.dataset.domainId);
+    render();
+  });
+  els.detailPanel.addEventListener('click', handleDetailClick);
+  els.detailPanel.addEventListener('submit', handleDetailSubmit);
+}
+
+async function refreshAll() {
+  setBusy(true);
+  try {
+    const [config, domains, events, smtpCredential] = await Promise.all([
+      api('/api/config'),
+      api('/api/domains'),
+      api('/api/events'),
+      api('/api/smtp-credential')
+    ]);
+    state.config = config;
+    state.domains = domains.domains || [];
+    state.events = events.events || [];
+    state.smtpCredential = smtpCredential.credential || null;
+    if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
+    renderDefaults();
+    render();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+function renderDefaults() {
+  if (!state.config) return;
+  els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`;
+  els.defaultSenderHost.value = state.config.mailHostname || '';
+  els.defaultSendingIp.value = state.config.sendingIp || '';
+  els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || '';
+  els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
+  els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
+  els.smtpCredentialState.className = `badge ${state.smtpCredential?.passwordSet ? 'ok' : 'warn'}`;
+  renderSmtpCredentialCopy();
+  els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
+  els.securityNotice.textContent = state.config.usingDefaultAdminPassword
+    ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
+    : '';
+}
+
+function renderSmtpCredentialCopy() {
+  if (!els.smtpCredentialCopy) return;
+  const credential = state.smtpCredential;
+  if (!credential?.username && !credential?.passwordSet) {
+    els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
+    return;
+  }
+  const password = credential?.password || '';
+  els.smtpCredentialCopy.innerHTML = `
+    <div class="copy-row">
+      <span>Username</span>
+      <code>${escapeHtml(credential.username || '')}</code>
+      <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button" ${credential.username ? '' : 'disabled'}>复制</button>
+    </div>
+    <div class="copy-row">
+      <span>Password</span>
+      ${password
+        ? `<code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button>`
+        : '<p class="muted">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
+    </div>
+  `;
+}
+
+function render() {
+  els.domainCount.textContent = String(state.domains.length);
+  renderDomainList();
+  renderDetail();
+}
+
+function renderDomainList() {
+  if (!state.domains.length) {
+    els.domainList.innerHTML = '<p class="muted">暂无域名</p>';
+    return;
+  }
+  els.domainList.innerHTML = state.domains.map((domain) => {
+    const status = statusMeta(domain.status);
+    return `
+      <button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
+        <div class="item-line">
+          <strong>${escapeHtml(domain.domain)}</strong>
+          <span class="badge ${status.className}">${status.label}</span>
+        </div>
+        <span class="muted">${escapeHtml(domain.selector)}._domainkey</span>
+      </button>
+    `;
+  }).join('');
+}
+
+function renderDetail() {
+  const domain = state.domains.find((item) => item.id === state.selectedId);
+  if (!domain) {
+    els.detailPanel.innerHTML = `
+      <div class="empty-state">
+        <h2>选择或添加一个域名</h2>
+        <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
+      </div>
+    `;
+    return;
+  }
+  const guide = domain.status || {};
+  const records = guide.records || [];
+  const warnings = guide.warnings || [];
+  const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
+  els.detailPanel.innerHTML = `
+    <div class="detail-header">
+      <div class="detail-title">
+        <h2>${escapeHtml(domain.domain)}</h2>
+        <div class="status-strip">
+          ${badge(statusMeta(guide))}
+          <span class="badge idle">最近检查 ${escapeHtml(checkedAt)}</span>
+          <span class="badge idle">Selector ${escapeHtml(domain.selector)}</span>
+        </div>
+      </div>
+      <div class="detail-actions">
+        <button class="primary-button" data-action="check" type="button">立即检查</button>
+        <button class="secondary-button" data-action="rotate-dkim" type="button">轮换 DKIM</button>
+        <button class="danger-button" data-action="delete-domain" type="button">删除</button>
+      </div>
+    </div>
+    <div class="detail-body">
+      ${renderSetupOverview(domain, guide, records)}
+      ${warnings.length ? renderWarnings(warnings) : ''}
+      <div class="grid-two">
+        <section class="subpanel">
+          <div class="subpanel-head">
+            <h3>配置引导</h3>
+            <button class="small-button" data-action="copy-all-dns" type="button">复制全部</button>
+          </div>
+          ${renderDnsGuide(records)}
+        </section>
+        <section class="subpanel">
+          <h3>域名设置</h3>
+          ${renderSettingsForm(domain)}
+        </section>
+      </div>
+      <div class="grid-two">
+        <section class="subpanel">
+          <h3>当前 DNS</h3>
+          ${renderLiveDns(guide.live)}
+        </section>
+        <section class="subpanel">
+          <h3>测试发送</h3>
+          ${renderSendForm(domain)}
+        </section>
+      </div>
+      <div class="grid-two">
+        <section class="subpanel api-box">
+          <h3>发送 API</h3>
+          <pre><code>${escapeHtml(apiExample(domain))}</code></pre>
+        </section>
+        <section class="subpanel">
+          <h3>SMTP 发信</h3>
+          ${renderSmtpBox(domain)}
+        </section>
+      </div>
+      <section class="subpanel">
+        <h3>最近发送</h3>
+        ${renderEvents(domain)}
+      </section>
+      <section class="subpanel">
+        <h3>增强安全记录</h3>
+        ${renderOptionalRecords(guide.optionalRecords || [])}
+      </section>
+    </div>
+  `;
+}
+
+function renderSetupOverview(domain, guide, records) {
+  const important = [
+    ['verification', '域名验证'],
+    ['dkim', 'DKIM'],
+    ['spf', 'SPF'],
+    ['dmarc', 'DMARC'],
+    ['ptr', 'PTR']
+  ];
+  const cards = important.map(([key, label]) => {
+    const record = records.find((item) => item.key === key);
+    const meta = statusMeta(record || {});
+    return `
+      <div class="metric-card">
+        <span>${escapeHtml(label)}</span>
+        <strong>${record ? escapeHtml(meta.label) : '待生成'}</strong>
+      </div>
+    `;
+  }).join('');
+  return `
+    <section class="guide-hero">
+      <div>
+        <span class="eyebrow">Sending domain</span>
+        <h3>${escapeHtml(domain.domain)}</h3>
+        <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
+      </div>
+      <div class="summary-grid">${cards}</div>
+    </section>
+  `;
+}
+
+function renderDnsGuide(records) {
+  if (!records.length) {
+    return `
+      <div class="empty-guide">
+        <h4>尚未生成检查结果</h4>
+        <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
+      </div>
+    `;
+  }
+  return `
+    <div class="record-steps">
+      ${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}
+    </div>
+  `;
+}
+
+function renderRecordCard(record, index) {
+  const meta = statusMeta(record);
+  const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []);
+  const warnings = record.warnings || [];
+  return `
+    <article class="record-card ${meta.className}">
+      <div class="record-step">
+        <span>${index}</span>
+      </div>
+      <div class="record-card-body">
+        <div class="record-card-title">
+          <div>
+            <h4>${escapeHtml(record.label)}</h4>
+            <p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p>
+          </div>
+          ${badge(meta)}
+        </div>
+        <div class="dns-value">
+          <span>目标值</span>
+          <code>${escapeHtml(record.value || '')}</code>
+          <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
+        </div>
+        ${current.length ? `
+          <div class="dns-current">
+            <span>当前值</span>
+            ${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}
+          </div>
+        ` : ''}
+        ${warnings.length ? `
+          <ul class="inline-warnings">
+            ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
+          </ul>
+        ` : ''}
+      </div>
+    </article>
+  `;
+}
+
+function renderRecordTable(records) {
+  if (!records.length) {
+    return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
+  }
+  return `
+    <div class="record-table-wrap">
+      <table>
+        <thead>
+          <tr>
+            <th>项目</th>
+            <th>主机</th>
+            <th>类型</th>
+            <th>目标值</th>
+            <th>状态</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${records.map(renderRecordRow).join('')}
+        </tbody>
+      </table>
+    </div>
+  `;
+}
+
+function renderRecordRow(record) {
+  const meta = statusMeta(record);
+  return `
+    <tr>
+      <td>${escapeHtml(record.label)}</td>
+      <td class="mono">${escapeHtml(record.host)}</td>
+      <td>${escapeHtml(record.type)}</td>
+      <td><code>${escapeHtml(record.value || '')}</code></td>
+      <td>${badge(meta)}</td>
+      <td><button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
+    </tr>
+  `;
+}
+
+function renderSettingsForm(domain) {
+  return `
+    <form class="compact-form" data-form="settings">
+      <div class="form-grid">
+        <label>
+          DKIM selector
+          <input name="selector" value="${escapeAttr(domain.selector)}">
+        </label>
+        <label>
+          DMARC 策略
+          <select name="dmarcPolicy">
+            ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
+          </select>
+        </label>
+      </div>
+      <label>
+        发信主机
+        <input name="senderHost" value="${escapeAttr(domain.senderHost)}">
+      </label>
+      <label>
+        发信 IP
+        <input name="sendingIp" value="${escapeAttr(domain.sendingIp)}">
+      </label>
+      <label>
+        兼容第三方 SPF
+        <textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea>
+      </label>
+      <label>
+        DMARC rua
+        <input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
+      </label>
+      <button class="secondary-button" type="submit">保存设置</button>
+    </form>
+  `;
+}
+
+function renderLiveDns(live) {
+  if (!live) return '<p class="muted">暂无检查数据</p>';
+  const rows = [
+    ['根域 TXT', live.rootTxt],
+    ['验证 TXT', live.verificationTxt],
+    ['DKIM TXT', live.dkimTxt],
+    ['DMARC TXT', live.dmarcTxt],
+    ['发信主机 A', live.senderA],
+    ['发信 IP PTR', live.ptr]
+  ];
+  return `
+    <div class="live-list">
+      ${rows.map(([label, values]) => `
+        <div class="live-row">
+          <span>${label}</span>
+          ${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="muted">未发现</p>'}
+        </div>
+      `).join('')}
+    </div>
+  `;
+}
+
+function renderSendForm(domain) {
+  return `
+    <form class="send-form" data-form="send">
+      <label>
+        From
+        <input name="from" value="noreply@${escapeAttr(domain.domain)}">
+      </label>
+      <label>
+        To
+        <input name="to" placeholder="user@example.com" required>
+      </label>
+      <label>
+        Subject
+        <input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}">
+      </label>
+      <label>
+        Text
+        <textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea>
+      </label>
+      <button class="primary-button" type="submit">发送测试</button>
+    </form>
+  `;
+}
+
+function renderEvents(domain) {
+  const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
+  if (!events.length) return '<p class="muted">暂无发送记录</p>';
+  return `
+    <div class="event-list">
+      ${events.map((event) => `
+        <div class="event-row">
+          <div class="item-line">
+            <strong>${escapeHtml(event.subject)}</strong>
+            ${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}
+          </div>
+          <span class="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span>
+          <span class="muted">${escapeHtml(formatDate(event.createdAt))}</span>
+        </div>
+      `).join('')}
+    </div>
+  `;
+}
+
+function renderSmtpBox(domain) {
+  const submission = state.config?.submission;
+  const credential = state.smtpCredential;
+  const password = credential?.password || '';
+  if (!submission?.enabled) {
+    return '<p class="muted">SMTP Submission 未启用。</p>';
+  }
+  return `
+    <div class="smtp-grid">
+      <div class="smtp-row">
+        <span>Host</span>
+        <code>${escapeHtml(submission.host)}</code>
+      </div>
+      <div class="smtp-row">
+        <span>Ports</span>
+        ${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}
+      </div>
+      <div class="smtp-row">
+        <span>Username</span>
+        <div class="copy-row inline">
+          <code>${escapeHtml(credential?.username || submission.username || '')}</code>
+          <button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button>
+        </div>
+      </div>
+      <div class="smtp-row">
+        <span>Password</span>
+        ${password
+          ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>`
+          : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}
+      </div>
+      <div class="smtp-row">
+        <span>AUTH</span>
+        <code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code>
+      </div>
+      <div class="smtp-row">
+        <span>From</span>
+        <code>noreply@${escapeHtml(domain.domain)}</code>
+      </div>
+    </div>
+  `;
+}
+
+function renderOptionalRecords(records) {
+  if (!records.length) return '<p class="muted">完成基础发信配置后可逐步启用。</p>';
+  return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
+}
+
+function renderWarnings(warnings) {
+  return `
+    <ul class="warning-list">
+      ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
+    </ul>
+  `;
+}
+
+function apiExample(domain) {
+  return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
+  -H 'Authorization: Bearer <API_TOKEN>' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@${domain.domain}",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by Postfix."
+  }'`;
+}
+
+async function addDomain(event) {
+  event.preventDefault();
+  const data = Object.fromEntries(new FormData(event.target).entries());
+  setBusy(true);
+  try {
+    const result = await api('/api/domains', {
+      method: 'POST',
+      body: JSON.stringify(data)
+    });
+    state.domains.unshift(result.domain);
+    state.selectedId = result.domain.id;
+    event.target.reset();
+    renderDefaults();
+    render();
+    await checkSelected();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function saveSmtpCredential(event) {
+  event.preventDefault();
+  const data = Object.fromEntries(new FormData(event.target).entries());
+  setBusy(true);
+  try {
+    const result = await api('/api/smtp-credential', {
+      method: 'PUT',
+      body: JSON.stringify(data)
+    });
+    state.smtpCredential = result.credential;
+    if (state.config?.submission) {
+      state.config.submission.username = result.credential.username;
+      state.config.submission.passwordSet = result.credential.passwordSet;
+    }
+    els.smtpPassword.value = '';
+    renderDefaults();
+    render();
+    toast('SMTP 凭据已保存');
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+function generateSmtpPassword() {
+  const bytes = new Uint8Array(24);
+  crypto.getRandomValues(bytes);
+  const password = btoa(String.fromCharCode(...bytes))
+    .replace(/[+/=]/g, '')
+    .slice(0, 28);
+  els.smtpPassword.type = 'text';
+  els.smtpPassword.value = password;
+  els.smtpPassword.focus();
+  els.smtpPassword.select();
+}
+
+async function handleDetailClick(event) {
+  const action = event.target.closest('[data-action]')?.dataset.action;
+  if (!action) return;
+  if (action === 'check') return checkSelected();
+  if (action === 'copy-all-dns') return copyAllDns();
+  if (action === 'rotate-dkim') return rotateDkim();
+  if (action === 'delete-domain') return deleteSelected();
+}
+
+async function handleCopyClick(event) {
+  const button = event.target.closest('[data-copy]');
+  if (!button) return;
+  event.preventDefault();
+  const value = button.dataset.copy || '';
+  if (!value) return;
+  await navigator.clipboard.writeText(value);
+  toast('已复制');
+}
+
+async function handleDetailSubmit(event) {
+  const form = event.target.closest('form[data-form]');
+  if (!form) return;
+  event.preventDefault();
+  if (form.dataset.form === 'settings') return saveSettings(form);
+  if (form.dataset.form === 'send') return sendTest(form);
+}
+
+async function checkSelected() {
+  const id = state.selectedId;
+  if (!id) return;
+  setBusy(true);
+  try {
+    const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
+    replaceDomain(result.domain);
+    render();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function saveSettings(form) {
+  const id = state.selectedId;
+  const data = Object.fromEntries(new FormData(form).entries());
+  setBusy(true);
+  try {
+    const result = await api(`/api/domains/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data)
+    });
+    replaceDomain(result.domain);
+    render();
+    await checkSelected();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function sendTest(form) {
+  const id = state.selectedId;
+  const data = Object.fromEntries(new FormData(form).entries());
+  setBusy(true);
+  try {
+    await api(`/api/domains/${id}/test-send`, {
+      method: 'POST',
+      body: JSON.stringify(data)
+    });
+    const events = await api('/api/events');
+    state.events = events.events || [];
+    render();
+    toast('已提交到 Postfix 队列');
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function rotateDkim() {
+  const id = state.selectedId;
+  if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
+  setBusy(true);
+  try {
+    const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
+    replaceDomain(result.domain);
+    render();
+    await checkSelected();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function deleteSelected() {
+  const id = state.selectedId;
+  const domain = state.domains.find((item) => item.id === id);
+  if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
+  setBusy(true);
+  try {
+    await api(`/api/domains/${id}`, { method: 'DELETE' });
+    state.domains = state.domains.filter((item) => item.id !== id);
+    state.selectedId = state.domains[0]?.id || null;
+    render();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
+async function copyAllDns() {
+  const domain = state.domains.find((item) => item.id === state.selectedId);
+  const records = domain?.status?.records || [];
+  if (!records.length) return toast('暂无 DNS 记录');
+  const text = records
+    .map((record) => `${record.host}\t${record.type}\t${record.value || ''}`)
+    .join('\n');
+  await navigator.clipboard.writeText(text);
+  toast('已复制全部 DNS 记录');
+}
+
+async function logout() {
+  setBusy(true);
+  try {
+    await fetch('/api/logout', { method: 'POST' });
+  } finally {
+    window.location.href = '/login';
+  }
+}
+
+async function api(path, options = {}) {
+  const response = await fetch(path, {
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...(options.headers || {})
+    }
+  });
+  const text = await response.text();
+  const payload = text ? JSON.parse(text) : {};
+  if (!response.ok) {
+    if (response.status === 401) {
+      window.location.href = '/login';
+      return {};
+    }
+    throw new Error(payload.error || `HTTP ${response.status}`);
+  }
+  return payload;
+}
+
+function replaceDomain(domain) {
+  state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
+}
+
+function statusMeta(target) {
+  if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
+  if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
+  if (target.status === 'missing') return { className: 'missing', label: '缺失' };
+  if (target.status === 'warn') return { className: 'warn', label: '需调整' };
+  return { className: 'warn', label: '待配置' };
+}
+
+function badge(meta) {
+  return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
+}
+
+function setBusy(value) {
+  state.busy = value;
+  document.body.classList.toggle('busy', value);
+  els.refreshButton.disabled = value;
+}
+
+function toast(message) {
+  const node = document.createElement('div');
+  node.className = 'notice';
+  node.textContent = message;
+  node.style.position = 'fixed';
+  node.style.right = '18px';
+  node.style.bottom = '18px';
+  node.style.zIndex = '10';
+  document.body.appendChild(node);
+  setTimeout(() => node.remove(), 2800);
+}
+
+function formatDate(value) {
+  if (!value) return '';
+  return new Intl.DateTimeFormat('zh-CN', {
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  }).format(new Date(value));
+}
+
+function escapeHtml(value) {
+  return String(value ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#039;');
+}
+
+function escapeAttr(value) {
+  return escapeHtml(value).replace(/`/g, '&#096;');
+}

+ 113 - 0
public/index.html

@@ -0,0 +1,113 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>MailHub</title>
+    <link rel="stylesheet" href="/styles.css">
+  </head>
+  <body>
+    <main class="app-shell">
+      <header class="topbar">
+        <div>
+          <h1>MailHub</h1>
+          <p id="runtimeLine">加载中</p>
+        </div>
+        <div class="topbar-actions">
+          <button class="icon-button" id="refreshButton" title="刷新">刷新</button>
+          <button class="icon-button" id="logoutButton" title="退出登录">退出</button>
+        </div>
+      </header>
+
+      <section class="notice hidden" id="securityNotice"></section>
+
+      <div class="workspace">
+        <aside class="sidebar">
+          <form class="panel compact-form" id="addDomainForm">
+            <h2>添加发信域名</h2>
+            <label>
+              域名
+              <input name="domain" placeholder="example.com" autocomplete="off" required>
+            </label>
+            <div class="form-grid">
+              <label>
+                DKIM selector
+                <input name="selector" placeholder="mh202607">
+              </label>
+              <label>
+                DMARC 策略
+                <select name="dmarcPolicy">
+                  <option value="none">none</option>
+                  <option value="quarantine">quarantine</option>
+                  <option value="reject">reject</option>
+                </select>
+              </label>
+            </div>
+            <label>
+              发信主机
+              <input name="senderHost" id="defaultSenderHost" autocomplete="off">
+            </label>
+            <label>
+              发信 IP
+              <input name="sendingIp" id="defaultSendingIp" autocomplete="off">
+            </label>
+            <label>
+              兼容第三方 SPF
+              <textarea name="spfExtra" id="defaultSpfExtra" rows="2"></textarea>
+            </label>
+            <button class="primary-button" type="submit">添加并生成 DNS</button>
+          </form>
+
+          <form class="panel compact-form" id="smtpCredentialForm">
+            <div class="section-head">
+              <h2>SMTP 凭据</h2>
+              <span class="badge idle" id="smtpCredentialState">未加载</span>
+            </div>
+            <label>
+              用户名
+              <input name="username" id="smtpUsername" autocomplete="off" required>
+            </label>
+            <label>
+              新密码
+              <input name="password" id="smtpPassword" type="password" autocomplete="new-password" placeholder="留空则不修改">
+            </label>
+            <div class="button-row">
+              <button class="secondary-button" id="generateSmtpPassword" type="button">生成密码</button>
+              <button class="primary-button" type="submit">保存凭据</button>
+            </div>
+            <div class="credential-copy" id="smtpCredentialCopy"></div>
+            <p class="muted">保存后立即生效,SMTP 客户端下一次认证会使用新凭据。</p>
+          </form>
+
+          <section class="panel domain-panel">
+            <div class="section-head">
+              <h2>域名</h2>
+              <span class="count" id="domainCount">0</span>
+            </div>
+            <div class="domain-list" id="domainList"></div>
+          </section>
+        </aside>
+
+        <section class="main-panel" id="detailPanel">
+          <div class="empty-state">
+            <h2>选择或添加一个域名</h2>
+            <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
+          </div>
+        </section>
+      </div>
+    </main>
+
+    <template id="recordRowTemplate">
+      <tr>
+        <td class="record-label"></td>
+        <td class="mono record-host"></td>
+        <td class="record-type"></td>
+        <td><code class="record-value"></code></td>
+        <td class="record-status"></td>
+        <td><button class="small-button copy-button" type="button">复制</button></td>
+      </tr>
+    </template>
+
+    <script src="/app.js" type="module"></script>
+  </body>
+</html>

+ 214 - 0
public/login.css

@@ -0,0 +1,214 @@
+:root {
+  color-scheme: light;
+  --bg: #eef2f6;
+  --panel: #ffffff;
+  --ink: #19212b;
+  --muted: #667286;
+  --line: #d8dee7;
+  --blue: #1d5fd1;
+  --teal: #087f8c;
+  --green: #16834c;
+  --shadow: 0 22px 60px rgba(24, 33, 43, 0.16);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  align-items: center;
+  background:
+    linear-gradient(135deg, rgba(8, 127, 140, 0.08), rgba(22, 131, 76, 0.05)),
+    var(--bg);
+  color: var(--ink);
+  display: grid;
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  line-height: 1.45;
+  margin: 0;
+  min-height: 100vh;
+  padding: 22px;
+}
+
+.login-shell {
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  box-shadow: var(--shadow);
+  display: grid;
+  grid-template-columns: minmax(320px, 0.85fr) minmax(340px, 1fr);
+  margin: 0 auto;
+  max-width: 980px;
+  min-height: 560px;
+  overflow: hidden;
+  width: 100%;
+}
+
+.brand-pane {
+  background: #12352f;
+  color: #fff;
+  display: grid;
+  gap: 26px;
+  grid-template-rows: auto auto 1fr;
+  padding: 42px;
+}
+
+.brand-mark {
+  align-items: center;
+  background: #e9f3ff;
+  border-radius: 8px;
+  color: #173b78;
+  display: flex;
+  font-weight: 800;
+  height: 56px;
+  justify-content: center;
+  letter-spacing: 0;
+  width: 56px;
+}
+
+.brand-copy {
+  display: grid;
+  gap: 8px;
+}
+
+h1,
+h2,
+p {
+  margin: 0;
+}
+
+h1 {
+  font-size: 36px;
+  letter-spacing: 0;
+}
+
+h2 {
+  font-size: 26px;
+  letter-spacing: 0;
+}
+
+.brand-copy p,
+.login-heading .eyebrow,
+label,
+.login-message {
+  color: var(--muted);
+}
+
+.brand-copy p {
+  color: #b7c8dc;
+  font-size: 15px;
+}
+
+.signal-grid {
+  align-self: end;
+  display: grid;
+  gap: 12px;
+}
+
+.signal-grid div {
+  background: rgba(255, 255, 255, 0.08);
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  border-radius: 8px;
+  display: grid;
+  gap: 4px;
+  padding: 14px;
+}
+
+.signal-grid span {
+  color: #a7bbd1;
+  font-size: 12px;
+}
+
+.signal-grid strong {
+  font-size: 14px;
+  overflow-wrap: anywhere;
+}
+
+.login-card {
+  align-content: center;
+  display: grid;
+  gap: 28px;
+  padding: 48px;
+}
+
+.login-heading {
+  display: grid;
+  gap: 7px;
+}
+
+.eyebrow {
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.login-form {
+  display: grid;
+  gap: 16px;
+}
+
+label {
+  display: grid;
+  font-size: 13px;
+  gap: 7px;
+}
+
+input {
+  background: #fff;
+  border: 1px solid #c6cfdb;
+  border-radius: 6px;
+  color: var(--ink);
+  font: inherit;
+  min-height: 44px;
+  padding: 10px 12px;
+  width: 100%;
+}
+
+input:focus {
+  border-color: var(--blue);
+  outline: 3px solid rgba(29, 95, 209, 0.16);
+}
+
+button {
+  background: var(--blue);
+  border: 0;
+  border-radius: 6px;
+  color: #fff;
+  cursor: pointer;
+  font: inherit;
+  font-weight: 700;
+  min-height: 46px;
+  padding: 0 14px;
+}
+
+button:disabled {
+  cursor: wait;
+  opacity: 0.72;
+}
+
+.login-message {
+  min-height: 20px;
+  font-size: 13px;
+}
+
+.login-message.error {
+  color: #b3261e;
+}
+
+@media (max-width: 780px) {
+  body {
+    padding: 14px;
+  }
+
+  .login-shell {
+    grid-template-columns: 1fr;
+  }
+
+  .brand-pane {
+    padding: 28px;
+  }
+
+  .login-card {
+    padding: 30px;
+  }
+}

+ 54 - 0
public/login.html

@@ -0,0 +1,54 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>登录 MailHub</title>
+    <link rel="stylesheet" href="/login.css">
+  </head>
+  <body>
+    <main class="login-shell">
+      <section class="brand-pane">
+        <div class="brand-mark">MH</div>
+        <div class="brand-copy">
+          <h1>MailHub</h1>
+          <p>发信控制台</p>
+        </div>
+        <div class="signal-grid">
+          <div>
+            <span>HELO</span>
+            <strong>in.ss5.xyz</strong>
+          </div>
+          <div>
+            <span>API</span>
+            <strong>/api/send</strong>
+          </div>
+          <div>
+            <span>DNS</span>
+            <strong>SPF DKIM DMARC</strong>
+          </div>
+        </div>
+      </section>
+
+      <section class="login-card">
+        <div class="login-heading">
+          <span class="eyebrow">Admin</span>
+          <h2>登录控制台</h2>
+        </div>
+        <form id="loginForm" class="login-form">
+          <label>
+            用户名
+            <input name="username" autocomplete="username" required autofocus>
+          </label>
+          <label>
+            密码
+            <input name="password" type="password" autocomplete="current-password" required>
+          </label>
+          <button type="submit">登录</button>
+          <p id="loginMessage" class="login-message" role="alert"></p>
+        </form>
+      </section>
+    </main>
+    <script src="/login.js" type="module"></script>
+  </body>
+</html>

+ 26 - 0
public/login.js

@@ -0,0 +1,26 @@
+const form = document.querySelector('#loginForm');
+const message = document.querySelector('#loginMessage');
+
+form.addEventListener('submit', async (event) => {
+  event.preventDefault();
+  message.textContent = '';
+  message.className = 'login-message';
+  const button = form.querySelector('button');
+  button.disabled = true;
+  try {
+    const payload = Object.fromEntries(new FormData(form).entries());
+    const response = await fetch('/api/login', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok) throw new Error(data.error || '登录失败。');
+    window.location.href = '/';
+  } catch (error) {
+    message.textContent = error.message;
+    message.classList.add('error');
+  } finally {
+    button.disabled = false;
+  }
+});

+ 746 - 0
public/styles.css

@@ -0,0 +1,746 @@
+:root {
+  color-scheme: light;
+  --bg: #f6f7f9;
+  --panel: #ffffff;
+  --ink: #1c2530;
+  --muted: #657183;
+  --line: #d8dde5;
+  --line-strong: #b7c0cc;
+  --blue: #1d5fd1;
+  --green: #16834c;
+  --amber: #9a5a00;
+  --red: #b3261e;
+  --teal: #0b7285;
+  --shadow: 0 12px 35px rgba(31, 45, 61, 0.08);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--ink);
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  line-height: 1.45;
+}
+
+button,
+input,
+textarea,
+select {
+  font: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+.app-shell {
+  min-height: 100vh;
+  padding: 20px;
+}
+
+.topbar {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  margin: 0 auto 16px;
+  max-width: 1480px;
+}
+
+h1,
+h2,
+h3,
+p {
+  margin: 0;
+}
+
+h1 {
+  font-size: 26px;
+  letter-spacing: 0;
+}
+
+h2 {
+  font-size: 16px;
+  letter-spacing: 0;
+}
+
+h3 {
+  font-size: 15px;
+  letter-spacing: 0;
+}
+
+.topbar p,
+.muted {
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.topbar-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.workspace {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: 360px minmax(0, 1fr);
+  margin: 0 auto;
+  max-width: 1480px;
+}
+
+.sidebar {
+  display: grid;
+  gap: 16px;
+  align-content: start;
+}
+
+.panel,
+.main-panel {
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  box-shadow: var(--shadow);
+}
+
+.panel {
+  padding: 16px;
+}
+
+.main-panel {
+  min-height: 720px;
+  overflow: hidden;
+}
+
+.compact-form {
+  display: grid;
+  gap: 12px;
+}
+
+label {
+  color: var(--muted);
+  display: grid;
+  font-size: 12px;
+  gap: 6px;
+}
+
+input,
+textarea,
+select {
+  background: #fff;
+  border: 1px solid var(--line-strong);
+  border-radius: 6px;
+  color: var(--ink);
+  min-width: 0;
+  padding: 9px 10px;
+  width: 100%;
+}
+
+textarea {
+  resize: vertical;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+  border-color: var(--blue);
+  outline: 3px solid rgba(29, 95, 209, 0.15);
+}
+
+.form-grid {
+  display: grid;
+  gap: 10px;
+  grid-template-columns: 1fr 1fr;
+}
+
+.primary-button,
+.secondary-button,
+.small-button,
+.icon-button,
+.danger-button {
+  align-items: center;
+  border-radius: 6px;
+  border: 1px solid transparent;
+  display: inline-flex;
+  justify-content: center;
+  min-height: 38px;
+  padding: 0 12px;
+}
+
+.primary-button {
+  background: var(--blue);
+  color: #fff;
+}
+
+.secondary-button,
+.icon-button {
+  background: #eef3fb;
+  border-color: #c9d7ef;
+  color: #173b78;
+}
+
+.small-button {
+  background: #f8fafc;
+  border-color: var(--line);
+  color: var(--ink);
+  min-height: 30px;
+  padding: 0 9px;
+}
+
+.danger-button {
+  background: #fff5f4;
+  border-color: #f0b6b1;
+  color: var(--red);
+}
+
+.section-head {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.count,
+.badge {
+  border-radius: 999px;
+  display: inline-flex;
+  font-size: 12px;
+  line-height: 1;
+  padding: 5px 8px;
+}
+
+.count {
+  background: #ecf2f8;
+  color: #38516f;
+}
+
+.domain-list {
+  display: grid;
+  gap: 8px;
+}
+
+.domain-item {
+  background: #fbfcfe;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  display: grid;
+  gap: 6px;
+  padding: 10px;
+  text-align: left;
+  width: 100%;
+}
+
+.domain-item.active {
+  border-color: var(--blue);
+  outline: 3px solid rgba(29, 95, 209, 0.12);
+}
+
+.domain-item strong {
+  color: var(--ink);
+  font-size: 14px;
+  overflow-wrap: anywhere;
+}
+
+.item-line {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.badge.ok {
+  background: #e8f6ee;
+  color: var(--green);
+}
+
+.badge.warn {
+  background: #fff4df;
+  color: var(--amber);
+}
+
+.badge.missing,
+.badge.failed {
+  background: #fff0ee;
+  color: var(--red);
+}
+
+.badge.idle {
+  background: #edf1f6;
+  color: #506070;
+}
+
+.detail-header {
+  border-bottom: 1px solid var(--line);
+  display: flex;
+  justify-content: space-between;
+  gap: 18px;
+  padding: 18px;
+}
+
+.detail-title {
+  display: grid;
+  gap: 8px;
+}
+
+.detail-title h2 {
+  font-size: 22px;
+  overflow-wrap: anywhere;
+}
+
+.detail-actions {
+  align-items: start;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  justify-content: end;
+}
+
+.status-strip {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.detail-body {
+  display: grid;
+  gap: 18px;
+  padding: 18px;
+}
+
+.grid-two {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
+}
+
+.subpanel {
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 14px;
+}
+
+.subpanel > h3 {
+  margin-bottom: 12px;
+}
+
+.subpanel-head {
+  align-items: center;
+  display: flex;
+  gap: 10px;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.subpanel-head h3 {
+  margin: 0;
+}
+
+.guide-hero {
+  align-items: stretch;
+  background: #ffffff;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  display: grid;
+  gap: 18px;
+  grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
+  padding: 16px;
+}
+
+.guide-hero h3 {
+  font-size: 24px;
+  margin: 4px 0;
+  overflow-wrap: anywhere;
+}
+
+.guide-hero p {
+  color: var(--muted);
+  font-size: 13px;
+  overflow-wrap: anywhere;
+}
+
+.eyebrow {
+  color: var(--teal);
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.summary-grid {
+  display: grid;
+  gap: 10px;
+  grid-template-columns: repeat(5, minmax(92px, 1fr));
+}
+
+.metric-card {
+  background: #f8fafc;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  display: grid;
+  gap: 7px;
+  min-height: 78px;
+  padding: 12px;
+}
+
+.metric-card span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.metric-card strong {
+  font-size: 14px;
+  overflow-wrap: anywhere;
+}
+
+.empty-guide {
+  align-items: center;
+  background: #f8fafc;
+  border: 1px dashed var(--line-strong);
+  border-radius: 8px;
+  display: grid;
+  gap: 6px;
+  min-height: 190px;
+  padding: 20px;
+  text-align: center;
+}
+
+.empty-guide h4,
+.record-card h4 {
+  font-size: 15px;
+  letter-spacing: 0;
+  margin: 0;
+}
+
+.empty-guide p {
+  color: var(--muted);
+  font-size: 13px;
+  margin: 0;
+}
+
+.record-steps {
+  display: grid;
+  gap: 12px;
+}
+
+.record-card {
+  background: #fff;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  display: grid;
+  gap: 12px;
+  grid-template-columns: 34px minmax(0, 1fr);
+  padding: 12px;
+}
+
+.record-card.ok {
+  border-color: #a8d9bd;
+}
+
+.record-card.warn {
+  border-color: #f0d49e;
+}
+
+.record-card.missing,
+.record-card.failed {
+  border-color: #efb1ac;
+}
+
+.record-step span {
+  align-items: center;
+  background: #ecf2f8;
+  border-radius: 999px;
+  color: #38516f;
+  display: flex;
+  font-size: 12px;
+  font-weight: 800;
+  height: 28px;
+  justify-content: center;
+  width: 28px;
+}
+
+.record-card-body {
+  display: grid;
+  gap: 10px;
+  min-width: 0;
+}
+
+.record-card-title {
+  align-items: start;
+  display: flex;
+  gap: 10px;
+  justify-content: space-between;
+}
+
+.record-card-title p {
+  color: var(--muted);
+  font-size: 12px;
+  margin-top: 4px;
+  overflow-wrap: anywhere;
+}
+
+.dns-value,
+.dns-current {
+  display: grid;
+  gap: 7px;
+}
+
+.dns-value > span,
+.dns-current > span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.dns-value .small-button {
+  justify-self: start;
+}
+
+.inline-warnings {
+  display: grid;
+  gap: 6px;
+  margin: 0;
+  padding: 0;
+}
+
+.inline-warnings li {
+  background: #fff8ea;
+  border: 1px solid #f1d49d;
+  border-radius: 6px;
+  color: #684000;
+  font-size: 12px;
+  list-style: none;
+  padding: 8px 9px;
+}
+
+.record-table-wrap {
+  overflow-x: auto;
+}
+
+table {
+  border-collapse: collapse;
+  width: 100%;
+}
+
+th,
+td {
+  border-bottom: 1px solid var(--line);
+  font-size: 13px;
+  padding: 10px 8px;
+  text-align: left;
+  vertical-align: top;
+}
+
+th {
+  color: var(--muted);
+  font-weight: 600;
+}
+
+code,
+.mono {
+  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+  font-size: 12px;
+}
+
+code {
+  background: #f3f6fa;
+  border: 1px solid #e0e7f1;
+  border-radius: 6px;
+  display: block;
+  max-width: 780px;
+  overflow-wrap: anywhere;
+  padding: 7px;
+  white-space: normal;
+}
+
+.warning-list {
+  display: grid;
+  gap: 8px;
+  margin: 0;
+  padding: 0;
+}
+
+.warning-list li {
+  background: #fff8ea;
+  border: 1px solid #f1d49d;
+  border-radius: 6px;
+  color: #684000;
+  list-style: none;
+  padding: 9px 10px;
+}
+
+.live-list {
+  display: grid;
+  gap: 10px;
+}
+
+.live-row {
+  display: grid;
+  gap: 5px;
+}
+
+.live-row span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.send-form {
+  display: grid;
+  gap: 10px;
+}
+
+.button-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.credential-copy {
+  display: grid;
+  gap: 8px;
+}
+
+.copy-row {
+  align-items: start;
+  display: grid;
+  gap: 7px;
+  grid-template-columns: minmax(72px, auto) minmax(0, 1fr) auto;
+}
+
+.copy-row.inline {
+  grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.copy-row > span {
+  color: var(--muted);
+  font-size: 12px;
+  padding-top: 7px;
+}
+
+.copy-row > p {
+  grid-column: 2 / -1;
+  padding-top: 7px;
+}
+
+.notice {
+  background: #fff5f4;
+  border: 1px solid #f0b6b1;
+  border-radius: 8px;
+  color: var(--red);
+  margin: 0 auto 16px;
+  max-width: 1480px;
+  padding: 10px 12px;
+}
+
+.hidden {
+  display: none;
+}
+
+.empty-state {
+  align-content: center;
+  display: grid;
+  gap: 8px;
+  justify-items: center;
+  min-height: 720px;
+  padding: 20px;
+  text-align: center;
+}
+
+.empty-state p {
+  color: var(--muted);
+}
+
+.api-box {
+  display: grid;
+  gap: 10px;
+}
+
+.api-box pre {
+  background: #101820;
+  border-radius: 8px;
+  color: #e9f2ff;
+  margin: 0;
+  overflow-x: auto;
+  padding: 12px;
+}
+
+.event-list {
+  display: grid;
+  gap: 8px;
+}
+
+.smtp-grid {
+  display: grid;
+  gap: 10px;
+}
+
+.smtp-row {
+  display: grid;
+  gap: 6px;
+}
+
+.smtp-row span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.event-row {
+  border-bottom: 1px solid var(--line);
+  display: grid;
+  gap: 4px;
+  padding: 8px 0;
+}
+
+.event-row:last-child {
+  border-bottom: 0;
+}
+
+@media (max-width: 1080px) {
+  .workspace,
+  .grid-two,
+  .guide-hero {
+    grid-template-columns: 1fr;
+  }
+
+  .summary-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .main-panel {
+    min-height: 520px;
+  }
+}
+
+@media (max-width: 640px) {
+  .app-shell {
+    padding: 12px;
+  }
+
+  .topbar,
+  .detail-header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .detail-actions {
+    justify-content: start;
+  }
+
+  .form-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .summary-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .record-card {
+    grid-template-columns: 1fr;
+  }
+
+  .record-card-title {
+    align-items: stretch;
+    flex-direction: column;
+  }
+}

+ 377 - 0
src/db.js

@@ -0,0 +1,377 @@
+import { mkdirSync } from 'node:fs';
+import crypto from 'node:crypto';
+import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+
+let db;
+let credentialSecret = '';
+
+export function initDatabase(dataDir, secret = '') {
+  credentialSecret = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
+  mkdirSync(dataDir, { recursive: true });
+  const dbPath = path.join(dataDir, 'mailhub.sqlite');
+  db = new DatabaseSync(dbPath);
+  db.exec(`
+    PRAGMA journal_mode = WAL;
+    PRAGMA foreign_keys = ON;
+
+    CREATE TABLE IF NOT EXISTS domains (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain TEXT NOT NULL UNIQUE,
+      selector TEXT NOT NULL,
+      verification_token TEXT NOT NULL,
+      dkim_public TEXT NOT NULL,
+      dkim_private TEXT NOT NULL,
+      sender_host TEXT NOT NULL,
+      sending_ip TEXT NOT NULL,
+      spf_extra TEXT NOT NULL DEFAULT '',
+      dmarc_policy TEXT NOT NULL DEFAULT 'none',
+      dmarc_rua TEXT NOT NULL DEFAULT '',
+      status_json TEXT NOT NULL DEFAULT '{}',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS send_events (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain_id INTEGER,
+      sender TEXT NOT NULL,
+      recipients TEXT NOT NULL,
+      subject TEXT NOT NULL,
+      status TEXT NOT NULL,
+      detail TEXT NOT NULL DEFAULT '',
+      created_at TEXT NOT NULL,
+      FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS smtp_credentials (
+      id INTEGER PRIMARY KEY CHECK (id = 1),
+      username TEXT NOT NULL,
+      password_hash TEXT NOT NULL,
+      password_secret TEXT NOT NULL DEFAULT '',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+  `);
+  ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
+  return db;
+}
+
+export function seedSmtpCredential(username, password) {
+  if (!username || !password) return null;
+  const existing = getSmtpCredential({ includeHash: true });
+  if (existing) return existing;
+  const createdAt = now();
+  requireDb()
+    .prepare(`
+      INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
+      VALUES (1, ?, ?, ?, ?, ?)
+    `)
+    .run(username, hashPassword(password), encryptPassword(password), createdAt, createdAt);
+  return getSmtpCredential();
+}
+
+function requireDb() {
+  if (!db) throw new Error('Database is not initialized.');
+  return db;
+}
+
+function now() {
+  return new Date().toISOString();
+}
+
+function ensureColumn(table, column, definition) {
+  const exists = requireDb()
+    .prepare(`PRAGMA table_info(${table})`)
+    .all()
+    .some((row) => row.name === column);
+  if (!exists) {
+    requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
+  }
+}
+
+function publicDomainRow(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    domain: row.domain,
+    selector: row.selector,
+    verificationToken: row.verification_token,
+    dkimPublic: row.dkim_public,
+    senderHost: row.sender_host,
+    sendingIp: row.sending_ip,
+    spfExtra: row.spf_extra,
+    dmarcPolicy: row.dmarc_policy,
+    dmarcRua: row.dmarc_rua,
+    status: safeJson(row.status_json, {}),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function privateDomainRow(row) {
+  const publicRow = publicDomainRow(row);
+  if (!publicRow) return null;
+  return {
+    ...publicRow,
+    dkimPrivate: row.dkim_private
+  };
+}
+
+function safeJson(value, fallback) {
+  try {
+    return JSON.parse(value);
+  } catch {
+    return fallback;
+  }
+}
+
+export function listDomains() {
+  const rows = requireDb()
+    .prepare('SELECT * FROM domains ORDER BY created_at DESC')
+    .all();
+  return rows.map(publicDomainRow);
+}
+
+export function getDomain(id, { includePrivate = false } = {}) {
+  const row = requireDb()
+    .prepare('SELECT * FROM domains WHERE id = ?')
+    .get(id);
+  return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
+}
+
+export function getDomainByName(domain, { includePrivate = false } = {}) {
+  const row = requireDb()
+    .prepare('SELECT * FROM domains WHERE domain = ?')
+    .get(domain);
+  return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
+}
+
+export function createDomain(domain) {
+  const createdAt = now();
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO domains (
+        domain, selector, verification_token, dkim_public, dkim_private,
+        sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua,
+        status_json, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
+    `)
+    .run(
+      domain.domain,
+      domain.selector,
+      domain.verificationToken,
+      domain.dkimPublic,
+      domain.dkimPrivate,
+      domain.senderHost,
+      domain.sendingIp,
+      domain.spfExtra,
+      domain.dmarcPolicy,
+      domain.dmarcRua,
+      createdAt,
+      createdAt
+    );
+  return getDomain(result.lastInsertRowid);
+}
+
+export function updateDomain(id, patch) {
+  const current = getDomain(id, { includePrivate: true });
+  if (!current) return null;
+  const next = {
+    selector: patch.selector ?? current.selector,
+    senderHost: patch.senderHost ?? current.senderHost,
+    sendingIp: patch.sendingIp ?? current.sendingIp,
+    spfExtra: patch.spfExtra ?? current.spfExtra,
+    dmarcPolicy: patch.dmarcPolicy ?? current.dmarcPolicy,
+    dmarcRua: patch.dmarcRua ?? current.dmarcRua,
+    updatedAt: now()
+  };
+  requireDb()
+    .prepare(`
+      UPDATE domains
+      SET selector = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
+          dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
+      WHERE id = ?
+    `)
+    .run(
+      next.selector,
+      next.senderHost,
+      next.sendingIp,
+      next.spfExtra,
+      next.dmarcPolicy,
+      next.dmarcRua,
+      next.updatedAt,
+      id
+    );
+  return getDomain(id);
+}
+
+export function updateDkim(id, keys, selector) {
+  requireDb()
+    .prepare(`
+      UPDATE domains
+      SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ?
+      WHERE id = ?
+    `)
+    .run(selector, keys.publicKey, keys.privateKey, now(), id);
+  return getDomain(id);
+}
+
+export function saveDomainStatus(id, status) {
+  requireDb()
+    .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ?')
+    .run(JSON.stringify(status), now(), id);
+}
+
+export function deleteDomain(id) {
+  const result = requireDb().prepare('DELETE FROM domains WHERE id = ?').run(id);
+  return result.changes > 0;
+}
+
+export function logSendEvent(event) {
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `)
+    .run(
+      event.domainId ?? null,
+      event.sender,
+      JSON.stringify(event.recipients),
+      event.subject,
+      event.status,
+      event.detail ?? '',
+      now()
+    );
+  return result.lastInsertRowid;
+}
+
+export function listSendEvents(limit = 30) {
+  return requireDb()
+    .prepare(`
+      SELECT e.*, d.domain
+      FROM send_events e
+      LEFT JOIN domains d ON d.id = e.domain_id
+      ORDER BY e.created_at DESC
+      LIMIT ?
+    `)
+    .all(limit)
+    .map((row) => ({
+      id: row.id,
+      domainId: row.domain_id,
+      domain: row.domain,
+      sender: row.sender,
+      recipients: safeJson(row.recipients, []),
+      subject: row.subject,
+      status: row.status,
+      detail: row.detail,
+      createdAt: row.created_at
+    }));
+}
+
+export function getSmtpCredential({ includeHash = false, includePassword = false, includeSecret = false } = {}) {
+  const row = requireDb()
+    .prepare('SELECT * FROM smtp_credentials WHERE id = 1')
+    .get();
+  if (!row) return null;
+  const password = includePassword ? decryptPassword(row.password_secret) : '';
+  const passwordRecoverable = Boolean(row.password_secret && (password || decryptPassword(row.password_secret)));
+  return {
+    username: row.username,
+    passwordSet: Boolean(row.password_hash),
+    passwordRecoverable,
+    ...(includePassword ? { password } : {}),
+    ...(includeHash ? { passwordHash: row.password_hash } : {}),
+    ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+export function saveSmtpCredential({ username, password }) {
+  const current = getSmtpCredential({ includeHash: true, includeSecret: true });
+  const nextUsername = String(username || current?.username || '').trim();
+  if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
+  const nextHash = password ? hashPassword(password) : current?.passwordHash;
+  if (!nextHash) throw new Error('SMTP 密码不能为空。');
+  const nextSecret = password ? encryptPassword(password) : current?.passwordSecret || '';
+  const updatedAt = now();
+  if (current) {
+    requireDb()
+      .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE id = 1')
+      .run(nextUsername, nextHash, nextSecret, updatedAt);
+  } else {
+    requireDb()
+      .prepare('INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?)')
+      .run(nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
+  }
+  return getSmtpCredential();
+}
+
+export function verifySmtpCredential(username, password) {
+  const credential = getSmtpCredential({ includeHash: true });
+  if (!credential?.passwordHash) return false;
+  if (!safeEqual(username, credential.username)) return false;
+  return verifyPassword(password, credential.passwordHash);
+}
+
+function hashPassword(password) {
+  const salt = crypto.randomBytes(16).toString('hex');
+  const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
+  return `scrypt$${salt}$${hash}`;
+}
+
+function encryptPassword(password) {
+  if (!password) return '';
+  const key = credentialKey();
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+  const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()]);
+  const tag = cipher.getAuthTag();
+  return [
+    'v1',
+    iv.toString('base64url'),
+    tag.toString('base64url'),
+    encrypted.toString('base64url')
+  ].join('$');
+}
+
+function decryptPassword(secret) {
+  const [version, ivRaw, tagRaw, encryptedRaw] = String(secret || '').split('$');
+  if (version !== 'v1' || !ivRaw || !tagRaw || !encryptedRaw) return '';
+  try {
+    const decipher = crypto.createDecipheriv(
+      'aes-256-gcm',
+      credentialKey(),
+      Buffer.from(ivRaw, 'base64url')
+    );
+    decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
+    return Buffer.concat([
+      decipher.update(Buffer.from(encryptedRaw, 'base64url')),
+      decipher.final()
+    ]).toString('utf8');
+  } catch {
+    return '';
+  }
+}
+
+function credentialKey() {
+  return crypto
+    .createHash('sha256')
+    .update(credentialSecret || 'mailhub-local-credential-secret')
+    .digest();
+}
+
+function verifyPassword(password, stored) {
+  const [scheme, salt, hash] = String(stored || '').split('$');
+  if (scheme !== 'scrypt' || !salt || !hash) return false;
+  const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
+  return safeEqual(actual, hash);
+}
+
+function safeEqual(actual, expected) {
+  const a = Buffer.from(String(actual || ''));
+  const b = Buffer.from(String(expected || ''));
+  if (a.length !== b.length) return false;
+  return crypto.timingSafeEqual(a, b);
+}

+ 126 - 0
src/dkim.js

@@ -0,0 +1,126 @@
+import crypto from 'node:crypto';
+
+export function createDkimKeyPair() {
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    publicKeyEncoding: {
+      type: 'spki',
+      format: 'pem'
+    },
+    privateKeyEncoding: {
+      type: 'pkcs8',
+      format: 'pem'
+    }
+  });
+  return {
+    publicKey: pemToDkimPublic(publicKey),
+    privateKey
+  };
+}
+
+export function pemToDkimPublic(pem) {
+  return pem
+    .replace(/-----BEGIN PUBLIC KEY-----/g, '')
+    .replace(/-----END PUBLIC KEY-----/g, '')
+    .replace(/\s+/g, '');
+}
+
+export function buildDkimRecord(publicKey) {
+  return `v=DKIM1; k=rsa; p=${publicKey}`;
+}
+
+export function signDkim(rawMessage, options) {
+  const headers = parseHeaders(rawMessage);
+  const body = rawMessage.slice(rawMessage.indexOf('\r\n\r\n') + 4);
+  const signedHeaderNames = [
+    'from',
+    'to',
+    'subject',
+    'date',
+    'message-id',
+    'mime-version',
+    'content-type'
+  ];
+  const bodyHash = crypto
+    .createHash('sha256')
+    .update(canonicalizeBody(body))
+    .digest('base64');
+
+  const signatureFields = [
+    'v=1',
+    'a=rsa-sha256',
+    'c=relaxed/relaxed',
+    `d=${options.domain}`,
+    `s=${options.selector}`,
+    `h=${signedHeaderNames.join(':')}`,
+    `bh=${bodyHash}`,
+    'b='
+  ];
+  const dkimValueWithoutSignature = signatureFields.join('; ');
+  const signingInput = [
+    ...signedHeaderNames.map((name) => canonicalizeHeader(findHeader(headers, name))),
+    canonicalizeHeader({ name: 'DKIM-Signature', value: dkimValueWithoutSignature })
+  ].join('');
+
+  const signature = crypto
+    .createSign('RSA-SHA256')
+    .update(signingInput)
+    .sign(options.privateKey, 'base64');
+
+  const folded = foldHeader('DKIM-Signature', `${dkimValueWithoutSignature}${signature}`);
+  return `${folded}\r\n${rawMessage}`;
+}
+
+function parseHeaders(rawMessage) {
+  const head = rawMessage.slice(0, rawMessage.indexOf('\r\n\r\n'));
+  const lines = head.split('\r\n');
+  const headers = [];
+  for (const line of lines) {
+    if (/^[\t ]/.test(line) && headers.length) {
+      headers[headers.length - 1].value += ` ${line.trim()}`;
+      continue;
+    }
+    const index = line.indexOf(':');
+    if (index === -1) continue;
+    headers.push({
+      name: line.slice(0, index),
+      value: line.slice(index + 1)
+    });
+  }
+  return headers;
+}
+
+function findHeader(headers, name) {
+  const found = [...headers].reverse().find((header) => header.name.toLowerCase() === name);
+  if (!found) return { name, value: '' };
+  return found;
+}
+
+function canonicalizeHeader(header) {
+  const name = header.name.toLowerCase();
+  const value = header.value.replace(/\s+/g, ' ').trim();
+  return `${name}:${value}\r\n`;
+}
+
+function canonicalizeBody(body) {
+  const normalized = body.replace(/\r?\n/g, '\r\n');
+  return `${normalized.replace(/(\r\n)*$/g, '')}\r\n`;
+}
+
+export function foldHeader(name, value) {
+  const prefix = `${name}: `;
+  const limit = 76;
+  const words = value.split(' ');
+  const lines = [];
+  let current = prefix;
+  for (const word of words) {
+    if ((current + word).length > limit && current.trim() !== `${name}:`) {
+      lines.push(current.trimEnd());
+      current = ` ${word} `;
+    } else {
+      current += `${word} `;
+    }
+  }
+  lines.push(current.trimEnd());
+  return lines.join('\r\n');
+}

+ 336 - 0
src/dns-guide.js

@@ -0,0 +1,336 @@
+import dns from 'node:dns';
+import { buildDkimRecord } from './dkim.js';
+
+const resolver = new dns.promises.Resolver();
+resolver.setServers(
+  String(process.env.DNS_RESOLVERS || '1.1.1.1,8.8.8.8')
+    .split(',')
+    .map((server) => server.trim())
+    .filter(Boolean)
+);
+
+export async function buildDnsGuide(domain) {
+  const live = await readLiveDns(domain);
+  const requiredSpf = buildRequiredSpfMechanisms(domain);
+  const spf = mergeSpfRecords(live.rootTxt.filter(isSpfRecord), requiredSpf);
+  const dmarc = mergeDmarcRecord(live.dmarcTxt.find(isDmarcRecord), domain);
+  const verificationValue = `mailhub-verification=${domain.verificationToken}`;
+  const dkimValue = buildDkimRecord(domain.dkimPublic);
+  const records = [
+    {
+      key: 'verification',
+      label: '域名验证',
+      host: `_mailhub.${domain.domain}`,
+      type: 'TXT',
+      value: verificationValue,
+      status: containsTxt(live.verificationTxt, verificationValue) ? 'ok' : 'missing'
+    },
+    {
+      key: 'dkim',
+      label: 'DKIM',
+      host: `${domain.selector}._domainkey.${domain.domain}`,
+      type: 'TXT',
+      value: dkimValue,
+      status: containsTxt(live.dkimTxt, dkimValue) ? 'ok' : 'missing'
+    },
+    {
+      key: 'spf',
+      label: 'SPF',
+      host: domain.domain,
+      type: 'TXT',
+      value: spf.recommended,
+      status: spf.ok ? 'ok' : 'warn',
+      current: spf.current,
+      warnings: spf.warnings
+    },
+    {
+      key: 'dmarc',
+      label: 'DMARC',
+      host: `_dmarc.${domain.domain}`,
+      type: 'TXT',
+      value: dmarc.recommended,
+      status: dmarc.ok ? 'ok' : 'warn',
+      current: dmarc.current,
+      warnings: dmarc.warnings
+    },
+    {
+      key: 'sender-a',
+      label: '发信主机 A 记录',
+      host: domain.senderHost,
+      type: 'A',
+      value: domain.sendingIp,
+      status: live.senderA.includes(domain.sendingIp) ? 'ok' : 'warn',
+      current: live.senderA.join(', '),
+      warnings: live.senderA.includes(domain.sendingIp)
+        ? []
+        : [`${domain.senderHost} 当前未解析到 ${domain.sendingIp},HELO/PTR/SPF 会出现不一致。`]
+    },
+    {
+      key: 'ptr',
+      label: 'PTR 反向解析',
+      host: domain.sendingIp,
+      type: 'PTR',
+      value: domain.senderHost,
+      status: live.ptr.includes(domain.senderHost) ? 'ok' : 'warn',
+      current: live.ptr.join(', '),
+      warnings: live.ptr.includes(domain.senderHost)
+        ? []
+        : ['PTR 需要在云服务器或 IP 服务商控制台设置,普通 DNS 控制台通常不能修改。']
+    }
+  ];
+
+  const okKeys = new Set(records.filter((record) => record.status === 'ok').map((record) => record.key));
+  const verified = okKeys.has('verification') && okKeys.has('dkim') && okKeys.has('spf') && okKeys.has('dmarc');
+
+  return {
+    checkedAt: new Date().toISOString(),
+    verified,
+    records,
+    live,
+    requiredSpf,
+    optionalRecords: buildOptionalRecords(domain),
+    warnings: collectWarnings(records, spf, dmarc, live)
+  };
+}
+
+async function readLiveDns(domain) {
+  const [
+    rootTxt,
+    verificationTxt,
+    dkimTxt,
+    dmarcTxt,
+    senderA,
+    ptr
+  ] = await Promise.all([
+    resolveTxt(domain.domain),
+    resolveTxt(`_mailhub.${domain.domain}`),
+    resolveTxt(`${domain.selector}._domainkey.${domain.domain}`),
+    resolveTxt(`_dmarc.${domain.domain}`),
+    resolve4(domain.senderHost),
+    resolvePtr(domain.sendingIp)
+  ]);
+  return {
+    rootTxt,
+    verificationTxt,
+    dkimTxt,
+    dmarcTxt,
+    senderA,
+    ptr
+  };
+}
+
+async function resolveTxt(name) {
+  try {
+    const rows = await resolver.resolveTxt(name);
+    return rows.map((parts) => parts.join(''));
+  } catch (error) {
+    if (['ENODATA', 'ENOTFOUND', 'SERVFAIL', 'ETIMEOUT'].includes(error.code)) return [];
+    return [`DNS lookup failed: ${error.code || error.message}`];
+  }
+}
+
+async function resolve4(name) {
+  try {
+    return await resolver.resolve4(name);
+  } catch {
+    return [];
+  }
+}
+
+async function resolvePtr(ip) {
+  try {
+    return await resolver.reverse(ip);
+  } catch {
+    return [];
+  }
+}
+
+function containsTxt(records, expected) {
+  return records.some((record) => normalizeTxt(record) === normalizeTxt(expected));
+}
+
+function normalizeTxt(value) {
+  return String(value).replace(/\s+/g, ' ').trim();
+}
+
+function isSpfRecord(value) {
+  return /^v=spf1(?:\s|$)/i.test(value.trim());
+}
+
+function isDmarcRecord(value) {
+  return /^v=DMARC1(?:;|\s|$)/i.test(value.trim());
+}
+
+function buildRequiredSpfMechanisms(domain) {
+  const mechanisms = [];
+  if (domain.sendingIp) mechanisms.push(`ip4:${domain.sendingIp}`);
+  if (domain.senderHost) mechanisms.push(`a:${domain.senderHost}`);
+  mechanisms.push(...splitMechanisms(domain.spfExtra));
+  return uniqueMechanisms(mechanisms);
+}
+
+function splitMechanisms(value) {
+  return String(value || '')
+    .split(/[\s,]+/)
+    .map((item) => item.trim())
+    .filter(Boolean);
+}
+
+export function mergeSpfRecords(existingRecords, requiredMechanisms) {
+  const warnings = [];
+  const current = existingRecords.map(normalizeTxt);
+  if (current.length > 1) {
+    warnings.push('当前域名存在多条 SPF TXT,收件方会判定 SPF permerror;需要合并为一条。');
+  }
+  if (current.length === 0) {
+    const recommended = `v=spf1 ${requiredMechanisms.join(' ')} ~all`.replace(/\s+/g, ' ').trim();
+    warnings.push('当前没有 SPF 记录。');
+    return {
+      current,
+      recommended,
+      ok: false,
+      warnings: withLookupWarning(warnings, recommended)
+    };
+  }
+
+  const parsed = current.map(parseSpf);
+  const mechanisms = [];
+  for (const record of parsed) mechanisms.push(...record.mechanisms);
+  mechanisms.push(...requiredMechanisms);
+
+  const all = parsed.find((record) => record.all === '-all')?.all
+    || parsed.find((record) => record.all === '~all')?.all
+    || parsed.find((record) => record.all === '?all')?.all
+    || '~all';
+  const recommended = `v=spf1 ${uniqueMechanisms(mechanisms).join(' ')} ${all}`
+    .replace(/\s+/g, ' ')
+    .trim();
+  const ok = current.length === 1
+    && normalizeTxt(current[0]) === recommended
+    && requiredMechanisms.every((mechanism) => hasMechanism(current[0], mechanism));
+
+  return {
+    current,
+    recommended,
+    ok,
+    warnings: withLookupWarning(warnings, recommended)
+  };
+}
+
+function parseSpf(record) {
+  const tokens = normalizeTxt(record).split(/\s+/).slice(1);
+  const mechanisms = [];
+  let all = '~all';
+  for (const token of tokens) {
+    if (/^[+\-~?]?all$/i.test(token)) {
+      all = token;
+    } else if (token) {
+      mechanisms.push(token);
+    }
+  }
+  return { mechanisms, all };
+}
+
+function uniqueMechanisms(items) {
+  const seen = new Set();
+  const output = [];
+  for (const item of items) {
+    const normalized = normalizeMechanism(item);
+    if (!normalized || seen.has(normalized)) continue;
+    seen.add(normalized);
+    output.push(item.replace(/^\+/, ''));
+  }
+  return output;
+}
+
+function normalizeMechanism(item) {
+  return String(item || '').trim().replace(/^\+/, '').toLowerCase();
+}
+
+function hasMechanism(record, mechanism) {
+  const normalized = normalizeMechanism(mechanism);
+  return parseSpf(record).mechanisms.some((item) => normalizeMechanism(item) === normalized);
+}
+
+function withLookupWarning(warnings, spf) {
+  const lookupCount = (spf.match(/\b(include|a|mx|ptr|exists|redirect)[=:]?/g) || []).length;
+  if (lookupCount > 10) {
+    return [...warnings, `SPF DNS 查询项约为 ${lookupCount} 个,超过 10 个会失败;建议减少 include 或改用专用子域。`];
+  }
+  if (lookupCount >= 8) {
+    return [...warnings, `SPF DNS 查询项约为 ${lookupCount} 个,接近 10 个上限。`];
+  }
+  return warnings;
+}
+
+export function mergeDmarcRecord(existingRecord, domain) {
+  const current = existingRecord ? normalizeTxt(existingRecord) : '';
+  const warnings = [];
+  const tags = parseDmarc(current);
+  if (!current) warnings.push('当前没有 DMARC 记录。');
+  tags.set('v', 'DMARC1');
+  tags.set('p', domain.dmarcPolicy || tags.get('p') || 'none');
+  tags.set('adkim', tags.get('adkim') || 's');
+  tags.set('aspf', tags.get('aspf') || 's');
+  tags.set('pct', tags.get('pct') || '100');
+  const rua = domain.dmarcRua || tags.get('rua') || `mailto:dmarc@${domain.domain}`;
+  if (rua) tags.set('rua', rua);
+  const order = ['v', 'p', 'rua', 'ruf', 'adkim', 'aspf', 'pct', 'fo'];
+  const recommended = [
+    ...order.filter((key) => tags.has(key)).map((key) => `${key}=${tags.get(key)}`),
+    ...[...tags.entries()]
+      .filter(([key]) => !order.includes(key))
+      .map(([key, value]) => `${key}=${value}`)
+  ].join('; ');
+  return {
+    current: current ? [current] : [],
+    recommended,
+    ok: current === recommended,
+    warnings
+  };
+}
+
+function parseDmarc(record) {
+  const tags = new Map();
+  for (const part of String(record || '').split(';')) {
+    const [key, ...rest] = part.trim().split('=');
+    if (!key || !rest.length) continue;
+    tags.set(key.toLowerCase(), rest.join('=').trim());
+  }
+  return tags;
+}
+
+function buildOptionalRecords(domain) {
+  return [
+    {
+      label: 'TLS-RPT',
+      host: `_smtp._tls.${domain.domain}`,
+      type: 'TXT',
+      value: `v=TLSRPTv1; rua=mailto:tlsrpt@${domain.domain}`
+    },
+    {
+      label: 'MTA-STS',
+      host: `_mta-sts.${domain.domain}`,
+      type: 'TXT',
+      value: 'v=STSv1; id=2026070701'
+    },
+    {
+      label: 'BIMI',
+      host: `default._bimi.${domain.domain}`,
+      type: 'TXT',
+      value: `v=BIMI1; l=https://${domain.domain}/bimi.svg`
+    }
+  ];
+}
+
+function collectWarnings(records, spf, dmarc, live) {
+  const warnings = [
+    ...records.flatMap((record) => record.warnings || []),
+    ...spf.warnings,
+    ...dmarc.warnings
+  ];
+  if (live.rootTxt.filter(isSpfRecord).length > 1) {
+    warnings.push('SPF 必须只有一条 TXT;不要新增第二条 v=spf1。');
+  }
+  return [...new Set(warnings)];
+}

+ 227 - 0
src/mailer.js

@@ -0,0 +1,227 @@
+import net from 'node:net';
+import tls from 'node:tls';
+import crypto from 'node:crypto';
+import { signDkim } from './dkim.js';
+
+export function parseAddressList(value) {
+  return String(value || '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map(extractAddress)
+    .filter(Boolean);
+}
+
+export function extractAddress(value) {
+  const match = String(value || '').match(/<([^<>@\s]+@[^<>@\s]+)>/);
+  const address = match ? match[1] : String(value || '').trim();
+  if (!/^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/.test(address)) return '';
+  return address.toLowerCase();
+}
+
+export function domainFromAddress(value) {
+  const address = extractAddress(value);
+  return address.split('@')[1] || '';
+}
+
+export function buildMessage({ from, to, subject, text, html, baseUrl }) {
+  const recipients = Array.isArray(to) ? to : parseAddressList(to);
+  if (!recipients.length) throw new Error('At least one recipient is required.');
+  const messageIdHost = domainFromAddress(from) || 'localhost';
+  const messageId = `<${crypto.randomUUID()}@${messageIdHost}>`;
+  const commonHeaders = [
+    ['From', sanitizeHeader(from)],
+    ['To', recipients.join(', ')],
+    ['Subject', encodeHeader(subject || '(no subject)')],
+    ['Date', new Date().toUTCString()],
+    ['Message-ID', messageId],
+    ['MIME-Version', '1.0'],
+    ['X-MailHub', baseUrl || 'mailhub']
+  ];
+
+  if (html) {
+    const boundary = `mailhub-${crypto.randomBytes(12).toString('hex')}`;
+    const headers = [
+      ...commonHeaders,
+      ['Content-Type', `multipart/alternative; boundary="${boundary}"`]
+    ];
+    const body = [
+      `--${boundary}`,
+      'Content-Type: text/plain; charset=UTF-8',
+      'Content-Transfer-Encoding: 8bit',
+      '',
+      normalizeBody(text || stripHtml(html)),
+      `--${boundary}`,
+      'Content-Type: text/html; charset=UTF-8',
+      'Content-Transfer-Encoding: 8bit',
+      '',
+      normalizeBody(html),
+      `--${boundary}--`,
+      ''
+    ].join('\r\n');
+    return `${formatHeaders(headers)}\r\n\r\n${body}`;
+  }
+
+  const headers = [
+    ...commonHeaders,
+    ['Content-Type', 'text/plain; charset=UTF-8'],
+    ['Content-Transfer-Encoding', '8bit']
+  ];
+  return `${formatHeaders(headers)}\r\n\r\n${normalizeBody(text || '')}\r\n`;
+}
+
+export function signMessageForDomain(rawMessage, domain) {
+  if (!domain?.dkimPrivate || !domain?.selector) return rawMessage;
+  return signDkim(rawMessage, {
+    domain: domain.domain,
+    selector: domain.selector,
+    privateKey: domain.dkimPrivate
+  });
+}
+
+export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
+  if (!host) throw new Error('SMTP_HOST is not configured.');
+  const client = await SmtpClient.connect({ host, port, secure });
+  try {
+    await client.expect([220]);
+    let response = await client.command(`EHLO ${helo || 'mailhub.local'}`, [250, 502, 500]);
+    if (![250].includes(response.code)) {
+      await client.command(`HELO ${helo || 'mailhub.local'}`, [250]);
+    }
+    if (username || password) {
+      const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64');
+      await client.command(`AUTH PLAIN ${auth}`, [235]);
+    }
+    await client.command(`MAIL FROM:<${extractAddress(mailFrom)}>`, [250]);
+    for (const recipient of recipients) {
+      await client.command(`RCPT TO:<${recipient}>`, [250, 251]);
+    }
+    await client.command('DATA', [354]);
+    await client.writeData(dotStuff(rawMessage));
+    const dataResponse = await client.expect([250]);
+    await client.command('QUIT', [221]).catch(() => null);
+    return dataResponse;
+  } finally {
+    client.close();
+  }
+}
+
+function sanitizeHeader(value) {
+  return String(value || '').replace(/[\r\n]+/g, ' ').trim();
+}
+
+function encodeHeader(value) {
+  const clean = sanitizeHeader(value);
+  if (/^[\x20-\x7e]*$/.test(clean)) return clean;
+  return `=?UTF-8?B?${Buffer.from(clean).toString('base64')}?=`;
+}
+
+function formatHeaders(headers) {
+  return headers
+    .filter(([, value]) => value !== undefined && value !== null && value !== '')
+    .map(([name, value]) => `${name}: ${value}`)
+    .join('\r\n');
+}
+
+function normalizeBody(value) {
+  return String(value || '').replace(/\r?\n/g, '\r\n');
+}
+
+function stripHtml(value) {
+  return String(value || '')
+    .replace(/<style[\s\S]*?<\/style>/gi, '')
+    .replace(/<script[\s\S]*?<\/script>/gi, '')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function dotStuff(rawMessage) {
+  const normalized = rawMessage.replace(/\r?\n/g, '\r\n');
+  return `${normalized.replace(/^\./gm, '..')}\r\n.`;
+}
+
+class SmtpClient {
+  static connect({ host, port = 25, secure = false }) {
+    return new Promise((resolve, reject) => {
+      const socket = secure
+        ? tls.connect({ host, port: Number(port), servername: host })
+        : net.createConnection({ host, port: Number(port) });
+      const client = new SmtpClient(socket);
+      socket.once('connect', () => resolve(client));
+      socket.once('secureConnect', () => resolve(client));
+      socket.once('error', reject);
+      setTimeout(() => reject(new Error('SMTP connection timeout.')), 15000).unref();
+    });
+  }
+
+  constructor(socket) {
+    this.socket = socket;
+    this.buffer = '';
+    this.pending = [];
+    this.currentLines = [];
+    socket.setEncoding('utf8');
+    socket.on('data', (chunk) => this.onData(chunk));
+    socket.on('error', (error) => this.rejectPending(error));
+    socket.on('close', () => this.rejectPending(new Error('SMTP connection closed.')));
+  }
+
+  command(command, expectedCodes) {
+    this.socket.write(`${command}\r\n`);
+    return this.expect(expectedCodes);
+  }
+
+  writeData(data) {
+    this.socket.write(`${data}\r\n`);
+    return Promise.resolve();
+  }
+
+  expect(expectedCodes) {
+    return new Promise((resolve, reject) => {
+      this.pending.push({ expectedCodes, resolve, reject });
+      this.flushResponses();
+    });
+  }
+
+  close() {
+    this.socket.destroy();
+  }
+
+  onData(chunk) {
+    this.buffer += chunk;
+    let index;
+    while ((index = this.buffer.indexOf('\n')) !== -1) {
+      const rawLine = this.buffer.slice(0, index).replace(/\r$/, '');
+      this.buffer = this.buffer.slice(index + 1);
+      this.currentLines.push(rawLine);
+      if (/^\d{3} /.test(rawLine)) {
+        this.flushResponses();
+      }
+    }
+  }
+
+  flushResponses() {
+    while (this.pending.length && this.currentLines.length) {
+      const lastLine = this.currentLines[this.currentLines.length - 1];
+      if (!/^\d{3} /.test(lastLine)) return;
+      const responseLines = this.currentLines.splice(0);
+      const code = Number(lastLine.slice(0, 3));
+      const response = {
+        code,
+        message: responseLines.join('\n')
+      };
+      const pending = this.pending.shift();
+      if (pending.expectedCodes.includes(code)) {
+        pending.resolve(response);
+      } else {
+        pending.reject(new Error(`Unexpected SMTP response ${response.message}`));
+      }
+    }
+  }
+
+  rejectPending(error) {
+    while (this.pending.length) {
+      this.pending.shift().reject(error);
+    }
+  }
+}

+ 534 - 0
src/server.js

@@ -0,0 +1,534 @@
+import crypto from 'node:crypto';
+import { existsSync, readFileSync, statSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import http from 'node:http';
+import path from 'node:path';
+import { fileURLToPath, domainToASCII } from 'node:url';
+import {
+  createDomain,
+  deleteDomain,
+  getDomain,
+  getDomainByName,
+  getSmtpCredential,
+  initDatabase,
+  listDomains,
+  listSendEvents,
+  logSendEvent,
+  saveSmtpCredential,
+  saveDomainStatus,
+  seedSmtpCredential,
+  updateDkim,
+  updateDomain
+} from './db.js';
+import { buildDnsGuide } from './dns-guide.js';
+import { createDkimKeyPair } from './dkim.js';
+import {
+  buildMessage,
+  domainFromAddress,
+  extractAddress,
+  parseAddressList,
+  sendViaSmtp,
+  signMessageForDomain
+} from './mailer.js';
+import {
+  parseSubmissionListeners,
+  publicSubmissionListeners,
+  startSubmissionServer
+} from './submission.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+loadDotEnv();
+
+const config = {
+  port: Number(process.env.PORT || 3000),
+  dataDir: process.env.DATA_DIR || path.join(process.cwd(), 'data'),
+  appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
+  adminUser: process.env.ADMIN_USER || 'admin',
+  adminPassword: process.env.ADMIN_PASSWORD || 'change-this-admin-password',
+  apiToken: process.env.API_TOKEN || '',
+  mailHostname: process.env.MAIL_HOSTNAME || 'ali.ss5.xyz',
+  sendingIp: process.env.SENDING_IP || '',
+  defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
+  smtpHost: process.env.SMTP_HOST || '',
+  smtpPort: Number(process.env.SMTP_PORT || 25),
+  smtpSecure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
+  smtpUser: process.env.SMTP_USERNAME || '',
+  smtpPassword: process.env.SMTP_PASSWORD || '',
+  smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
+  sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true',
+  submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
+  submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
+  submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
+  submissionUsername: process.env.SUBMISSION_USERNAME || '',
+  submissionPassword: process.env.SUBMISSION_PASSWORD || '',
+  submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
+  submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
+  submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
+  dmarcPolicy: process.env.DMARC_POLICY || 'none',
+  dmarcRua: process.env.DMARC_RUA || '',
+  sessionSecret: process.env.SESSION_SECRET || crypto
+    .createHash('sha256')
+    .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
+    .digest('hex')
+};
+
+initDatabase(config.dataDir, config.sessionSecret);
+seedSmtpCredential(config.submissionUsername, config.submissionPassword);
+
+const server = http.createServer(async (req, res) => {
+  try {
+    setSecurityHeaders(res);
+    if (req.method === 'OPTIONS') return handleOptions(res);
+    const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+    if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
+    if (req.method === 'POST' && url.pathname === '/api/login') return await handleLogin(req, res);
+    if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
+    if (isLoginAsset(url.pathname)) {
+      if (url.pathname === '/login' && isAuthorized(req, url.pathname)) return redirect(res, '/');
+      return await serveStatic(req, res, url, { loginPage: true });
+    }
+
+    if (!isAuthorized(req, url.pathname)) {
+      if (url.pathname.startsWith('/api/')) {
+        return sendJson(res, 401, { error: 'Authentication required.' });
+      }
+      return redirect(res, '/login');
+    }
+
+    if (url.pathname.startsWith('/api/')) {
+      return await handleApi(req, res, url);
+    }
+    return await serveStatic(req, res, url);
+  } catch (error) {
+    console.error(error);
+    return sendJson(res, 500, { error: error.message || 'Internal server error.' });
+  }
+});
+
+server.listen(config.port, '0.0.0.0', () => {
+  console.log(`MailHub listening on 0.0.0.0:${config.port}`);
+});
+
+startSubmissionServer({
+  enabled: config.submissionEnabled && Boolean(getSmtpCredential()),
+  listeners: config.submissionListeners,
+  hostname: config.submissionHost,
+  allowInsecureAuth: config.submissionAllowInsecureAuth,
+  tlsCertPath: config.submissionTlsCert,
+  tlsKeyPath: config.submissionTlsKey,
+  relayHost: config.smtpHost,
+  relayPort: config.smtpPort,
+  relaySecure: config.smtpSecure,
+  relayUsername: config.smtpUser,
+  relayPassword: config.smtpPassword,
+  relayHelo: config.smtpHelo
+});
+
+async function handleApi(req, res, url) {
+  const method = req.method || 'GET';
+  const pathname = url.pathname;
+
+  if (method === 'GET' && pathname === '/api/config') {
+    return sendJson(res, 200, publicConfig());
+  }
+  if (method === 'GET' && pathname === '/api/domains') {
+    return sendJson(res, 200, { domains: listDomains() });
+  }
+  if (method === 'POST' && pathname === '/api/domains') {
+    const body = await readJson(req);
+    const domain = normalizeDomain(body.domain);
+    if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
+    const selector = normalizeSelector(body.selector || defaultSelector());
+    if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
+    const keys = createDkimKeyPair();
+    const row = createDomain({
+      domain,
+      selector,
+      verificationToken: crypto.randomBytes(18).toString('hex'),
+      dkimPublic: keys.publicKey,
+      dkimPrivate: keys.privateKey,
+      senderHost: normalizeHostname(body.senderHost || config.mailHostname),
+      sendingIp: String(body.sendingIp || config.sendingIp).trim(),
+      spfExtra: String(body.spfExtra ?? config.defaultSpfMechanisms).trim(),
+      dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || config.dmarcPolicy),
+      dmarcRua: String(body.dmarcRua ?? config.dmarcRua).trim()
+    });
+    return sendJson(res, 201, { domain: row });
+  }
+  if (method === 'GET' && pathname === '/api/events') {
+    return sendJson(res, 200, { events: listSendEvents() });
+  }
+  if (method === 'GET' && pathname === '/api/smtp-credential') {
+    return sendJson(res, 200, { credential: getSmtpCredential({ includePassword: true }) });
+  }
+  if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
+    const body = await readJson(req);
+    saveSmtpCredential({
+      username: String(body.username || '').trim(),
+      password: String(body.password || '')
+    });
+    const credential = getSmtpCredential({ includePassword: true });
+    return sendJson(res, 200, { credential });
+  }
+  if (method === 'POST' && pathname === '/api/send') {
+    const body = await readJson(req);
+    const result = await sendMailFromBody(body);
+    return sendJson(res, 202, result);
+  }
+
+  const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
+  if (domainMatch) {
+    const id = Number(domainMatch[1]);
+    const action = domainMatch[2] || '';
+    if (method === 'GET' && !action) {
+      const domain = getDomain(id);
+      if (!domain) return sendJson(res, 404, { error: '域名不存在。' });
+      return sendJson(res, 200, { domain });
+    }
+    if (method === 'PATCH' && !action) {
+      const body = await readJson(req);
+      const row = updateDomain(id, {
+        selector: body.selector ? normalizeSelector(body.selector) : undefined,
+        senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
+        sendingIp: body.sendingIp ? String(body.sendingIp).trim() : undefined,
+        spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
+        dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
+        dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
+      });
+      if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      return sendJson(res, 200, { domain: row });
+    }
+    if (method === 'DELETE' && !action) {
+      const deleted = deleteDomain(id);
+      return sendJson(res, deleted ? 200 : 404, { deleted });
+    }
+    if (method === 'POST' && action === 'check') {
+      const row = getDomain(id);
+      if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      const guide = await buildDnsGuide(row);
+      saveDomainStatus(id, guide);
+      return sendJson(res, 200, { guide, domain: getDomain(id) });
+    }
+    if (method === 'POST' && action === 'rotate-dkim') {
+      const row = getDomain(id);
+      if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      const body = await readJson(req).catch(() => ({}));
+      const selector = normalizeSelector(body.selector || defaultSelector());
+      const next = updateDkim(id, createDkimKeyPair(), selector);
+      return sendJson(res, 200, { domain: next });
+    }
+    if (method === 'POST' && action === 'test-send') {
+      const row = getDomain(id);
+      if (!row) return sendJson(res, 404, { error: '域名不存在。' });
+      const body = await readJson(req);
+      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}.`
+      });
+      return sendJson(res, 202, result);
+    }
+  }
+
+  return sendJson(res, 404, { error: 'Not found.' });
+}
+
+async function sendMailFromBody(body) {
+  const from = extractAddress(body.from);
+  if (!from) throw new Error('发件人地址格式不正确。');
+  const recipients = parseAddressList(body.to);
+  if (!recipients.length) throw new Error('收件人地址格式不正确。');
+  const fromDomain = domainFromAddress(from);
+  const domain = getDomainByName(fromDomain, { includePrivate: true });
+  if (!domain) throw new Error(`发件域名 ${fromDomain} 尚未添加。`);
+  if (config.sendRequiresVerified && !domain.status?.verified) {
+    throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
+  }
+
+  const rawMessage = buildMessage({
+    from,
+    to: recipients,
+    subject: body.subject || '(no subject)',
+    text: body.text || '',
+    html: body.html || '',
+    baseUrl: config.appBaseUrl
+  });
+  const signed = signMessageForDomain(rawMessage, domain);
+  try {
+    const smtpResult = await sendViaSmtp({
+      host: config.smtpHost,
+      port: config.smtpPort,
+      secure: config.smtpSecure,
+      username: config.smtpUser,
+      password: config.smtpPassword,
+      helo: config.smtpHelo,
+      mailFrom: from,
+      recipients,
+      rawMessage: signed
+    });
+    logSendEvent({
+      domainId: domain.id,
+      sender: from,
+      recipients,
+      subject: body.subject || '(no subject)',
+      status: 'queued',
+      detail: smtpResult.message
+    });
+    return {
+      queued: true,
+      domain: domain.domain,
+      recipients,
+      smtp: smtpResult.message
+    };
+  } catch (error) {
+    logSendEvent({
+      domainId: domain.id,
+      sender: from,
+      recipients,
+      subject: body.subject || '(no subject)',
+      status: 'failed',
+      detail: error.message
+    });
+    throw error;
+  }
+}
+
+async function serveStatic(req, res, url) {
+  const publicDir = path.join(__dirname, '..', 'public');
+  const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
+  const filePath = path.normalize(path.join(publicDir, pathname));
+  if (!filePath.startsWith(publicDir) || !existsSync(filePath) || statSync(filePath).isDirectory()) {
+    return sendStaticFile(res, path.join(publicDir, 'index.html'));
+  }
+  return sendStaticFile(res, filePath);
+}
+
+async function sendStaticFile(res, filePath) {
+  const ext = path.extname(filePath);
+  const contentType = {
+    '.html': 'text/html; charset=utf-8',
+    '.css': 'text/css; charset=utf-8',
+    '.js': 'application/javascript; charset=utf-8',
+    '.json': 'application/json; charset=utf-8',
+    '.svg': 'image/svg+xml'
+  }[ext] || 'application/octet-stream';
+  res.writeHead(200, { 'Content-Type': contentType });
+  res.end(await readFile(filePath));
+}
+
+async function readJson(req) {
+  const chunks = [];
+  for await (const chunk of req) chunks.push(chunk);
+  if (!chunks.length) return {};
+  const raw = Buffer.concat(chunks).toString('utf8');
+  return JSON.parse(raw);
+}
+
+function sendJson(res, status, payload) {
+  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
+  res.end(JSON.stringify(payload));
+}
+
+function redirect(res, location) {
+  res.writeHead(302, { Location: location });
+  res.end();
+}
+
+function setSecurityHeaders(res) {
+  res.setHeader('X-Content-Type-Options', 'nosniff');
+  res.setHeader('X-Frame-Options', 'DENY');
+  res.setHeader('Referrer-Policy', 'same-origin');
+}
+
+function handleOptions(res) {
+  res.writeHead(204, {
+    'Access-Control-Allow-Origin': '*',
+    'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
+    'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+  });
+  res.end();
+}
+
+function isAuthorized(req, pathname) {
+  const auth = req.headers.authorization || '';
+  if (hasValidSession(req)) return true;
+  if (auth.startsWith('Basic ')) {
+    const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
+    const index = decoded.indexOf(':');
+    const user = decoded.slice(0, index);
+    const password = decoded.slice(index + 1);
+    return safeEqual(user, config.adminUser) && safeEqual(password, config.adminPassword);
+  }
+  if (pathname === '/api/send' && config.apiToken && auth.startsWith('Bearer ')) {
+    return safeEqual(auth.slice(7), config.apiToken);
+  }
+  return false;
+}
+
+async function handleLogin(req, res) {
+  const body = await readJson(req);
+  const user = String(body.username || '');
+  const password = String(body.password || '');
+  if (!safeEqual(user, config.adminUser) || !safeEqual(password, config.adminPassword)) {
+    return sendJson(res, 401, { error: '账号或密码不正确。' });
+  }
+  const token = createSessionToken(user);
+  res.writeHead(200, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Set-Cookie': sessionCookie(token)
+  });
+  res.end(JSON.stringify({ ok: true }));
+}
+
+function handleLogout(res) {
+  res.writeHead(200, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
+  });
+  res.end(JSON.stringify({ ok: true }));
+}
+
+function createSessionToken(user) {
+  const payload = Buffer.from(JSON.stringify({
+    user,
+    exp: Date.now() + 12 * 60 * 60 * 1000,
+    nonce: crypto.randomBytes(10).toString('hex')
+  })).toString('base64url');
+  const signature = signSessionPayload(payload);
+  return `${payload}.${signature}`;
+}
+
+function hasValidSession(req) {
+  const token = parseCookies(req.headers.cookie || '').mailhub_session;
+  if (!token || !token.includes('.')) return false;
+  const [payload, signature] = token.split('.');
+  if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return false;
+  try {
+    const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+    return data.user === config.adminUser && Number(data.exp) > Date.now();
+  } catch {
+    return false;
+  }
+}
+
+function signSessionPayload(payload) {
+  return crypto
+    .createHmac('sha256', config.sessionSecret)
+    .update(payload)
+    .digest('base64url');
+}
+
+function sessionCookie(token) {
+  return [
+    `mailhub_session=${token}`,
+    'Path=/',
+    'HttpOnly',
+    'SameSite=Lax',
+    'Max-Age=43200'
+  ].join('; ');
+}
+
+function parseCookies(header) {
+  const cookies = {};
+  for (const part of String(header || '').split(';')) {
+    const index = part.indexOf('=');
+    if (index === -1) continue;
+    const key = part.slice(0, index).trim();
+    const value = part.slice(index + 1).trim();
+    cookies[key] = value;
+  }
+  return cookies;
+}
+
+function safeEqual(actual, expected) {
+  const a = Buffer.from(String(actual || ''));
+  const b = Buffer.from(String(expected || ''));
+  if (a.length !== b.length) return false;
+  return crypto.timingSafeEqual(a, b);
+}
+
+function normalizeDomain(input) {
+  const raw = String(input || '')
+    .trim()
+    .toLowerCase()
+    .replace(/^https?:\/\//, '')
+    .replace(/\/.*$/, '')
+    .replace(/\.$/, '');
+  const ascii = domainToASCII(raw);
+  if (!ascii || ascii.length > 253) return '';
+  if (!/^(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(ascii)) return '';
+  return ascii;
+}
+
+function normalizeHostname(input) {
+  return normalizeDomain(input) || String(input || '').trim().toLowerCase();
+}
+
+function normalizeSelector(input) {
+  const value = String(input || '').trim().toLowerCase();
+  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) return '';
+  return value;
+}
+
+function normalizeDmarcPolicy(input) {
+  const value = String(input || '').trim().toLowerCase();
+  return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
+}
+
+function defaultSelector() {
+  const d = new Date();
+  return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
+}
+
+function publicConfig() {
+  const smtpCredential = getSmtpCredential();
+  return {
+    appBaseUrl: config.appBaseUrl,
+    mailHostname: config.mailHostname,
+    sendingIp: config.sendingIp,
+    defaultSpfMechanisms: config.defaultSpfMechanisms,
+    smtpHost: config.smtpHost ? 'configured' : '',
+    submission: {
+      enabled: config.submissionEnabled && Boolean(smtpCredential),
+      host: config.submissionHost,
+      ports: publicSubmissionListeners(config.submissionListeners),
+      username: smtpCredential?.username || '',
+      passwordSet: Boolean(smtpCredential?.passwordSet),
+      tls: Boolean(config.submissionTlsCert && config.submissionTlsKey),
+      requireTlsForAuth: !config.submissionAllowInsecureAuth
+    },
+    sendRequiresVerified: config.sendRequiresVerified,
+    apiTokenSet: Boolean(config.apiToken),
+    usingDefaultAdminPassword: config.adminPassword === 'change-this-admin-password'
+  };
+}
+
+function isLoginAsset(pathname) {
+  return ['/login', '/login.html', '/login.css', '/login.js'].includes(pathname);
+}
+
+function resolveStaticPathname(pathname) {
+  if (pathname === '/') return '/index.html';
+  if (pathname === '/login') return '/login.html';
+  return pathname;
+}
+
+function loadDotEnv() {
+  const file = path.join(process.cwd(), '.env');
+  if (!existsSync(file)) return;
+  const lines = readFileSync(file, 'utf8').split(/\r?\n/);
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#')) continue;
+    const index = trimmed.indexOf('=');
+    if (index === -1) continue;
+    const key = trimmed.slice(0, index).trim();
+    let value = trimmed.slice(index + 1).trim();
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+      value = value.slice(1, -1);
+    }
+    if (!(key in process.env)) process.env[key] = value;
+  }
+}

+ 371 - 0
src/submission.js

@@ -0,0 +1,371 @@
+import net from 'node:net';
+import tls from 'node:tls';
+import { readFileSync } from 'node:fs';
+import { getDomainByName, logSendEvent, verifySmtpCredential } from './db.js';
+import {
+  domainFromAddress,
+  extractAddress,
+  sendViaSmtp,
+  signMessageForDomain
+} from './mailer.js';
+
+export function startSubmissionServer(config) {
+  if (!config.enabled) return null;
+  const tlsMaterial = loadTlsMaterial(config);
+  const servers = [];
+  for (const listener of config.listeners) {
+    const listenerConfig = {
+      ...config,
+      port: listener.port,
+      protocol: listener.protocol,
+      secureContext: tlsMaterial?.secureContext || null,
+      tlsActive: listener.protocol === 'smtps',
+      startTlsAvailable: listener.protocol === 'smtp' && Boolean(tlsMaterial?.secureContext)
+    };
+    const server = listener.protocol === 'smtps'
+      ? tls.createServer({ key: tlsMaterial?.key, cert: tlsMaterial?.cert }, (socket) => new SubmissionSession(socket, listenerConfig))
+      : net.createServer((socket) => new SubmissionSession(socket, listenerConfig));
+    server.listen(listener.port, '0.0.0.0', () => {
+      console.log(`MailHub SMTP ${listener.protocol} listening on 0.0.0.0:${listener.port}`);
+    });
+    servers.push(server);
+  }
+  return servers;
+}
+
+function loadTlsMaterial(config) {
+  if (!config.tlsKeyPath || !config.tlsCertPath) {
+    console.warn('SMTP TLS certificate paths are not configured; STARTTLS/SMTPS will be unavailable.');
+    return null;
+  }
+  try {
+    const key = readFileSync(config.tlsKeyPath);
+    const cert = readFileSync(config.tlsCertPath);
+    return tls.createSecureContext({
+      key,
+      cert
+    }) && {
+      key,
+      cert,
+      secureContext: tls.createSecureContext({ key, cert })
+    };
+  } catch (error) {
+    console.warn(`Unable to load SMTP TLS certificate: ${error.message}`);
+    return null;
+  }
+}
+
+export function parseSubmissionListeners(value) {
+  return String(value || '25:smtp,587:smtp,465:smtps,2525:smtp')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((item) => {
+      const [portRaw, protocolRaw = 'smtp'] = item.split(':');
+      const port = Number(portRaw);
+      const protocol = protocolRaw.toLowerCase() === 'smtps' ? 'smtps' : 'smtp';
+      if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
+      return { port, protocol };
+    })
+    .filter(Boolean);
+}
+
+export function publicSubmissionListeners(listeners) {
+  return listeners.map((listener) => ({
+    port: listener.port,
+    protocol: listener.protocol === 'smtps' ? 'SMTPS' : 'SMTP + STARTTLS'
+  }));
+}
+
+class SubmissionSession {
+  constructor(socket, config) {
+    this.socket = socket;
+    this.config = config;
+    this.buffer = '';
+    this.dataMode = false;
+    this.dataLines = [];
+    this.authState = '';
+    this.authUser = '';
+    this.authenticated = false;
+    this.mailFrom = '';
+    this.recipients = [];
+    this.remoteAddress = socket.remoteAddress || '';
+    this.onDataBound = (chunk) => this.onData(chunk);
+    this.queue = Promise.resolve();
+    socket.setEncoding('utf8');
+    socket.on('data', this.onDataBound);
+    socket.on('error', () => null);
+    this.write(220, `${config.hostname} MailHub SMTP ready`);
+  }
+
+  onData(chunk) {
+    this.buffer += chunk;
+    let index;
+    while ((index = this.buffer.indexOf('\n')) !== -1) {
+      const line = this.buffer.slice(0, index).replace(/\r$/, '');
+      this.buffer = this.buffer.slice(index + 1);
+      this.queue = this.queue
+        .then(() => this.onLine(line))
+        .catch((error) => {
+          console.error(error);
+          this.write(451, 'Temporary local error');
+        });
+    }
+  }
+
+  async onLine(line) {
+    if (this.dataMode) {
+      if (line === '.') return await this.finishData();
+      this.dataLines.push(line.startsWith('..') ? line.slice(1) : line);
+      return;
+    }
+
+    if (this.authState) return this.continueAuth(line);
+
+    const [rawCommand, ...args] = line.split(' ');
+    const command = rawCommand.toUpperCase();
+    const argument = args.join(' ').trim();
+
+    if (command === 'EHLO' || command === 'HELO') return this.ehlo();
+    if (command === 'NOOP') return this.write(250, 'OK');
+    if (command === 'RSET') return this.resetEnvelope();
+    if (command === 'QUIT') {
+      this.write(221, 'Bye');
+      return this.socket.end();
+    }
+    if (command === 'AUTH') return this.auth(argument);
+    if (command === 'STARTTLS') return this.startTls();
+    if (command === 'MAIL') return this.mail(argument);
+    if (command === 'RCPT') return this.rcpt(argument);
+    if (command === 'DATA') return this.data();
+    return this.write(502, 'Command not implemented');
+  }
+
+  ehlo() {
+    this.socket.write(`250-${this.config.hostname}\r\n`);
+    this.socket.write('250-SIZE 52428800\r\n');
+    this.socket.write('250-8BITMIME\r\n');
+    if (this.config.startTlsAvailable && !this.config.tlsActive) {
+      this.socket.write('250-STARTTLS\r\n');
+    }
+    if (this.canAuthenticate()) {
+      this.socket.write('250-AUTH PLAIN LOGIN\r\n');
+    }
+    this.socket.write('250 SMTPUTF8\r\n');
+  }
+
+  startTls() {
+    if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write(454, 'TLS is not available');
+    if (this.config.tlsActive) return this.write(503, 'TLS is already active');
+    this.write(220, 'Ready to start TLS');
+    this.socket.removeListener('data', this.onDataBound);
+    const secureSocket = new tls.TLSSocket(this.socket, {
+      isServer: true,
+      secureContext: this.config.secureContext
+    });
+    this.socket = secureSocket;
+    this.buffer = '';
+    this.authenticated = false;
+    this.authState = '';
+    this.config = {
+      ...this.config,
+      tlsActive: true,
+      startTlsAvailable: false
+    };
+    secureSocket.setEncoding('utf8');
+    secureSocket.on('data', this.onDataBound);
+    secureSocket.on('error', () => null);
+  }
+
+  auth(argument) {
+    if (!this.canAuthenticate()) return this.write(538, 'Encryption required for authentication');
+    const [methodRaw, response] = argument.split(/\s+/, 2);
+    const method = String(methodRaw || '').toUpperCase();
+    if (method === 'PLAIN') {
+      if (!response) {
+        this.authState = 'plain';
+        return this.write(334, '');
+      }
+      return this.finishPlainAuth(response);
+    }
+    if (method === 'LOGIN') {
+      this.authState = 'login-username';
+      return this.write(334, Buffer.from('Username:').toString('base64'));
+    }
+    return this.write(504, 'Unsupported authentication method');
+  }
+
+  continueAuth(line) {
+    if (this.authState === 'plain') return this.finishPlainAuth(line);
+    if (this.authState === 'login-username') {
+      this.authUser = decodeBase64(line);
+      this.authState = 'login-password';
+      return this.write(334, Buffer.from('Password:').toString('base64'));
+    }
+    if (this.authState === 'login-password') {
+      const password = decodeBase64(line);
+      this.authState = '';
+      return this.finishAuth(this.authUser, password);
+    }
+  }
+
+  finishPlainAuth(response) {
+    const decoded = decodeBase64(response);
+    const parts = decoded.split('\u0000');
+    const user = parts[1] || parts[0] || '';
+    const password = parts[2] || parts[1] || '';
+    this.authState = '';
+    return this.finishAuth(user, password);
+  }
+
+  finishAuth(user, password) {
+    if (verifySmtpCredential(user, password)) {
+      this.authenticated = true;
+      return this.write(235, 'Authentication successful');
+    }
+    this.authenticated = false;
+    return this.write(535, 'Authentication failed');
+  }
+
+  mail(argument) {
+    if (!this.authenticated) return this.write(530, 'Authentication required');
+    const address = extractPathAddress(argument);
+    if (!address) return this.write(501, 'Invalid MAIL FROM');
+    this.mailFrom = address;
+    this.recipients = [];
+    return this.write(250, 'Sender OK');
+  }
+
+  rcpt(argument) {
+    if (!this.authenticated) return this.write(530, 'Authentication required');
+    if (!this.mailFrom) return this.write(503, 'MAIL FROM required first');
+    const address = extractPathAddress(argument);
+    if (!address) return this.write(501, 'Invalid RCPT TO');
+    if (this.recipients.length >= 100) return this.write(452, 'Too many recipients');
+    this.recipients.push(address);
+    return this.write(250, 'Recipient OK');
+  }
+
+  data() {
+    if (!this.authenticated) return this.write(530, 'Authentication required');
+    if (!this.mailFrom || !this.recipients.length) return this.write(503, 'Need MAIL FROM and RCPT TO first');
+    this.dataMode = true;
+    this.dataLines = [];
+    return this.write(354, 'End data with <CR><LF>.<CR><LF>');
+  }
+
+  async finishData() {
+    this.dataMode = false;
+    const rawMessage = `${this.dataLines.join('\r\n')}\r\n`;
+    const headerFrom = extractHeader(rawMessage, 'from');
+    const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
+    const sender = extractAddress(headerFrom) || this.mailFrom;
+    const domainName = domainFromAddress(sender || this.mailFrom);
+    const domain = getDomainByName(domainName, { includePrivate: true });
+    if (!domain) {
+      logSendEvent({
+        domainId: null,
+        sender: sender || this.mailFrom,
+        recipients: this.recipients,
+        subject,
+        status: 'failed',
+        detail: `Sender domain ${domainName || '(unknown)'} is not configured`
+      });
+      return this.write(550, 'Sender domain is not configured in MailHub');
+    }
+
+    try {
+      const signed = signMessageForDomain(rawMessage, domain);
+      const smtpResult = await sendViaSmtp({
+        host: this.config.relayHost,
+        port: this.config.relayPort,
+        secure: this.config.relaySecure,
+        username: this.config.relayUsername,
+        password: this.config.relayPassword,
+        helo: this.config.relayHelo,
+        mailFrom: this.mailFrom,
+        recipients: this.recipients,
+        rawMessage: signed
+      });
+      logSendEvent({
+        domainId: domain.id,
+        sender: sender || this.mailFrom,
+        recipients: this.recipients,
+        subject,
+        status: 'queued',
+        detail: `submission ${this.remoteAddress}; ${smtpResult.message}`
+      });
+      this.resetEnvelope(false);
+      return this.write(250, 'Message queued');
+    } catch (error) {
+      logSendEvent({
+        domainId: domain.id,
+        sender: sender || this.mailFrom,
+        recipients: this.recipients,
+        subject,
+        status: 'failed',
+        detail: `submission ${this.remoteAddress}; ${error.message}`
+      });
+      return this.write(451, 'Temporary local delivery error');
+    }
+  }
+
+  resetEnvelope(reply = true) {
+    this.mailFrom = '';
+    this.recipients = [];
+    this.dataMode = false;
+    this.dataLines = [];
+    if (reply) this.write(250, 'OK');
+  }
+
+  write(code, message) {
+    this.socket.write(`${code} ${message}\r\n`);
+  }
+
+  canAuthenticate() {
+    return this.config.tlsActive || this.config.allowInsecureAuth;
+  }
+}
+
+function extractPathAddress(argument) {
+  const match = String(argument || '').match(/FROM:\s*<([^>]+)>|TO:\s*<([^>]+)>/i);
+  const raw = match ? (match[1] || match[2]) : argument;
+  return extractAddress(raw);
+}
+
+function extractHeader(rawMessage, name) {
+  const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
+  const lines = head.split(/\r?\n/);
+  const headers = [];
+  for (const line of lines) {
+    if (/^[\t ]/.test(line) && headers.length) {
+      headers[headers.length - 1].value += ` ${line.trim()}`;
+      continue;
+    }
+    const index = line.indexOf(':');
+    if (index === -1) continue;
+    headers.push({
+      name: line.slice(0, index).toLowerCase(),
+      value: line.slice(index + 1).trim()
+    });
+  }
+  return headers.reverse().find((header) => header.name === name.toLowerCase())?.value || '';
+}
+
+function decodeHeader(value) {
+  return String(value || '').replace(/=\?UTF-8\?B\?([^?]+)\?=/gi, (_, encoded) => {
+    try {
+      return Buffer.from(encoded, 'base64').toString('utf8');
+    } catch {
+      return _;
+    }
+  });
+}
+
+function decodeBase64(value) {
+  try {
+    return Buffer.from(String(value || ''), 'base64').toString('utf8');
+  } catch {
+    return '';
+  }
+}