Prechádzať zdrojové kódy

feat: add inbound mail handling

AI-Co-Authored-By: Codex
chendeben 1 mesiac pred
rodič
commit
3bf5311b45

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
public/assets/index-B1y0mlYN.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
public/assets/index-kRpKLbfI.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/login-BesHXcx1.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/styles-BJk6n3Q_.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/styles-KD6S-HfV.js


+ 3 - 3
public/index.html

@@ -4,10 +4,10 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-kRpKLbfI.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-JlAcutjU.js">
+    <script type="module" crossorigin src="/assets/index-B1y0mlYN.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-yTEP6Wre.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 3 - 3
public/login.html

@@ -4,10 +4,10 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-B0xej5ug.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-JlAcutjU.js">
+    <script type="module" crossorigin src="/assets/login-BesHXcx1.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-yTEP6Wre.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 365 - 4
src/db.js

@@ -150,6 +150,44 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS inbound_mailboxes (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      domain_id INTEGER NOT NULL,
+      address TEXT NOT NULL UNIQUE,
+      local_part TEXT NOT NULL,
+      display_name TEXT NOT NULL DEFAULT '',
+      status TEXT NOT NULL DEFAULT 'active',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      deleted_at TEXT,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+      FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
+    );
+
+    CREATE TABLE IF NOT EXISTS inbound_messages (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      mailbox_id INTEGER NOT NULL,
+      user_id INTEGER NOT NULL,
+      domain_id INTEGER NOT NULL,
+      sender TEXT NOT NULL DEFAULT '',
+      recipients_json TEXT NOT NULL DEFAULT '[]',
+      subject TEXT NOT NULL DEFAULT '',
+      message_id TEXT NOT NULL DEFAULT '',
+      raw_message TEXT NOT NULL DEFAULT '',
+      text_body TEXT NOT NULL DEFAULT '',
+      html_body TEXT NOT NULL DEFAULT '',
+      preview TEXT NOT NULL DEFAULT '',
+      read_state TEXT NOT NULL DEFAULT 'false',
+      received_at TEXT NOT NULL,
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      deleted_at TEXT,
+      FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+      FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS api_tokens (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
@@ -242,6 +280,10 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_tracking_events_event_time ON tracking_events(send_event_id, occurred_at);
     CREATE INDEX IF NOT EXISTS idx_tracking_events_link_time ON tracking_events(tracking_link_id, occurred_at);
     CREATE INDEX IF NOT EXISTS idx_tracking_events_type_time ON tracking_events(event_type, occurred_at);
+    CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_user_id ON inbound_mailboxes(user_id);
+    CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_domain_id ON inbound_mailboxes(domain_id);
+    CREATE INDEX IF NOT EXISTS idx_inbound_messages_user_received ON inbound_messages(user_id, received_at);
+    CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_received ON inbound_messages(mailbox_id, received_at);
   `);
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
@@ -375,6 +417,8 @@ export function listUsersWithResourceCounts() {
         (SELECT COUNT(*) FROM domains WHERE domains.user_id = users.id) AS domains_count,
         (SELECT COUNT(*) FROM dns_credentials WHERE dns_credentials.user_id = users.id) AS dns_credentials_count,
         (SELECT COUNT(*) FROM api_tokens WHERE api_tokens.user_id = users.id) AS api_tokens_count,
+        (SELECT COUNT(*) FROM inbound_mailboxes WHERE inbound_mailboxes.user_id = users.id AND inbound_mailboxes.deleted_at IS NULL) AS inbound_mailboxes_count,
+        (SELECT COUNT(*) FROM inbound_messages WHERE inbound_messages.user_id = users.id AND inbound_messages.deleted_at IS NULL) AS inbound_messages_count,
         (SELECT COUNT(*) FROM send_events WHERE send_events.user_id = users.id) AS send_events_count,
         (SELECT COUNT(*) FROM smtp_credentials WHERE smtp_credentials.user_id = users.id) AS smtp_credentials_count
       FROM users
@@ -387,6 +431,8 @@ export function listUsersWithResourceCounts() {
         domains: Number(row.domains_count || 0),
         dnsCredentials: Number(row.dns_credentials_count || 0),
         apiTokens: Number(row.api_tokens_count || 0),
+        inboundMailboxes: Number(row.inbound_mailboxes_count || 0),
+        inboundMessages: Number(row.inbound_messages_count || 0),
         sendEvents: Number(row.send_events_count || 0),
         smtpCredential: Number(row.smtp_credentials_count || 0)
       }
@@ -411,12 +457,35 @@ export function getAdminResourceInventory() {
     .prepare('SELECT * FROM api_tokens ORDER BY user_id, created_at DESC')
     .all()
     .map(publicApiToken);
+  const inboundMailboxes = requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        COUNT(msg.id) AS message_count,
+        COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
+        MAX(msg.received_at) AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
+      WHERE m.deleted_at IS NULL
+      GROUP BY m.id
+      ORDER BY m.user_id, COALESCE(last_message_at, m.created_at) DESC, m.id DESC
+    `)
+    .all()
+    .map(publicInboundMailbox);
   const sendEventCounts = new Map(
     requireDb()
       .prepare('SELECT user_id, COUNT(*) AS count FROM send_events GROUP BY user_id')
       .all()
       .map((row) => [row.user_id, Number(row.count || 0)])
   );
+  const inboundMessageCounts = new Map(
+    requireDb()
+      .prepare('SELECT user_id, COUNT(*) AS count FROM inbound_messages WHERE deleted_at IS NULL GROUP BY user_id')
+      .all()
+      .map((row) => [row.user_id, Number(row.count || 0)])
+  );
   const dnsCredentialById = new Map(dnsCredentials.map((credential) => [credential.id, credential]));
 
   return {
@@ -426,6 +495,8 @@ export function getAdminResourceInventory() {
       dnsCredentials: dnsCredentials.filter((credential) => credential.userId === user.id),
       smtpCredential: smtpCredentials.find((credential) => credential.userId === user.id) || null,
       apiTokens: apiTokens.filter((token) => token.userId === user.id),
+      inboundMailboxes: inboundMailboxes.filter((mailbox) => mailbox.userId === user.id),
+      inboundMessageCount: inboundMessageCounts.get(user.id) || 0,
       sendEventCount: sendEventCounts.get(user.id) || 0
     })),
     warnings: domains.flatMap((domain) => {
@@ -453,6 +524,7 @@ export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredent
     requireDb()
       .prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
       .run(target.id, nextDnsCredentialId, now(), domain.id);
+    const inboundCounts = moveInboundDomainResources(domain.id, target.id);
     if (mode === 'with_dns_credential' && domain.dns_credential_id) {
       const credential = requireDnsCredentialRow(domain.dns_credential_id);
       if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
@@ -472,7 +544,9 @@ export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredent
         fromUserId: domain.user_id,
         toUserId: target.id,
         dnsCredentialMode: mode,
-        dnsCredentialId: domain.dns_credential_id || null
+        dnsCredentialId: domain.dns_credential_id || null,
+        inboundMailboxes: inboundCounts.mailboxes,
+        inboundMessages: inboundCounts.messages
       }
     });
     return updated;
@@ -548,6 +622,8 @@ export function previewUserMerge({ sourceUserId, targetUserId }) {
     domains: countRows('domains', source.id),
     dnsCredentials: countRows('dns_credentials', source.id),
     apiTokens: countRows('api_tokens', source.id),
+    inboundMailboxes: countRows('inbound_mailboxes', source.id, 'deleted_at IS NULL'),
+    inboundMessages: countRows('inbound_messages', source.id, 'deleted_at IS NULL'),
     sendEvents: countRows('send_events', source.id),
     smtpCredential: sourceSmtpCredentials.length
   };
@@ -563,6 +639,8 @@ export function previewUserMerge({ sourceUserId, targetUserId }) {
     domains: counts.domains,
     dnsCredentials: counts.dnsCredentials,
     apiTokens: counts.apiTokens,
+    inboundMailboxes: defaultOptions.transferDomains ? counts.inboundMailboxes : 0,
+    inboundMessages: defaultOptions.transferDomains ? counts.inboundMessages : 0,
     sendEvents: counts.sendEvents,
     smtpCredential: defaultOptions.transferSmtpCredential ? counts.smtpCredential : 0
   };
@@ -592,10 +670,15 @@ export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, opti
     if (confirmation !== preview.confirmationText) throw new Error('确认文本不匹配。');
     const sourceId = preview.sourceUser.id;
     const targetId = preview.targetUser.id;
+    const inboundCounts = options.transferDomains === false
+      ? { mailboxes: 0, messages: 0 }
+      : moveInboundResourcesForUserDomains(sourceId, targetId);
     const counts = {
       domains: options.transferDomains === false ? 0 : moveRows('domains', sourceId, targetId),
       dnsCredentials: options.transferDnsCredentials === false ? 0 : moveRows('dns_credentials', sourceId, targetId),
       apiTokens: options.transferApiTokens === false ? 0 : moveRows('api_tokens', sourceId, targetId),
+      inboundMailboxes: inboundCounts.mailboxes,
+      inboundMessages: inboundCounts.messages,
       sendEvents: options.transferSendEvents === false ? 0 : moveRows('send_events', sourceId, targetId),
       smtpCredential: 0
     };
@@ -748,6 +831,163 @@ export function createDomain(userId, domain) {
   return getDomain(result.lastInsertRowid, { userId });
 }
 
+export function createInboundMailbox(userId, mailbox = {}) {
+  const address = normalizeInboundAddress(mailbox.address);
+  if (!address) throw new Error('收信邮箱格式不正确。');
+  const [localPart, domainName] = address.split('@');
+  const domain = getDomainByName(domainName, { userId });
+  if (!domain) throw new Error('收信域名不存在。');
+  const createdAt = now();
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO inbound_mailboxes (
+        user_id, domain_id, address, local_part, display_name, status, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
+    `)
+    .run(
+      userId,
+      domain.id,
+      address,
+      localPart,
+      String(mailbox.displayName || '').trim(),
+      createdAt,
+      createdAt
+    );
+  return getInboundMailbox(result.lastInsertRowid, userId);
+}
+
+export function listInboundMailboxes(userId) {
+  return requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        COUNT(msg.id) AS message_count,
+        COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
+        MAX(msg.received_at) AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
+      WHERE m.user_id = ? AND m.deleted_at IS NULL
+      GROUP BY m.id
+      ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
+    `)
+    .all(userId)
+    .map(publicInboundMailbox);
+}
+
+export function getInboundMailbox(id, userId) {
+  const row = requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        COUNT(msg.id) AS message_count,
+        COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
+        MAX(msg.received_at) AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
+      WHERE m.id = ? AND m.user_id = ? AND m.deleted_at IS NULL
+      GROUP BY m.id
+    `)
+    .get(Number(id), userId);
+  return publicInboundMailbox(row);
+}
+
+export function getInboundMailboxByAddress(address) {
+  const cleanAddress = normalizeInboundAddress(address);
+  if (!cleanAddress) return null;
+  const row = requireDb()
+    .prepare(`
+      SELECT m.*, d.domain, 0 AS message_count, 0 AS unread_count, NULL AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      JOIN users u ON u.id = m.user_id
+      WHERE m.address = ?
+        AND m.status = 'active'
+        AND m.deleted_at IS NULL
+        AND u.status = 'active'
+      LIMIT 1
+    `)
+    .get(cleanAddress);
+  return publicInboundMailbox(row);
+}
+
+export function createInboundMessage(mailbox, message = {}) {
+  if (!mailbox?.id || !mailbox?.userId || !mailbox?.domainId) throw new Error('收信邮箱不存在。');
+  const receivedAt = message.receivedAt || now();
+  const textBody = String(message.textBody || '');
+  const htmlBody = String(message.htmlBody || '');
+  const rawMessage = String(message.rawMessage || '');
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO inbound_messages (
+        mailbox_id, user_id, domain_id, sender, recipients_json, subject, message_id,
+        raw_message, text_body, html_body, preview, read_state, received_at, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
+    `)
+    .run(
+      mailbox.id,
+      mailbox.userId,
+      mailbox.domainId,
+      normalizeEmail(message.sender) || String(message.sender || '').trim(),
+      JSON.stringify(normalizeRecipientList(message.recipients)),
+      String(message.subject || '').trim() || '(no subject)',
+      String(message.messageId || '').trim(),
+      rawMessage,
+      textBody,
+      htmlBody,
+      inboundPreview(textBody || htmlToText(htmlBody) || rawMessage),
+      receivedAt,
+      receivedAt,
+      receivedAt
+    );
+  return getInboundMessage(mailbox.userId, result.lastInsertRowid);
+}
+
+export function listInboundMessages(userId, { mailboxId = null } = {}) {
+  const where = ['msg.user_id = ?', 'msg.deleted_at IS NULL'];
+  const params = [userId];
+  if (mailboxId) {
+    where.push('msg.mailbox_id = ?');
+    params.push(Number(mailboxId));
+  }
+  return requireDb()
+    .prepare(`
+      SELECT msg.*, m.address AS mailbox_address, d.domain
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      JOIN domains d ON d.id = msg.domain_id
+      WHERE ${where.join(' AND ')}
+      ORDER BY msg.received_at DESC, msg.id DESC
+    `)
+    .all(...params)
+    .map((row) => publicInboundMessage(row));
+}
+
+export function getInboundMessage(userId, id) {
+  const row = requireDb()
+    .prepare(`
+      SELECT msg.*, m.address AS mailbox_address, d.domain
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      JOIN domains d ON d.id = msg.domain_id
+      WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+    `)
+    .get(Number(id), userId);
+  return publicInboundMessage(row, { includeBody: true });
+}
+
+export function markInboundMessageRead(userId, id, read = true) {
+  const updatedAt = now();
+  const result = requireDb()
+    .prepare('UPDATE inbound_messages SET read_state = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
+    .run(read ? 'true' : 'false', updatedAt, Number(id), userId);
+  if (!result.changes) return null;
+  return getInboundMessage(userId, id);
+}
+
 export function updateDomain(id, userId, patch) {
   const current = getDomain(id, { userId, includePrivate: true });
   if (!current) return null;
@@ -2562,13 +2802,16 @@ function mergeResourcesForUser(userId) {
     domains: listDomains(userId),
     dnsCredentials: listDnsCredentials(userId),
     apiTokens: listApiTokens(userId),
+    inboundMailboxes: listInboundMailboxes(userId),
+    inboundMessageCount: countRows('inbound_messages', userId, 'deleted_at IS NULL'),
     sendEventCount: countRows('send_events', userId),
     smtpCredential: getSmtpCredential(userId)
   };
 }
 
-function countRows(table, userId) {
-  return Number(requireDb().prepare(`SELECT COUNT(*) AS count FROM ${mergeResourceTable(table)} WHERE user_id = ?`).get(userId).count || 0);
+function countRows(table, userId, extraWhere = '') {
+  const suffix = extraWhere ? ` AND ${extraWhere}` : '';
+  return Number(requireDb().prepare(`SELECT COUNT(*) AS count FROM ${mergeResourceTable(table)} WHERE user_id = ?${suffix}`).get(userId).count || 0);
 }
 
 function moveRows(table, sourceUserId, targetUserId) {
@@ -2579,12 +2822,52 @@ function moveRows(table, sourceUserId, targetUserId) {
 }
 
 function mergeResourceTable(table) {
-  if (!['domains', 'dns_credentials', 'api_tokens', 'send_events', 'smtp_credentials'].includes(table)) {
+  if (!['domains', 'dns_credentials', 'api_tokens', 'inbound_mailboxes', 'inbound_messages', 'send_events', 'smtp_credentials'].includes(table)) {
     throw new Error('资源类型不正确。');
   }
   return table;
 }
 
+function moveInboundDomainResources(domainId, targetUserId) {
+  const updatedAt = now();
+  const mailboxResult = requireDb()
+    .prepare('UPDATE inbound_mailboxes SET user_id = ?, updated_at = ? WHERE domain_id = ? AND deleted_at IS NULL')
+    .run(targetUserId, updatedAt, domainId);
+  const messageResult = requireDb()
+    .prepare('UPDATE inbound_messages SET user_id = ?, updated_at = ? WHERE domain_id = ? AND deleted_at IS NULL')
+    .run(targetUserId, updatedAt, domainId);
+  return {
+    mailboxes: mailboxResult.changes,
+    messages: messageResult.changes
+  };
+}
+
+function moveInboundResourcesForUserDomains(sourceUserId, targetUserId) {
+  const updatedAt = now();
+  const mailboxResult = requireDb()
+    .prepare(`
+      UPDATE inbound_mailboxes
+      SET user_id = ?, updated_at = ?
+      WHERE user_id = ?
+        AND deleted_at IS NULL
+        AND domain_id IN (SELECT id FROM domains WHERE user_id = ?)
+    `)
+    .run(targetUserId, updatedAt, sourceUserId, sourceUserId);
+  const messageResult = requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET user_id = ?, updated_at = ?
+      WHERE user_id = ?
+        AND deleted_at IS NULL
+        AND domain_id IN (SELECT id FROM domains WHERE user_id = ?)
+    `)
+    .run(targetUserId, updatedAt, sourceUserId, sourceUserId);
+  return {
+    mailboxes: mailboxResult.changes,
+    messages: messageResult.changes
+  };
+}
+
 function requireDomainRow(domainId) {
   const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
   if (!domain) throw new Error('域名不存在。');
@@ -2744,6 +3027,51 @@ function privateDomainRow(row) {
   return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
 }
 
+function publicInboundMailbox(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    domainId: row.domain_id,
+    domain: row.domain || '',
+    address: row.address,
+    localPart: row.local_part,
+    displayName: row.display_name,
+    status: row.status,
+    messageCount: Number(row.message_count || 0),
+    unreadCount: Number(row.unread_count || 0),
+    lastMessageAt: row.last_message_at || null,
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  };
+}
+
+function publicInboundMessage(row, { includeBody = false } = {}) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    mailboxId: row.mailbox_id,
+    userId: row.user_id,
+    domainId: row.domain_id,
+    domain: row.domain || '',
+    mailboxAddress: row.mailbox_address || '',
+    sender: row.sender,
+    recipients: safeJson(row.recipients_json, []),
+    subject: row.subject,
+    messageId: row.message_id,
+    preview: row.preview,
+    read: row.read_state === 'true',
+    receivedAt: row.received_at,
+    createdAt: row.created_at,
+    updatedAt: row.updated_at,
+    ...(includeBody ? {
+      rawMessage: row.raw_message,
+      textBody: row.text_body,
+      htmlBody: row.html_body
+    } : {})
+  };
+}
+
 function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
   if (!row) return null;
   const password = includePassword ? decryptSecret(row.password_secret) : '';
@@ -3123,6 +3451,39 @@ function normalizeEmail(value) {
   return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
 }
 
+function normalizeInboundAddress(value) {
+  const email = normalizeEmail(value);
+  if (!email) return '';
+  const [localPart, domain] = email.split('@');
+  if (!localPart || !domain) return '';
+  return `${localPart}@${domain}`;
+}
+
+function normalizeRecipientList(values) {
+  const list = Array.isArray(values) ? values : [values];
+  return [...new Set(list.map(normalizeEmail).filter(Boolean))];
+}
+
+function inboundPreview(value) {
+  return String(value || '')
+    .replace(/\s+/g, ' ')
+    .trim()
+    .slice(0, 240);
+}
+
+function htmlToText(value) {
+  return String(value || '')
+    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&amp;/gi, '&')
+    .replace(/&lt;/gi, '<')
+    .replace(/&gt;/gi, '>')
+    .replace(/&quot;/gi, '"')
+    .replace(/&#39;/g, "'");
+}
+
 function normalizePort(value, fallback) {
   const port = Number(value);
   return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;

+ 87 - 1
src/frontend/App.tsx

@@ -9,6 +9,7 @@ import Dashboard from '../pages/Dashboard';
 import DnsApi from '../pages/DnsApi';
 import DomainDetail from '../pages/Domains/DomainDetail';
 import DomainsPage from '../pages/Domains';
+import Inbox from '../pages/Inbox';
 import PlaceholderPage from '../pages/PlaceholderPage';
 import SendingLogs from '../pages/SendingLogs';
 import Settings from '../pages/Settings';
@@ -27,6 +28,8 @@ import type {
   Domain,
   DomainMode,
   DomainPatchPayload,
+  InboundMailbox,
+  InboundMessage,
   RuntimeConfig,
   SmtpCredential,
   SmtpRelay,
@@ -44,6 +47,8 @@ const emptyData: AppData = {
   smtpCredential: null,
   smtpCredentials: [],
   smtpRelays: [],
+  inboundMailboxes: [],
+  inboundMessages: [],
   dnsCredentials: [],
   apiTokens: [],
   settings: null,
@@ -55,6 +60,7 @@ const viewTitleKeys: Record<ViewKey, string> = {
   domains: 'nav.domains',
   'dns-api': 'nav.dnsApi',
   smtp: 'nav.smtp',
+  inbox: 'nav.inbox',
   tokens: 'nav.tokens',
   logs: 'nav.logs',
   webhooks: 'nav.webhooks',
@@ -98,10 +104,24 @@ function MailHubConsole() {
     setLoading(true);
     try {
       const me = await api.me();
-      const [config, domains, events, analytics, smtpCredential, smtpCredentials, smtpRelays, dnsCredentials, apiTokens] = await Promise.all([
+      const [
+        config,
+        domains,
+        events,
+        inboundMailboxes,
+        inboundMessages,
+        analytics,
+        smtpCredential,
+        smtpCredentials,
+        smtpRelays,
+        dnsCredentials,
+        apiTokens
+      ] = await Promise.all([
         api.config(),
         api.domains(),
         api.events(),
+        api.inboundMailboxes(),
+        api.inboundMessages(),
         api.analytics(7),
         api.smtpCredential(),
         api.smtpCredentials(),
@@ -121,6 +141,8 @@ function MailHubConsole() {
         config,
         domains: domains.domains || [],
         events: events.events || [],
+        inboundMailboxes: inboundMailboxes.mailboxes || [],
+        inboundMessages: inboundMessages.messages || [],
         analytics: analytics.analytics || null,
         smtpCredential: smtpCredential.credential || null,
         smtpCredentials: smtpCredentials.credentials || [],
@@ -237,6 +259,7 @@ function MailHubConsole() {
       from: `noreply@${domain.domain}`,
       subject: `MailHub test for ${domain.domain}`,
       text: `This is a MailHub test message from ${domain.domain}.`,
+      html: `<p>This is a MailHub test message from ${domain.domain}.</p><p><a href="${window.location.origin}/login">Open MailHub</a></p>`,
       smtpRelayId: domain.smtpRelayId || undefined
     });
   }
@@ -375,6 +398,50 @@ function MailHubConsole() {
     }));
   }
 
+  async function createInboundMailbox(values: { address: string; displayName?: string }) {
+    const result = await runAction(async () => api.createInboundMailbox(values), t('actions.inboundMailboxCreated'));
+    if (!result?.mailbox) return null;
+    setData((current) => ({
+      ...current,
+      inboundMailboxes: [result.mailbox, ...current.inboundMailboxes]
+    }));
+    return result.mailbox;
+  }
+
+  async function loadInboundMessages(mailboxId?: number | null) {
+    const result = await runAction(async () => api.inboundMessages(mailboxId));
+    if (!result?.messages) return [];
+    setData((current) => ({ ...current, inboundMessages: result.messages }));
+    return result.messages;
+  }
+
+  async function loadInboundMessage(id: number): Promise<InboundMessage | null> {
+    const result = await api.inboundMessage(id);
+    let inboundMessage = result.message;
+    if (inboundMessage && !inboundMessage.read) {
+      const readResult = await api.markInboundMessageRead(id, true);
+      inboundMessage = readResult.message || { ...inboundMessage, read: true };
+    }
+    if (inboundMessage) {
+      setData((current) => {
+        const previous = current.inboundMessages.find((item) => item.id === inboundMessage.id);
+        const shouldDecrementUnread = Boolean(previous && !previous.read && inboundMessage.read);
+        return {
+          ...current,
+          inboundMessages: current.inboundMessages.map((item) => (
+            item.id === inboundMessage.id ? { ...item, ...inboundMessage } : item
+          )),
+          inboundMailboxes: current.inboundMailboxes.map((mailbox) => (
+            mailbox.id === inboundMessage.mailboxId && shouldDecrementUnread
+              ? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount - 1) }
+              : mailbox
+          ))
+        };
+      });
+    }
+    return inboundMessage;
+  }
+
   async function createApiToken(name: string) {
     const result = await runAction(async () => api.createApiToken(name), t('tokens.createdSuccess'));
     if (!result?.token) return null;
@@ -457,6 +524,9 @@ function MailHubConsole() {
           <Form.Item name="text" label="Text">
             <Input.TextArea rows={5} />
           </Form.Item>
+          <Form.Item name="html" label="HTML">
+            <Input.TextArea rows={5} />
+          </Form.Item>
           <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
             <Select
               allowClear
@@ -552,6 +622,22 @@ function MailHubConsole() {
         />
       );
     }
+    if (activeView === 'inbox') {
+      return (
+        <Inbox
+          config={data.config}
+          domains={data.domains}
+          mailboxes={data.inboundMailboxes}
+          messages={data.inboundMessages}
+          loading={actionLoading}
+          onCreateMailbox={createInboundMailbox}
+          onLoadMessages={loadInboundMessages}
+          onLoadMessage={loadInboundMessage}
+          onCopy={copy}
+          onAddDomain={() => setAddOpen(true)}
+        />
+      );
+    }
     if (activeView === 'tokens') {
       return (
         <ApiTokens

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

@@ -231,6 +231,44 @@ const messages = {
     'logs.engagement': '互动追踪',
     'logs.opens': '打开',
     'logs.clicks': '点击',
+    'logs.trackingDisabled': '这封邮件发送时未启用打开/点击追踪。',
+    'logs.trackingDisabledShort': '未启用',
+    'inbox.title': '收件箱',
+    'inbox.subtitle': '管理本地收信邮箱,查看通过 MailHub SMTP 接收的邮件。',
+    'inbox.createMailbox': '新增收信邮箱',
+    'inbox.inboundDisabled': '当前运行配置已关闭入站收信。请设置 INBOUND_ENABLED=true 后重启服务。',
+    'inbox.mailboxes': '收信邮箱',
+    'inbox.mailboxAddress': '邮箱地址',
+    'inbox.unread': '未读',
+    'inbox.messageCount': '邮件数',
+    'inbox.lastMessageAt': '最近收信',
+    'inbox.noDomain': '请先添加域名,再创建该域名下的收信邮箱。',
+    'inbox.messages': '入站邮件',
+    'inbox.mailboxFilter': '收信邮箱',
+    'inbox.searchPlaceholder': '搜索发件人、主题或正文预览',
+    'inbox.receivedAt': '收信时间',
+    'inbox.subject': '主题',
+    'inbox.noSubject': '(无主题)',
+    'inbox.sender': '发件人',
+    'inbox.mailbox': '收信邮箱',
+    'inbox.read': '已读',
+    'inbox.preview': '预览',
+    'inbox.localPart': '邮箱前缀',
+    'inbox.localPartRequired': '请输入邮箱前缀',
+    'inbox.localPartInvalid': '邮箱前缀不能包含 @ 或空格',
+    'inbox.domainRequired': '请选择域名',
+    'inbox.displayName': '显示名称',
+    'inbox.messageDetail': '邮件详情',
+    'inbox.copyRaw': '复制原文',
+    'inbox.recipients': '收件人',
+    'inbox.textBody': '文本正文',
+    'inbox.htmlBody': 'HTML 源码',
+    'inbox.rawMessage': '原始 MIME',
+    'inbox.noTextBody': '暂无文本正文',
+    'inbox.noHtmlBody': '暂无 HTML 正文',
+    'inbox.noRawMessage': '暂无原始 MIME',
+    'inbox.messageNotFound': '邮件不存在或已被删除。',
+    'inbox.detailLoadFailed': '邮件详情加载失败。',
     'logs.trackingScope': '统计范围',
     'logs.messageLevel': '消息级(多收件人)',
     'logs.recipientLevel': '单收件人',
@@ -441,6 +479,7 @@ const messages = {
     'actions.smtpRelayCreated': 'SMTP 出口已新增',
     'actions.smtpRelayUpdated': 'SMTP 出口已更新',
     'actions.smtpRelayDeleted': 'SMTP 出口已删除',
+    'actions.inboundMailboxCreated': '收信邮箱已新增',
     'actions.settingsSaved': '系统设置已保存',
     'actions.webhookCreated': 'Webhook 已创建',
     'actions.webhookUpdated': 'Webhook 已更新',
@@ -452,6 +491,7 @@ const messages = {
     'nav.domains': '发信域名',
     'nav.dnsApi': 'DNS API',
     'nav.smtp': 'SMTP 凭据',
+    'nav.inbox': '收件箱',
     'nav.tokens': 'API Token',
     'nav.logs': '发送记录',
     'nav.webhooks': 'Webhooks',
@@ -690,6 +730,8 @@ const messages = {
     'logs.engagement': 'Engagement tracking',
     'logs.opens': 'Opens',
     'logs.clicks': 'Clicks',
+    'logs.trackingDisabled': 'Open and click tracking was not enabled when this email was sent.',
+    'logs.trackingDisabledShort': 'Off',
     'logs.trackingScope': 'Tracking scope',
     'logs.messageLevel': 'Message-level (multiple recipients)',
     'logs.recipientLevel': 'Single recipient',
@@ -704,6 +746,42 @@ const messages = {
     'logs.statusDeferred': 'Deferred',
     'logs.statusBounced': 'Bounced',
     'logs.statusFailed': 'Failed',
+    'inbox.title': 'Inbox',
+    'inbox.subtitle': 'Manage local receiving mailboxes and read mail accepted by MailHub SMTP.',
+    'inbox.createMailbox': 'New mailbox',
+    'inbox.inboundDisabled': 'Inbound receiving is disabled. Set INBOUND_ENABLED=true and restart the service.',
+    'inbox.mailboxes': 'Mailboxes',
+    'inbox.mailboxAddress': 'Mailbox address',
+    'inbox.unread': 'Unread',
+    'inbox.messageCount': 'Messages',
+    'inbox.lastMessageAt': 'Last received',
+    'inbox.noDomain': 'Add a domain before creating receiving mailboxes for it.',
+    'inbox.messages': 'Inbound messages',
+    'inbox.mailboxFilter': 'Mailbox',
+    'inbox.searchPlaceholder': 'Search sender, subject, or preview',
+    'inbox.receivedAt': 'Received at',
+    'inbox.subject': 'Subject',
+    'inbox.noSubject': '(no subject)',
+    'inbox.sender': 'Sender',
+    'inbox.mailbox': 'Mailbox',
+    'inbox.read': 'Read',
+    'inbox.preview': 'Preview',
+    'inbox.localPart': 'Local part',
+    'inbox.localPartRequired': 'Enter the local part',
+    'inbox.localPartInvalid': 'The local part cannot contain @ or spaces',
+    'inbox.domainRequired': 'Select a domain',
+    'inbox.displayName': 'Display name',
+    'inbox.messageDetail': 'Message detail',
+    'inbox.copyRaw': 'Copy raw',
+    'inbox.recipients': 'Recipients',
+    'inbox.textBody': 'Text body',
+    'inbox.htmlBody': 'HTML source',
+    'inbox.rawMessage': 'Raw MIME',
+    'inbox.noTextBody': 'No text body',
+    'inbox.noHtmlBody': 'No HTML body',
+    'inbox.noRawMessage': 'No raw MIME',
+    'inbox.messageNotFound': 'Message not found or deleted.',
+    'inbox.detailLoadFailed': 'Failed to load message detail.',
     'smtp.connectionTitle': 'SMTP connection',
     'smtp.loginCredentialsTitle': 'Sending login credentials',
     'smtp.username': 'Username',
@@ -900,6 +978,7 @@ const messages = {
     'actions.smtpRelayCreated': 'SMTP relay created',
     'actions.smtpRelayUpdated': 'SMTP relay updated',
     'actions.smtpRelayDeleted': 'SMTP relay deleted',
+    'actions.inboundMailboxCreated': 'Inbound mailbox created',
     'actions.settingsSaved': 'System settings saved',
     'actions.webhookCreated': 'Webhook created',
     'actions.webhookUpdated': 'Webhook updated',
@@ -911,6 +990,7 @@ const messages = {
     'nav.domains': 'Domains',
     'nav.dnsApi': 'DNS API',
     'nav.smtp': 'SMTP Credentials',
+    'nav.inbox': 'Inbox',
     'nav.tokens': 'API Tokens',
     'nav.logs': 'Sending Logs',
     'nav.webhooks': 'Webhooks',

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

@@ -8,6 +8,8 @@ import type {
   DnsCredential,
   Domain,
   DomainPatchPayload,
+  InboundMailbox,
+  InboundMessage,
   RuntimeConfig,
   SendEvent,
   SmtpCredential,
@@ -95,6 +97,16 @@ export const api = {
   domains: () => request<{ domains: Domain[] }>('/api/domains'),
   events: () => request<{ events: SendEvent[] }>('/api/events'),
   event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
+  inboundMailboxes: () => request<{ mailboxes: InboundMailbox[] }>('/api/inbound-mailboxes'),
+  createInboundMailbox: (data: { address: string; displayName?: string }) =>
+    request<{ mailbox: InboundMailbox }>('/api/inbound-mailboxes', { method: 'POST', data }),
+  inboundMessages: (mailboxId?: number | null) => {
+    const query = mailboxId ? `?mailboxId=${mailboxId}` : '';
+    return request<{ messages: InboundMessage[] }>(`/api/inbound-messages${query}`);
+  },
+  inboundMessage: (id: number) => request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`),
+  markInboundMessageRead: (id: number, read = true) =>
+    request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`, { method: 'PATCH', data: { read } }),
   analytics: (days = 7) => request<{ analytics: Analytics }>(`/api/analytics?days=${days}`),
   smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
   saveSmtpCredential: (data: { username: string; password?: string }) =>
@@ -141,7 +153,18 @@ export const api = {
     request<{ domain: Domain; apply?: Domain['status']['apply'] }>(`/api/domains/${id}/apply-dns`, { method: 'POST' }),
   rotateDkim: (id: number, selector?: string) =>
     request<{ domain: Domain }>(`/api/domains/${id}/rotate-dkim`, { method: 'POST', data: { selector } }),
-  sendTest: (id: number, data: { from?: string; to: string; subject?: string; text?: string; smtpRelayId?: number | string | null }) =>
+  sendTest: (
+    id: number,
+    data: {
+      from?: string;
+      to: string;
+      subject?: string;
+      text?: string;
+      html?: string;
+      tracking?: boolean | { opens?: boolean; clicks?: boolean };
+      smtpRelayId?: number | string | null;
+    }
+  ) =>
     request<{ queued: boolean }>(`/api/domains/${id}/test-send`, { method: 'POST', data }),
   deleteDomain: (id: number) => request<{ deleted: boolean }>(`/api/domains/${id}`, { method: 'DELETE' }),
   adminSettings: () => request<{ settings: RuntimeConfig }>('/api/admin/settings'),

+ 20 - 0
src/frontend/styles.css

@@ -511,6 +511,26 @@ body {
   margin-top: 16px;
 }
 
+.inbox-toolbar {
+  margin-bottom: 16px;
+}
+
+.inbox-message-body {
+  background: #f8fafc;
+  border: 1px solid var(--mh-border);
+  border-radius: 10px;
+  color: #1e293b;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
+  font-size: 12px;
+  line-height: 1.6;
+  margin: 0;
+  max-height: 52vh;
+  overflow: auto;
+  padding: 14px;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
 .delivery-log-entry {
   display: grid;
   gap: 8px;

+ 45 - 0
src/frontend/types.ts

@@ -3,6 +3,7 @@ export type ViewKey =
   | 'domains'
   | 'dns-api'
   | 'smtp'
+  | 'inbox'
   | 'tokens'
   | 'logs'
   | 'webhooks'
@@ -17,6 +18,8 @@ export interface UserResourceCounts {
   domains: number;
   dnsCredentials: number;
   apiTokens: number;
+  inboundMailboxes: number;
+  inboundMessages: number;
   sendEvents: number;
   smtpCredential: number;
 }
@@ -52,6 +55,7 @@ export interface RuntimeConfig {
     ports: Array<{ port: number; protocol: string }>;
     username: string;
     passwordSet: boolean;
+    inboundEnabled: boolean;
     tls: boolean;
     requireTlsForAuth: boolean;
   };
@@ -197,6 +201,8 @@ export interface AdminUserResourceGroup {
   dnsCredentials: DnsCredential[];
   smtpCredential: SmtpCredential | null;
   apiTokens: ApiToken[];
+  inboundMailboxes: InboundMailbox[];
+  inboundMessageCount: number;
   sendEventCount: number;
 }
 
@@ -256,6 +262,43 @@ export interface ApiToken {
   createdAt: string;
 }
 
+export interface InboundMailbox {
+  id: number;
+  userId: number;
+  domainId: number;
+  domain: string;
+  address: string;
+  localPart: string;
+  displayName: string;
+  status: string;
+  messageCount: number;
+  unreadCount: number;
+  lastMessageAt?: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface InboundMessage {
+  id: number;
+  mailboxId: number;
+  userId: number;
+  domainId: number;
+  domain: string;
+  mailboxAddress: string;
+  sender: string;
+  recipients: string[];
+  subject: string;
+  messageId: string;
+  preview: string;
+  read: boolean;
+  receivedAt: string;
+  createdAt: string;
+  updatedAt: string;
+  rawMessage?: string;
+  textBody?: string;
+  htmlBody?: string;
+}
+
 export interface DeliveryLogEntry {
   at: string;
   phase: 'connect' | 'smtp' | 'auth' | 'envelope' | 'data' | 'queue' | 'quit' | 'error' | string;
@@ -471,6 +514,8 @@ export interface AppData {
   smtpCredential: SmtpCredential | null;
   smtpCredentials: SmtpCredential[];
   smtpRelays: SmtpRelay[];
+  inboundMailboxes: InboundMailbox[];
+  inboundMessages: InboundMessage[];
   dnsCredentials: DnsCredential[];
   apiTokens: ApiToken[];
   settings: RuntimeConfig | null;

+ 125 - 0
src/inbound-mail.js

@@ -0,0 +1,125 @@
+import { Readable, Writable } from 'node:stream';
+import { Splitter, Streamer } from '@zone-eu/mailsplit';
+import { extractAddress, parseAddressList } from './mailer.js';
+
+export async function parseInboundMessage(rawMessage, envelopeRecipients = []) {
+  const source = String(rawMessage || '');
+  const textParts = await collectTextParts(source);
+  const textBody = textParts.find((part) => part.contentType === 'text/plain')?.body || '';
+  const htmlBody = textParts.find((part) => part.contentType === 'text/html')?.body || '';
+  const recipients = normalizeRecipients(envelopeRecipients);
+  const headerRecipients = [
+    ...parseAddressList(extractHeader(source, 'to')),
+    ...parseAddressList(extractHeader(source, 'cc'))
+  ];
+
+  return {
+    sender: extractAddress(extractHeader(source, 'from')) || extractAddress(extractHeader(source, 'sender')),
+    recipients: recipients.length ? recipients : normalizeRecipients(headerRecipients),
+    subject: decodeHeader(extractHeader(source, 'subject')) || '(no subject)',
+    messageId: extractHeader(source, 'message-id'),
+    rawMessage: source,
+    textBody,
+    htmlBody,
+    preview: previewText(textBody || htmlToText(htmlBody) || source)
+  };
+}
+
+async function collectTextParts(rawMessage) {
+  const parts = [];
+  const splitter = new Splitter({ ignoreEmbedded: true });
+  const streamer = new Streamer((node) => (
+    ['text/plain', 'text/html'].includes(node.contentType) && node.disposition !== 'attachment'
+  ));
+  const drain = new Writable({
+    objectMode: true,
+    write(_chunk, _encoding, callback) {
+      callback();
+    }
+  });
+
+  streamer.on('node', (data) => {
+    const chunks = [];
+    data.decoder.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
+    data.decoder.on('end', () => {
+      parts.push({
+        contentType: data.node.contentType,
+        body: decodeText(Buffer.concat(chunks), data.node.charset)
+      });
+      data.done();
+    });
+    data.decoder.on('error', () => data.done());
+  });
+
+  await new Promise((resolve, reject) => {
+    drain.on('finish', resolve);
+    drain.on('error', reject);
+    splitter.on('error', reject);
+    streamer.on('error', reject);
+    Readable.from([Buffer.from(rawMessage)]).pipe(splitter).pipe(streamer).pipe(drain);
+  });
+
+  if (!parts.length) {
+    const body = rawMessage.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
+    if (body) parts.push({ contentType: 'text/plain', body });
+  }
+
+  return parts;
+}
+
+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.find((header) => header.name === name.toLowerCase())?.value || '';
+}
+
+function decodeHeader(value) {
+  return String(value || '').replace(/=\?([^?]+)\?([bq])\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => {
+    const buffer = encoding.toLowerCase() === 'b'
+      ? Buffer.from(encoded, 'base64')
+      : Buffer.from(encoded.replace(/_/g, ' ').replace(/=([a-f0-9]{2})/gi, (_hex, value) => (
+          String.fromCharCode(Number.parseInt(value, 16))
+        )), 'binary');
+    return decodeText(buffer, charset);
+  });
+}
+
+function decodeText(buffer, charset) {
+  const normalized = String(charset || 'utf-8').trim().toLowerCase();
+  if (['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)) return buffer.toString('latin1').trim();
+  return buffer.toString('utf8').trim();
+}
+
+function normalizeRecipients(values) {
+  return [...new Set((Array.isArray(values) ? values : [values]).map(extractAddress).filter(Boolean))];
+}
+
+function previewText(value) {
+  return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
+}
+
+function htmlToText(value) {
+  return String(value || '')
+    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&amp;/gi, '&')
+    .replace(/&lt;/gi, '<')
+    .replace(/&gt;/gi, '>')
+    .replace(/&quot;/gi, '"')
+    .replace(/&#39;/g, "'");
+}

+ 2 - 0
src/layouts/AdminLayout.tsx

@@ -4,6 +4,7 @@ import {
   CloudServerOutlined,
   DashboardOutlined,
   GlobalOutlined,
+  InboxOutlined,
   KeyOutlined,
   MailOutlined,
   ReloadOutlined,
@@ -39,6 +40,7 @@ const navGroups: Array<{
     labelKey: 'nav.group.delivery',
     items: [
       { key: 'smtp', labelKey: 'nav.smtp', icon: <MailOutlined /> },
+      { key: 'inbox', labelKey: 'nav.inbox', icon: <InboxOutlined /> },
       { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
       { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
       { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> }

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

@@ -9,6 +9,8 @@ const MERGE_SUMMARY_ITEMS = [
   ['domains', '域名'],
   ['dnsCredentials', 'DNS 凭据'],
   ['apiTokens', 'API Token'],
+  ['inboundMailboxes', '收信邮箱'],
+  ['inboundMessages', '入站邮件'],
   ['sendEvents', '发送记录'],
   ['smtpCredential', 'SMTP 凭据']
 ];

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

@@ -420,6 +420,7 @@ function AdminResources({
     },
     { title: '资源', render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
     { title: '发送记录', dataIndex: 'sendEventCount', width: 120 },
+    { title: '入站邮件', dataIndex: 'inboundMessageCount', width: 120 },
     {
       title: 'SMTP',
       width: 120,
@@ -841,6 +842,8 @@ function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] })
       <Tag>域名 {counts.domains}</Tag>
       <Tag>DNS {counts.dnsCredentials}</Tag>
       <Tag>Token {counts.apiTokens}</Tag>
+      <Tag>收信 {counts.inboundMailboxes}</Tag>
+      <Tag>入站 {counts.inboundMessages}</Tag>
       <Tag>记录 {counts.sendEvents}</Tag>
       <Tag>SMTP {counts.smtpCredential}</Tag>
     </Space>

+ 349 - 0
src/pages/Inbox.tsx

@@ -0,0 +1,349 @@
+import {
+  CopyOutlined,
+  InboxOutlined,
+  PlusOutlined,
+  ReloadOutlined,
+  SearchOutlined
+} from '@ant-design/icons';
+import { Alert, Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Spin, Table, Tabs, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useMemo, useState } from 'react';
+
+import { EmptyState } from '../components/common/EmptyState';
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
+import { useI18n } from '../frontend/i18n/react';
+import type { Domain, InboundMailbox, InboundMessage, RuntimeConfig } from '../frontend/types';
+
+interface InboxProps {
+  config: RuntimeConfig | null;
+  domains: Domain[];
+  mailboxes: InboundMailbox[];
+  messages: InboundMessage[];
+  loading?: boolean;
+  onCreateMailbox: (values: { address: string; displayName?: string }) => Promise<InboundMailbox | null>;
+  onLoadMessages: (mailboxId?: number | null) => Promise<InboundMessage[]>;
+  onLoadMessage: (id: number) => Promise<InboundMessage | null>;
+  onCopy: (value: string) => void;
+  onAddDomain: () => void;
+}
+
+interface MailboxFormValues {
+  localPart: string;
+  domain: string;
+  displayName?: string;
+}
+
+export default function Inbox({
+  config,
+  domains,
+  mailboxes,
+  messages,
+  loading,
+  onCreateMailbox,
+  onLoadMessages,
+  onLoadMessage,
+  onCopy,
+  onAddDomain
+}: InboxProps) {
+  const { t } = useI18n();
+  const [form] = Form.useForm<MailboxFormValues>();
+  const [mailboxOpen, setMailboxOpen] = useState(false);
+  const [mailboxLoading, setMailboxLoading] = useState(false);
+  const [selectedMailboxId, setSelectedMailboxId] = useState<number | null>(null);
+  const [query, setQuery] = useState('');
+  const [selectedMessage, setSelectedMessage] = useState<InboundMessage | null>(null);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [detailError, setDetailError] = useState('');
+
+  const filteredMessages = useMemo(() => {
+    const cleanQuery = query.trim().toLowerCase();
+    if (!cleanQuery) return messages;
+    return messages.filter((message) => [
+      message.sender,
+      message.mailboxAddress,
+      message.subject,
+      message.preview,
+      message.recipients.join(', ')
+    ].some((value) => String(value || '').toLowerCase().includes(cleanQuery)));
+  }, [messages, query]);
+
+  const mailboxColumns: ColumnsType<InboundMailbox> = [
+    {
+      title: t('inbox.mailboxAddress'),
+      dataIndex: 'address',
+      render: (value: string, mailbox) => (
+        <Space wrap>
+          <Typography.Text strong>{value}</Typography.Text>
+          {mailbox.displayName ? <Typography.Text type="secondary">{mailbox.displayName}</Typography.Text> : null}
+        </Space>
+      )
+    },
+    {
+      title: t('inbox.unread'),
+      dataIndex: 'unreadCount',
+      width: 100,
+      render: (value: number) => (
+        <StatusPill tone={value > 0 ? 'warning' : 'neutral'}>{String(value)}</StatusPill>
+      )
+    },
+    { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 120 },
+    {
+      title: t('inbox.lastMessageAt'),
+      dataIndex: 'lastMessageAt',
+      width: 190,
+      render: formatOptionalTime
+    }
+  ];
+
+  const messageColumns: ColumnsType<InboundMessage> = [
+    {
+      title: t('inbox.receivedAt'),
+      dataIndex: 'receivedAt',
+      width: 190,
+      render: (value: string) => new Date(value).toLocaleString()
+    },
+    {
+      title: t('inbox.subject'),
+      dataIndex: 'subject',
+      ellipsis: true,
+      render: (value: string, message) => (
+        <Button type="link" className="table-link" onClick={() => void openMessage(message)}>
+          {value || t('inbox.noSubject')}
+        </Button>
+      )
+    },
+    { title: t('inbox.sender'), dataIndex: 'sender', width: 220, ellipsis: true },
+    { title: t('inbox.mailbox'), dataIndex: 'mailboxAddress', width: 220, ellipsis: true },
+    {
+      title: t('common.status'),
+      dataIndex: 'read',
+      width: 100,
+      render: (read: boolean) => (
+        <Tag color={read ? 'default' : 'blue'}>{read ? t('inbox.read') : t('inbox.unread')}</Tag>
+      )
+    },
+    { title: t('inbox.preview'), dataIndex: 'preview', ellipsis: true }
+  ];
+
+  return (
+    <>
+      <Space direction="vertical" size={20} className="full-width">
+        <PageHeader
+          title={t('inbox.title')}
+          subtitle={t('inbox.subtitle')}
+          extra={
+            <Space wrap>
+              <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void onLoadMessages(selectedMailboxId)}>
+                {t('common.refresh')}
+              </Button>
+              <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openMailboxModal}>
+                {t('inbox.createMailbox')}
+              </Button>
+            </Space>
+          }
+        />
+
+        {config?.submission?.inboundEnabled === false ? (
+          <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} />
+        ) : null}
+
+        <SectionCard
+          title={t('inbox.mailboxes')}
+          extra={
+            <Typography.Text type="secondary">
+              {mailboxes.length}
+            </Typography.Text>
+          }
+        >
+          {domains.length ? (
+            <Table
+              rowKey="id"
+              columns={mailboxColumns}
+              dataSource={mailboxes}
+              pagination={{ pageSize: 5 }}
+              scroll={{ x: 760 }}
+            />
+          ) : (
+            <EmptyState
+              icon={<InboxOutlined />}
+              description={t('inbox.noDomain')}
+              action={<Button type="primary" onClick={onAddDomain}>{t('common.addDomain')}</Button>}
+            />
+          )}
+        </SectionCard>
+
+        <SectionCard
+          title={t('inbox.messages')}
+          extra={
+            <Typography.Text type="secondary">
+              {filteredMessages.length} / {messages.length}
+            </Typography.Text>
+          }
+        >
+          <div className="page-toolbar inbox-toolbar">
+            <Space wrap>
+              <Select
+                allowClear
+                placeholder={t('inbox.mailboxFilter')}
+                value={selectedMailboxId || undefined}
+                onChange={(value) => void selectMailbox(value || null)}
+                options={mailboxes.map((mailbox) => ({ value: mailbox.id, label: mailbox.address }))}
+                className="toolbar-select"
+              />
+              <Input
+                allowClear
+                prefix={<SearchOutlined />}
+                placeholder={t('inbox.searchPlaceholder')}
+                value={query}
+                onChange={(event) => setQuery(event.target.value)}
+                className="toolbar-search"
+              />
+            </Space>
+          </div>
+          <Table
+            rowKey="id"
+            columns={messageColumns}
+            dataSource={filteredMessages}
+            loading={loading}
+            scroll={{ x: 1180 }}
+          />
+        </SectionCard>
+      </Space>
+
+      <Modal
+        title={t('inbox.createMailbox')}
+        open={mailboxOpen}
+        confirmLoading={mailboxLoading}
+        onOk={saveMailbox}
+        onCancel={closeMailboxModal}
+      >
+        <Form form={form} layout="vertical">
+          <Form.Item
+            name="localPart"
+            label={t('inbox.localPart')}
+            rules={[
+              { required: true, message: t('inbox.localPartRequired') },
+              { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }
+            ]}
+          >
+            <Input placeholder="support" />
+          </Form.Item>
+          <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]}>
+            <Select
+              options={domains.map((domain) => ({ value: domain.domain, label: domain.domain }))}
+              placeholder="example.com"
+            />
+          </Form.Item>
+          <Form.Item name="displayName" label={t('inbox.displayName')}>
+            <Input placeholder="Support" />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      <Drawer
+        title={selectedMessage ? `${t('inbox.messageDetail')} · mh-in-${selectedMessage.id}` : t('inbox.messageDetail')}
+        open={Boolean(selectedMessage)}
+        width="min(820px, 100vw)"
+        onClose={() => setSelectedMessage(null)}
+        extra={selectedMessage?.rawMessage ? (
+          <Button icon={<CopyOutlined />} onClick={() => onCopy(selectedMessage.rawMessage || '')}>
+            {t('inbox.copyRaw')}
+          </Button>
+        ) : null}
+      >
+        <Spin spinning={detailLoading}>
+          {selectedMessage ? (
+            <Space direction="vertical" size={16} className="full-width">
+              {detailError ? <Alert type="error" showIcon message={detailError} /> : null}
+              <Descriptions bordered size="small" column={1}>
+                <Descriptions.Item label={t('inbox.receivedAt')}>{formatOptionalTime(selectedMessage.receivedAt)}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.sender')}>{selectedMessage.sender || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.recipients')}>{selectedMessage.recipients.join(', ') || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.mailbox')}>{selectedMessage.mailboxAddress || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.subject')}>{selectedMessage.subject || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.messageId')}>
+                  <Typography.Text code>{selectedMessage.messageId || '-'}</Typography.Text>
+                </Descriptions.Item>
+              </Descriptions>
+              <Tabs
+                items={[
+                  {
+                    key: 'text',
+                    label: t('inbox.textBody'),
+                    children: <MessageBody value={selectedMessage.textBody} empty={t('inbox.noTextBody')} />
+                  },
+                  {
+                    key: 'html',
+                    label: t('inbox.htmlBody'),
+                    children: <MessageBody value={selectedMessage.htmlBody} empty={t('inbox.noHtmlBody')} />
+                  },
+                  {
+                    key: 'raw',
+                    label: t('inbox.rawMessage'),
+                    children: <MessageBody value={selectedMessage.rawMessage} empty={t('inbox.noRawMessage')} />
+                  }
+                ]}
+              />
+            </Space>
+          ) : null}
+        </Spin>
+      </Drawer>
+    </>
+  );
+
+  function openMailboxModal() {
+    form.setFieldsValue({ localPart: '', domain: domains[0]?.domain || '', displayName: '' });
+    setMailboxOpen(true);
+  }
+
+  function closeMailboxModal() {
+    setMailboxOpen(false);
+    form.resetFields();
+  }
+
+  async function saveMailbox() {
+    const values = await form.validateFields();
+    setMailboxLoading(true);
+    try {
+      const mailbox = await onCreateMailbox({
+        address: `${values.localPart.trim()}@${values.domain}`,
+        displayName: values.displayName?.trim()
+      });
+      if (!mailbox) return;
+      closeMailboxModal();
+    } finally {
+      setMailboxLoading(false);
+    }
+  }
+
+  async function selectMailbox(mailboxId: number | null) {
+    setSelectedMailboxId(mailboxId);
+    await onLoadMessages(mailboxId);
+  }
+
+  async function openMessage(message: InboundMessage) {
+    setSelectedMessage(message);
+    setDetailError('');
+    setDetailLoading(true);
+    try {
+      const detail = await onLoadMessage(message.id);
+      if (detail) setSelectedMessage(detail);
+      if (!detail) setDetailError(t('inbox.messageNotFound'));
+    } catch (error) {
+      setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
+    } finally {
+      setDetailLoading(false);
+    }
+  }
+}
+
+function MessageBody({ value, empty }: { value?: string; empty: string }) {
+  if (!value) return <EmptyState description={empty} />;
+  return <pre className="inbox-message-body">{value}</pre>;
+}
+
+function formatOptionalTime(value?: string | null) {
+  return value ? new Date(value).toLocaleString() : '-';
+}

+ 16 - 5
src/pages/SendingLogs.tsx

@@ -54,13 +54,13 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
     },
     {
       title: t('logs.opens'),
-      width: 90,
-      render: (_, event) => event.tracking?.summary?.totalOpens ?? 0
+      width: 110,
+      render: (_, event) => trackingMetric(event, 'opens')
     },
     {
       title: t('logs.clicks'),
-      width: 90,
-      render: (_, event) => event.tracking?.summary?.totalClicks ?? 0
+      width: 110,
+      render: (_, event) => trackingMetric(event, 'clicks')
     },
     { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
@@ -154,6 +154,13 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
     }
   }
 
+  function trackingMetric(event: SendEvent, type: 'opens' | 'clicks') {
+    if (!event.tracking?.enabled) return <Tag>{t('logs.trackingDisabledShort')}</Tag>;
+    return type === 'opens'
+      ? event.tracking.summary?.totalOpens ?? 0
+      : event.tracking.summary?.totalClicks ?? 0;
+  }
+
   function DeliveryLogDrawer({
     event,
     loading,
@@ -243,7 +250,11 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
                   ) : null}
                   {event.tracking.links?.length ? <TrackingLinksTable links={event.tracking.links} /> : null}
                 </SectionCard>
-              ) : null}
+              ) : (
+                <SectionCard title={t('logs.engagement')} className="delivery-log-card">
+                  <Alert type="info" showIcon message={t('logs.trackingDisabled')} />
+                </SectionCard>
+              )}
               <SectionCard title={t('logs.trackingTimeline')} className="delivery-log-card">
                 {event.tracking?.eventsTruncated ? (
                   <Alert type="warning" showIcon message={t('logs.trackingTimelineTruncated')} />

+ 49 - 0
src/server.js

@@ -11,6 +11,7 @@ import {
   createApiToken,
   createAccountToken,
   createDomain,
+  createInboundMailbox,
   createSendEvent,
   createTrackingLink,
   createUserWithAccountToken,
@@ -27,6 +28,7 @@ import {
   getDnsCredential,
   getDomain,
   getDomainByName,
+  getInboundMessage,
   getSendEvent,
   getSendAnalytics,
   getSettings,
@@ -42,6 +44,8 @@ import {
   listAuditLogs,
   listDnsCredentials,
   listDomains,
+  listInboundMailboxes,
+  listInboundMessages,
   listSendEvents,
   listSmtpCredentials,
   listSmtpRelays,
@@ -50,6 +54,7 @@ import {
   listWebhooks,
   logAudit,
   logSendEvent,
+  markInboundMessageRead,
   markUserEmailVerified,
   previewUserMerge,
   replayWebhookDelivery,
@@ -153,6 +158,8 @@ const envConfig = {
   submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
   submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
   submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
+  submissionMaxMessageBytes: Number(process.env.SUBMISSION_MAX_MESSAGE_BYTES || 50 * 1024 * 1024),
+  inboundEnabled: String(process.env.INBOUND_ENABLED || 'true').toLowerCase() !== 'false',
   sessionSecret: process.env.SESSION_SECRET || fallbackSecret,
   trackingSecret: process.env.TRACKING_SECRET || process.env.SESSION_SECRET || fallbackSecret,
   trustProxy: String(process.env.TRUST_PROXY || '').toLowerCase() === 'true',
@@ -261,6 +268,8 @@ startSubmissionServer({
   listeners: envConfig.submissionListeners,
   hostname: envConfig.submissionHost,
   allowInsecureAuth: envConfig.submissionAllowInsecureAuth,
+  inboundEnabled: envConfig.inboundEnabled,
+  maxMessageBytes: envConfig.submissionMaxMessageBytes,
   tlsCertPath: envConfig.submissionTlsCert,
   tlsKeyPath: envConfig.submissionTlsKey,
   relayHost: envConfig.smtpHost,
@@ -332,6 +341,43 @@ async function handleApi(req, res, url, user) {
   if (method === 'GET' && pathname === '/api/events') {
     return sendJson(res, 200, { events: listSendEvents(user.id) });
   }
+  if (method === 'GET' && pathname === '/api/inbound-mailboxes') {
+    return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
+  }
+  if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
+    const body = await readJson(req);
+    try {
+      return sendJson(res, 201, {
+        mailbox: createInboundMailbox(user.id, {
+          address: body.address,
+          displayName: body.displayName
+        })
+      });
+    } catch (error) {
+      if (isUniqueError(error)) return sendJson(res, 409, { error: '该收信邮箱已存在。' });
+      return sendJson(res, 400, { error: error.message || '收信邮箱创建失败。' });
+    }
+  }
+  if (method === 'GET' && pathname === '/api/inbound-messages') {
+    return sendJson(res, 200, {
+      messages: listInboundMessages(user.id, {
+        mailboxId: Number(url.searchParams.get('mailboxId') || 0) || null
+      })
+    });
+  }
+  const inboundMessageMatch = pathname.match(/^\/api\/inbound-messages\/(\d+)$/);
+  if (inboundMessageMatch) {
+    const id = Number(inboundMessageMatch[1]);
+    if (method === 'GET') {
+      const message = getInboundMessage(user.id, id);
+      return sendJson(res, message ? 200 : 404, { message });
+    }
+    if (method === 'PATCH') {
+      const body = await readJson(req);
+      const message = markInboundMessageRead(user.id, id, body.read !== false);
+      return sendJson(res, message ? 200 : 404, { message });
+    }
+  }
   const sendEventMatch = pathname.match(/^\/api\/events\/(\d+)$/);
   if (sendEventMatch && method === 'GET') {
     const event = getSendEvent(user.id, Number(sendEventMatch[1]), { trackingSecret: envConfig.trackingSecret });
@@ -663,6 +709,8 @@ async function handleApi(req, res, url, user) {
         to: body.to,
         subject: body.subject || `MailHub test for ${row.domain}`,
         text: body.text || `This is a MailHub test message from ${row.domain}.`,
+        html: body.html,
+        tracking: body.tracking,
         smtpRelayId: body.smtpRelayId
       }, user);
       return sendJson(res, 202, result);
@@ -1446,6 +1494,7 @@ function publicConfig(user) {
       ports: publicSubmissionListeners(envConfig.submissionListeners),
       username: smtpCredential?.username || '',
       passwordSet: Boolean(smtpCredential?.passwordSet),
+      inboundEnabled: envConfig.inboundEnabled,
       tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
       requireTlsForAuth: !envConfig.submissionAllowInsecureAuth
     },

+ 78 - 14
src/submission.js

@@ -3,12 +3,15 @@ import tls from 'node:tls';
 import { readFileSync } from 'node:fs';
 import {
   createSendEvent,
+  createInboundMessage,
   createTrackingLink,
   finalizeSendEvent,
   getDomainByName,
+  getInboundMailboxByAddress,
   logSendEvent,
   verifySmtpCredential
 } from './db.js';
+import { parseInboundMessage } from './inbound-mail.js';
 import {
   domainFromAddress,
   extractAddress,
@@ -24,6 +27,7 @@ import {
 } from './tracking.js';
 
 const implicitTlsDetectTimeoutMs = 300;
+const defaultMaxMessageBytes = 50 * 1024 * 1024;
 
 export function startSubmissionServer(config) {
   if (!config.enabled) return null;
@@ -209,7 +213,11 @@ class SubmissionSession {
     this.user = null;
     this.authenticated = false;
     this.mailFrom = '';
+    this.mailFromAccepted = false;
     this.recipients = [];
+    this.inboundMailboxes = [];
+    this.dataBytes = 0;
+    this.dataTooLarge = false;
     this.remoteAddress = socket.remoteAddress || '';
     this.onDataBound = (chunk) => this.onData(chunk);
     this.queue = Promise.resolve();
@@ -237,7 +245,17 @@ class SubmissionSession {
   async onLine(line) {
     if (this.dataMode) {
       if (line === '.') return await this.finishData();
-      this.dataLines.push(line.startsWith('..') ? line.slice(1) : line);
+      const dataLine = line.startsWith('..') ? line.slice(1) : line;
+      const nextBytes = this.dataBytes + Buffer.byteLength(`${dataLine}\r\n`, 'utf8');
+      if (nextBytes > this.maxMessageBytes()) {
+        this.dataTooLarge = true;
+        this.dataBytes = nextBytes;
+        return;
+      }
+      if (!this.dataTooLarge) {
+        this.dataLines.push(dataLine);
+        this.dataBytes = nextBytes;
+      }
       return;
     }
 
@@ -264,7 +282,7 @@ class SubmissionSession {
 
   ehlo() {
     this.socket.write(`250-${this.config.hostname}\r\n`);
-    this.socket.write('250-SIZE 52428800\r\n');
+    this.socket.write(`250-SIZE ${this.maxMessageBytes()}\r\n`);
     this.socket.write('250-8BITMIME\r\n');
     if (this.config.startTlsAvailable && !this.config.tlsActive) {
       this.socket.write('250-STARTTLS\r\n');
@@ -353,35 +371,51 @@ class SubmissionSession {
   }
 
   mail(argument) {
-    if (!this.authenticated) return this.write(530, 'Authentication required');
-    const address = extractPathAddress(argument);
-    if (!address) return this.write(501, 'Invalid MAIL FROM');
+    if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    const address = extractPathAddress(argument, { allowEmpty: !this.authenticated });
+    if (address === null) return this.write(501, 'Invalid MAIL FROM');
     this.mailFrom = address;
+    this.mailFromAccepted = true;
     this.recipients = [];
+    this.inboundMailboxes = [];
     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');
+    if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    if (!this.mailFromAccepted) return this.write(503, 'MAIL FROM required first');
     const address = extractPathAddress(argument);
-    if (!address) return this.write(501, 'Invalid RCPT TO');
+    if (address === null || !address) return this.write(501, 'Invalid RCPT TO');
     if (this.recipients.length >= 100) return this.write(452, 'Too many recipients');
+    if (!this.authenticated) {
+      const mailbox = getInboundMailboxByAddress(address);
+      if (!mailbox) return this.write(550, 'Recipient is not a local MailHub mailbox');
+      this.inboundMailboxes.push(mailbox);
+      this.recipients.push(address);
+      return this.write(250, 'Recipient OK');
+    }
     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');
+    if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    if (!this.mailFromAccepted || !this.recipients.length) return this.write(503, 'Need MAIL FROM and RCPT TO first');
     this.dataMode = true;
     this.dataLines = [];
+    this.dataBytes = 0;
+    this.dataTooLarge = false;
     return this.write(354, 'End data with <CR><LF>.<CR><LF>');
   }
 
   async finishData() {
     this.dataMode = false;
+    if (this.dataTooLarge) {
+      this.resetEnvelope(false);
+      return this.write(552, 'Message size exceeds fixed maximum message size');
+    }
     const rawMessage = `${this.dataLines.join('\r\n')}\r\n`;
+    if (!this.authenticated) return await this.finishInboundData(rawMessage);
     const headerFrom = extractHeader(rawMessage, 'from');
     const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
     const sender = extractAddress(headerFrom) || this.mailFrom;
@@ -473,11 +507,35 @@ class SubmissionSession {
     }
   }
 
+  async finishInboundData(rawMessage) {
+    if (!this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    if (!this.inboundMailboxes.length) return this.write(550, 'Recipient is not a local MailHub mailbox');
+    try {
+      const parsedMessage = await parseInboundMessage(rawMessage, this.recipients);
+      for (const mailbox of this.inboundMailboxes) {
+        createInboundMessage(mailbox, {
+          ...parsedMessage,
+          recipients: [mailbox.address],
+          sender: parsedMessage.sender || this.mailFrom
+        });
+      }
+      this.resetEnvelope(false);
+      return this.write(250, 'Message accepted');
+    } catch (error) {
+      console.error(error);
+      return this.write(451, 'Temporary local delivery error');
+    }
+  }
+
   resetEnvelope(reply = true) {
     this.mailFrom = '';
+    this.mailFromAccepted = false;
     this.recipients = [];
+    this.inboundMailboxes = [];
     this.dataMode = false;
     this.dataLines = [];
+    this.dataBytes = 0;
+    this.dataTooLarge = false;
     this.user = this.authenticated ? this.user : null;
     if (reply) this.write(250, 'OK');
   }
@@ -489,12 +547,18 @@ class SubmissionSession {
   canAuthenticate() {
     return this.config.tlsActive || this.config.allowInsecureAuth;
   }
+
+  maxMessageBytes() {
+    const value = Number(this.config.maxMessageBytes || defaultMaxMessageBytes);
+    return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultMaxMessageBytes;
+  }
 }
 
-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 extractPathAddress(argument, { allowEmpty = false } = {}) {
+  const match = String(argument || '').match(/FROM:\s*<([^>]*)>|TO:\s*<([^>]*)>/i);
+  const raw = match ? (match[1] ?? match[2]) : argument;
+  if (allowEmpty && String(raw || '').trim() === '') return '';
+  return extractAddress(raw) || null;
 }
 
 function extractHeader(rawMessage, name) {

+ 54 - 0
test/db.test.js

@@ -14,6 +14,8 @@ import {
   createSendEvent,
   createTrackingLink,
   createDomain,
+  createInboundMailbox,
+  createInboundMessage,
   createUser,
   createUserWithAccountToken,
   createWebhook,
@@ -21,6 +23,7 @@ import {
   deleteSmtpRelay,
   getDnsCredential,
   getDomain,
+  getInboundMessage,
   getSendEvent,
   getSendAnalytics,
   getSmtpRelay,
@@ -31,6 +34,8 @@ import {
   initDatabase,
   listAuditLogs,
   listDomains,
+  listInboundMailboxes,
+  listInboundMessages,
   listSendEvents,
   listSmtpCredentials,
   listSmtpRelays,
@@ -1004,6 +1009,13 @@ test('lists users with owned resource counts', () => {
   const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
   createDomain(alice.id, domainFixture('news.alice.example'));
   const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
+  const aliceMailbox = createInboundMailbox(alice.id, { address: 'support@alice.example' });
+  createInboundMessage(aliceMailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@alice.example'],
+    subject: 'Inbound',
+    rawMessage: 'Subject: Inbound\r\n\r\nHello'
+  });
 
   saveDnsCredential(alice.id, {
     name: 'Alice Cloudflare',
@@ -1057,6 +1069,8 @@ test('lists users with owned resource counts', () => {
     domains: 2,
     dnsCredentials: 2,
     apiTokens: 2,
+    inboundMailboxes: 1,
+    inboundMessages: 1,
     sendEvents: 2,
     smtpCredential: 2
   });
@@ -1064,6 +1078,8 @@ test('lists users with owned resource counts', () => {
     domains: 1,
     dnsCredentials: 0,
     apiTokens: 1,
+    inboundMailboxes: 0,
+    inboundMessages: 0,
     sendEvents: 1,
     smtpCredential: 0
   });
@@ -1087,6 +1103,13 @@ test('builds admin resource inventory grouped by user with ownership warnings',
     ...domainFixture('alice.example'),
     dnsCredentialId: bobCredential.id
   });
+  const aliceMailbox = createInboundMailbox(alice.id, { address: 'support@alice.example' });
+  createInboundMessage(aliceMailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@alice.example'],
+    subject: 'Inbound inventory',
+    rawMessage: 'Subject: Inbound inventory\r\n\r\nHello'
+  });
   createDomain(bob.id, domainFixture('bob.example'));
   createApiToken(alice.id, 'primary');
   saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
@@ -1107,6 +1130,8 @@ test('builds admin resource inventory grouped by user with ownership warnings',
   assert.equal(aliceResources.domains[0].domain, 'alice.example');
   assert.equal(aliceResources.dnsCredentials.length, 0);
   assert.equal(aliceResources.apiTokens.length, 1);
+  assert.equal(aliceResources.inboundMailboxes.length, 1);
+  assert.equal(aliceResources.inboundMessageCount, 1);
   assert.equal(aliceResources.smtpCredential.username, 'smtp-alice');
   assert.equal(aliceResources.smtpCredential.passwordSet, true);
   assert.equal(aliceResources.sendEventCount, 1);
@@ -1157,6 +1182,13 @@ test('transfers individual resources with audit logs', () => {
   const domainOnly = createDomain(alice.id, { ...domainFixture('domain-only.example'), dnsCredentialId: domainOnlyCredential.id });
   const clearDomain = createDomain(alice.id, { ...domainFixture('clear.example'), dnsCredentialId: clearCredential.id });
   const withDomain = createDomain(alice.id, { ...domainFixture('with.example'), dnsCredentialId: withCredential.id });
+  const domainOnlyMailbox = createInboundMailbox(alice.id, { address: 'support@domain-only.example' });
+  const inboundMessage = createInboundMessage(domainOnlyMailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@domain-only.example'],
+    subject: 'Transfer inbound',
+    rawMessage: 'Subject: Transfer inbound\r\n\r\nHello'
+  });
   const apiToken = createApiToken(alice.id, 'primary');
 
   assert.equal(transferDomain({
@@ -1166,6 +1198,10 @@ test('transfers individual resources with audit logs', () => {
     dnsCredentialMode: 'domain_only'
   }).userId, bob.id);
   assert.equal(getDomain(domainOnly.id).dnsCredentialId, domainOnlyCredential.id);
+  assert.equal(listInboundMailboxes(alice.id).length, 0);
+  assert.equal(listInboundMailboxes(bob.id)[0].address, 'support@domain-only.example');
+  assert.equal(getInboundMessage(bob.id, inboundMessage.id).subject, 'Transfer inbound');
+  assert.equal(getInboundMessage(alice.id, inboundMessage.id), null);
   assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
   assert.throws(
     () => transferDomain({
@@ -1237,6 +1273,13 @@ test('previews and executes user merge with resource counts and multiple smtp cr
     credentials: { apiToken: 'source-secret' }
   });
   const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
+  const inboundMailbox = createInboundMailbox(source.id, { address: 'support@source.example' });
+  createInboundMessage(inboundMailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@source.example'],
+    subject: 'Merge inbound',
+    rawMessage: 'Subject: Merge inbound\r\n\r\nHello'
+  });
   const apiToken = createApiToken(source.id, 'primary');
   saveSmtpCredential(source.id, { username: 'smtp-source', password: 'source-secret-123' });
   saveSmtpCredential(source.id, { username: 'smtp-source-app', password: 'source-secret-456' });
@@ -1256,18 +1299,24 @@ test('previews and executes user merge with resource counts and multiple smtp cr
     domains: 1,
     dnsCredentials: 1,
     apiTokens: 1,
+    inboundMailboxes: 1,
+    inboundMessages: 1,
     sendEvents: 1,
     smtpCredential: 2
   });
   assert.equal(preview.resources.source.domains[0].domain, 'source.example');
   assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
   assert.equal(preview.resources.source.apiTokens[0].name, 'primary');
+  assert.equal(preview.resources.source.inboundMailboxes[0].address, 'support@source.example');
+  assert.equal(preview.resources.source.inboundMessageCount, 1);
   assert.equal(preview.resources.source.sendEventCount, 1);
   assert.equal(preview.resources.target.domains.length, 0);
   assert.deepEqual(preview.selectedCounts, {
     domains: 1,
     dnsCredentials: 1,
     apiTokens: 1,
+    inboundMailboxes: 1,
+    inboundMessages: 1,
     sendEvents: 1,
     smtpCredential: 2
   });
@@ -1296,10 +1345,15 @@ test('previews and executes user merge with resource counts and multiple smtp cr
     domains: 1,
     dnsCredentials: 1,
     apiTokens: 1,
+    inboundMailboxes: 1,
+    inboundMessages: 1,
     sendEvents: 1,
     smtpCredential: 2
   });
   assert.equal(getDomain(domain.id).userId, target.id);
+  assert.equal(listInboundMailboxes(target.id)[0].address, 'support@source.example');
+  assert.equal(listInboundMessages(target.id)[0].subject, 'Merge inbound');
+  assert.deepEqual(listInboundMailboxes(source.id), []);
   assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);
   assert.equal(verifyApiToken(apiToken.token).id, target.id);
   assert.equal(listSendEvents(target.id).length, 1);

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

@@ -24,6 +24,8 @@ test('summarizes merge preview counts and confirmation text', () => {
       domains: 3,
       dnsCredentials: 2,
       apiTokens: 1,
+      inboundMailboxes: 4,
+      inboundMessages: 5,
       sendEvents: 9,
       smtpCredential: 0
     }
@@ -34,6 +36,8 @@ test('summarizes merge preview counts and confirmation text', () => {
     { key: 'domains', label: '域名', count: 3 },
     { key: 'dnsCredentials', label: 'DNS 凭据', count: 2 },
     { key: 'apiTokens', label: 'API Token', count: 1 },
+    { key: 'inboundMailboxes', label: '收信邮箱', count: 4 },
+    { key: 'inboundMessages', label: '入站邮件', count: 5 },
     { key: 'sendEvents', label: '发送记录', count: 9 },
     { key: 'smtpCredential', label: 'SMTP 凭据', count: 0 }
   ]);

+ 103 - 0
test/inbound-db.test.js

@@ -0,0 +1,103 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createInboundMailbox,
+  createInboundMessage,
+  createUser,
+  getInboundMailboxByAddress,
+  getInboundMessage,
+  initDatabase,
+  listInboundMailboxes,
+  listInboundMessages,
+  markInboundMessageRead
+} from '../src/db.js';
+
+test('users can create inbound mailboxes and read received messages', () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-db-')), 'inbound-secret');
+  const user = createUser({ username: 'inbound-user', email: 'inbound-user@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'inbound.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.inbound.example',
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'Support@Inbound.Example',
+    displayName: 'Support'
+  });
+  assert.equal(mailbox.address, 'support@inbound.example');
+  assert.equal(mailbox.displayName, 'Support');
+  assert.equal(mailbox.messageCount, 0);
+  assert.equal(mailbox.unreadCount, 0);
+
+  const resolved = getInboundMailboxByAddress('SUPPORT@INBOUND.EXAMPLE');
+  assert.equal(resolved.id, mailbox.id);
+  assert.equal(resolved.userId, user.id);
+
+  const message = createInboundMessage(resolved, {
+    sender: 'alice@example.net',
+    recipients: ['support@inbound.example'],
+    subject: 'Hello inbound',
+    messageId: '<hello@example.net>',
+    rawMessage: 'From: alice@example.net\r\nTo: support@inbound.example\r\nSubject: Hello inbound\r\n\r\nHello MailHub',
+    textBody: 'Hello MailHub',
+    htmlBody: ''
+  });
+  assert.equal(message.mailboxId, mailbox.id);
+  assert.equal(message.read, false);
+
+  const mailboxes = listInboundMailboxes(user.id);
+  assert.equal(mailboxes.length, 1);
+  assert.equal(mailboxes[0].messageCount, 1);
+  assert.equal(mailboxes[0].unreadCount, 1);
+
+  const messages = listInboundMessages(user.id);
+  assert.equal(messages.length, 1);
+  assert.equal(messages[0].subject, 'Hello inbound');
+  assert.equal(messages[0].preview, 'Hello MailHub');
+  assert.equal('rawMessage' in messages[0], false);
+
+  const detail = getInboundMessage(user.id, message.id);
+  assert.equal(detail.rawMessage.includes('Hello MailHub'), true);
+  assert.equal(detail.textBody, 'Hello MailHub');
+
+  const readMessage = markInboundMessageRead(user.id, message.id, true);
+  assert.equal(readMessage.read, true);
+  assert.equal(listInboundMailboxes(user.id)[0].unreadCount, 0);
+  assert.equal(markInboundMessageRead(999999, message.id, true), null);
+});
+
+test('inbound mailboxes must belong to a domain owned by the user', () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-db-scope-')), 'inbound-secret');
+  const owner = createUser({ username: 'owner-user', email: 'owner@example.com', password: 'password123' });
+  const other = createUser({ username: 'other-user', email: 'other@example.com', password: 'password123' });
+  createDomain(owner.id, {
+    domain: 'owned.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.owned.example',
+    sendingIp: '192.0.2.11',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+
+  assert.throws(
+    () => createInboundMailbox(other.id, { address: 'support@owned.example' }),
+    /收信域名不存在/
+  );
+});

+ 38 - 0
test/inbound-mail.test.js

@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { parseInboundMessage } from '../src/inbound-mail.js';
+
+test('parseInboundMessage extracts common headers and text bodies from MIME', async () => {
+  const rawMessage = [
+    'From: Alice <alice@example.net>',
+    'To: Support <support@inbound.example>',
+    'Subject: =?UTF-8?B?5pS25L+h5rWL6K+V?=',
+    'Message-ID: <mime-test@example.net>',
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/alternative; boundary="alt"',
+    '',
+    '--alt',
+    'Content-Type: text/plain; charset=UTF-8',
+    'Content-Transfer-Encoding: base64',
+    '',
+    Buffer.from('Hello plain body.', 'utf8').toString('base64'),
+    '--alt',
+    'Content-Type: text/html; charset=UTF-8',
+    'Content-Transfer-Encoding: quoted-printable',
+    '',
+    '<p>Hello <strong>HTML</strong> body.</p>',
+    '--alt--',
+    ''
+  ].join('\r\n');
+
+  const parsed = await parseInboundMessage(rawMessage, ['support@inbound.example']);
+
+  assert.equal(parsed.sender, 'alice@example.net');
+  assert.deepEqual(parsed.recipients, ['support@inbound.example']);
+  assert.equal(parsed.subject, '收信测试');
+  assert.equal(parsed.messageId, '<mime-test@example.net>');
+  assert.equal(parsed.textBody, 'Hello plain body.');
+  assert.match(parsed.htmlBody, /<strong>HTML<\/strong>/);
+  assert.equal(parsed.preview, 'Hello plain body.');
+});

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

@@ -146,6 +146,66 @@ test('users can manage multiple smtp login credentials', async () => {
   }
 });
 
+test('users can manage inbound mailboxes and read inbound messages', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    await createSendingDomain(baseUrl, cookie, { domain: 'inbound-api.example' });
+
+    const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        address: 'Support@inbound-api.example',
+        displayName: 'Support'
+      })
+    });
+    assert.equal(createMailbox.status, 201);
+    const mailbox = (await createMailbox.json()).mailbox;
+    assert.equal(mailbox.address, 'support@inbound-api.example');
+    assert.equal(mailbox.displayName, 'Support');
+    assert.equal(mailbox.unreadCount, 0);
+
+    const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } });
+    assert.equal(mailboxes.status, 200);
+    const mailboxesBody = await mailboxes.json();
+    assert.deepEqual(mailboxesBody.mailboxes.map((entry) => entry.address), ['support@inbound-api.example']);
+
+    const messageId = seedInboundMessage(dataDir, sessionSecret, 'support@inbound-api.example');
+    const messages = await fetch(`${baseUrl}/api/inbound-messages?mailboxId=${mailbox.id}`, { headers: { Cookie: cookie } });
+    assert.equal(messages.status, 200);
+    const messagesBody = await messages.json();
+    assert.equal(messagesBody.messages.length, 1);
+    assert.equal(messagesBody.messages[0].id, messageId);
+    assert.equal(messagesBody.messages[0].subject, 'Inbound API message');
+    assert.equal(messagesBody.messages[0].textBody, undefined);
+
+    const detail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
+    assert.equal(detail.status, 200);
+    const detailBody = await detail.json();
+    assert.equal(detailBody.message.textBody, 'Hello from inbound API.');
+    assert.equal(detailBody.message.rawMessage.includes('Inbound API message'), true);
+
+    const markRead = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ read: true })
+    });
+    assert.equal(markRead.status, 200);
+    assert.equal((await markRead.json()).message.read, true);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
 test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
   const relayServer = await startFakeSmtpServer();
   const { child, baseUrl } = await startTestServer();
@@ -302,6 +362,19 @@ test('smtp relay selection prefers request relay then domain relay then user def
 
   try {
     const cookie = await login(baseUrl, 'admin', 'password123');
+    const settings = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        appBaseUrl: baseUrl,
+        engagementTrackingEnabled: true
+      })
+    });
+    assert.equal(settings.status, 200);
+
     const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
       name: 'Default relay',
       host: '127.0.0.1',
@@ -359,12 +432,20 @@ test('smtp relay selection prefers request relay then domain relay then user def
       body: JSON.stringify({
         to: 'test-send@example.com',
         subject: 'Selected relay test send',
+        text: 'Open the HTML version to verify tracking.',
+        html: '<html><body><p>MailHub tracking test.</p><a href="https://example.net/tracked">Tracked link</a></body></html>',
         smtpRelayId: requestRelay.id
       })
     });
     assert.equal(testSend.status, 202);
-    assert.equal((await testSend.json()).smtpRelayId, requestRelay.id);
+    const testSendBody = await testSend.json();
+    assert.equal(testSendBody.smtpRelayId, requestRelay.id);
+    assert.deepEqual(testSendBody.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
     await waitForCondition(() => requestRelayServer.messages.length === 2);
+    const testHtml = decodeHtmlPart(requestRelayServer.messages[1]);
+    assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
+    assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
+    assert.equal(testHtml.includes('https://example.net/tracked'), false);
 
     const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
       method: 'POST',
@@ -1366,6 +1447,46 @@ function seedUsers(dataDir, sessionSecret, users) {
   assert.equal(result.status, 0, result.stderr || result.stdout);
 }
 
+function seedInboundMessage(dataDir, sessionSecret, address) {
+  const script = `
+    import {
+      createInboundMessage,
+      getInboundMailboxByAddress,
+      initDatabase
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const mailbox = getInboundMailboxByAddress(process.env.INBOUND_ADDRESS);
+    const message = createInboundMessage(mailbox, {
+      sender: 'alice@example.net',
+      recipients: [process.env.INBOUND_ADDRESS],
+      subject: 'Inbound API message',
+      messageId: '<inbound-api@example.net>',
+      rawMessage: [
+        'From: Alice <alice@example.net>',
+        'To: Support <' + process.env.INBOUND_ADDRESS + '>',
+        'Subject: Inbound API message',
+        '',
+        'Hello from inbound API.'
+      ].join('\\r\\n'),
+      textBody: 'Hello from inbound API.'
+    });
+    console.log(String(message.id));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      INBOUND_ADDRESS: address
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return Number(result.stdout.trim());
+}
+
 function seedTransferResources(dataDir, sessionSecret) {
   const script = `
     import {
@@ -1720,6 +1841,16 @@ function assertRelayAuth(relayServer, username, password) {
   assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
 }
 
+function decodeHtmlPart(rawMessage) {
+  const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
+  assert.ok(match, 'expected an HTML MIME part');
+  return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
+}
+
+function escapeRegExp(value) {
+  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
   const commands = [];
   const messages = [];

+ 308 - 0
test/submission-inbound.test.js

@@ -0,0 +1,308 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createInboundMailbox,
+  createUser,
+  initDatabase,
+  listInboundMessages
+} from '../src/db.js';
+import { sendViaSmtp } from '../src/mailer.js';
+import { startSubmissionServer } from '../src/submission.js';
+
+test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-')), 'inbound-secret');
+  const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'inbound.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.inbound.example',
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.inbound.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    relayHost: '',
+    relayPort: 25,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mx.inbound.example'
+  });
+  await waitForListening(server);
+
+  try {
+    const rawMessage = [
+      'From: Alice <alice@example.net>',
+      'To: Support <support@inbound.example>',
+      'Subject: Hello inbound SMTP',
+      'Message-ID: <hello-inbound@example.net>',
+      'Content-Type: text/plain; charset=UTF-8',
+      '',
+      'Hello through SMTP.',
+      ''
+    ].join('\r\n');
+    const response = await sendViaSmtp({
+      host: '127.0.0.1',
+      port: server.address().port,
+      secure: false,
+      username: '',
+      password: '',
+      helo: 'sender.example.net',
+      mailFrom: 'alice@example.net',
+      recipients: ['support@inbound.example'],
+      rawMessage
+    });
+    assert.match(response.message, /Message accepted/i);
+
+    const [message] = listInboundMessages(user.id);
+    assert.equal(message.sender, 'alice@example.net');
+    assert.deepEqual(message.recipients, ['support@inbound.example']);
+    assert.equal(message.subject, 'Hello inbound SMTP');
+    assert.equal(message.preview, 'Hello through SMTP.');
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.inbound.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  try {
+    const transcript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      'MAIL FROM:<alice@example.net>',
+      'RCPT TO:<nobody@external.example>'
+    ]);
+    assert.match(transcript.at(-1), /^550 /);
+    assert.equal(listInboundMessages(1).length, 0);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP stores each inbound recipient without exposing other envelope recipients', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-multi-')), 'inbound-secret');
+  const supportUser = createUser({ username: 'support-user', email: 'support@example.com', password: 'password123' });
+  const privateUser = createUser({ username: 'private-user', email: 'private@example.com', password: 'password123' });
+  createDomain(supportUser.id, {
+    domain: 'support.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.support.example',
+    sendingIp: '192.0.2.12',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createDomain(privateUser.id, {
+    domain: 'private.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.private.example',
+    sendingIp: '192.0.2.13',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(supportUser.id, { address: 'support@support.example' });
+  createInboundMailbox(privateUser.id, { address: 'private@private.example' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.inbound.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  try {
+    const rawMessage = [
+      'From: Alice <alice@example.net>',
+      'To: Support <support@support.example>',
+      'Subject: Multi recipient',
+      '',
+      'Hello both.',
+      ''
+    ].join('\r\n');
+    await sendViaSmtp({
+      host: '127.0.0.1',
+      port: server.address().port,
+      secure: false,
+      username: '',
+      password: '',
+      helo: 'sender.example.net',
+      mailFrom: 'alice@example.net',
+      recipients: ['support@support.example', 'private@private.example'],
+      rawMessage
+    });
+
+    assert.deepEqual(listInboundMessages(supportUser.id)[0].recipients, ['support@support.example']);
+    assert.deepEqual(listInboundMessages(privateUser.id)[0].recipients, ['private@private.example']);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP accepts unauthenticated inbound bounces with an empty envelope sender', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-bounce-')), 'inbound-secret');
+  const user = createUser({ username: 'bounce-user', email: 'bounce@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'bounce.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.bounce.example',
+    sendingIp: '192.0.2.14',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, { address: 'postmaster@bounce.example' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.bounce.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  try {
+    await sendViaSmtp({
+      host: '127.0.0.1',
+      port: server.address().port,
+      secure: false,
+      username: '',
+      password: '',
+      helo: 'sender.example.net',
+      mailFrom: '',
+      recipients: ['postmaster@bounce.example'],
+      rawMessage: 'From: MAILER-DAEMON <>\r\nSubject: Delivery status\r\n\r\nBounced.'
+    });
+
+    const [message] = listInboundMessages(user.id);
+    assert.equal(message.sender, '');
+    assert.deepEqual(message.recipients, ['postmaster@bounce.example']);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP rejects oversized unauthenticated inbound messages without storing them', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-size-')), 'inbound-secret');
+  const user = createUser({ username: 'size-user', email: 'size@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'size.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.size.example',
+    sendingIp: '192.0.2.15',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, { address: 'support@size.example' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.size.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    maxMessageBytes: 64
+  });
+  await waitForListening(server);
+
+  try {
+    const transcript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      'MAIL FROM:<alice@example.net>',
+      'RCPT TO:<support@size.example>',
+      'DATA',
+      [
+        'Subject: Oversized inbound',
+        '',
+        'This body is intentionally longer than the configured inbound message size limit.',
+        '.'
+      ].join('\r\n')
+    ]);
+    assert.match(transcript.at(-1), /^552 /);
+    assert.equal(listInboundMessages(user.id).length, 0);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+function waitForListening(server) {
+  if (server.listening) return Promise.resolve();
+  return new Promise((resolve) => server.once('listening', resolve));
+}
+
+function closeServer(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}
+
+async function smtpTranscript(port, commands) {
+  return await new Promise((resolve, reject) => {
+    const socket = net.createConnection({ host: '127.0.0.1', port });
+    socket.setEncoding('utf8');
+    socket.setTimeout(3000);
+    const responses = [];
+    let buffer = '';
+    let index = -1;
+
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let lineEnd;
+      while ((lineEnd = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
+        buffer = buffer.slice(lineEnd + 1);
+        if (!/^\d{3}[ -]/.test(line)) continue;
+        responses.push(line);
+        if (/^\d{3} /.test(line)) {
+          index += 1;
+          if (index >= commands.length) {
+            socket.end('QUIT\r\n');
+            resolve(responses);
+            return;
+          }
+          socket.write(`${commands[index]}\r\n`);
+        }
+      }
+    });
+    socket.once('error', reject);
+    socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
+  });
+}

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov