Przeglądaj źródła

feat: add mailbox access protocols

AI-Co-Authored-By: Codex
chendeben 1 miesiąc temu
rodzic
commit
3d5aa7517f

+ 8 - 0
.env.example

@@ -18,6 +18,14 @@ SUBMISSION_TLS_KEY=/certs/mailhub.example.com.key
 SUBMISSION_USERNAME=change-this-smtp-user
 SUBMISSION_PASSWORD=change-this-smtp-password
 
+IMAP_ENABLED=true
+IMAP_BIND=0.0.0.0
+IMAP_PORTS=143:imap,993:imaps
+POP3_ENABLED=true
+POP3_BIND=0.0.0.0
+POP3_PORTS=110:pop3,995:pop3s
+MAIL_ACCESS_ALLOW_INSECURE_AUTH=false
+
 # Default outbound identity used in SPF, HELO, and Postfix myhostname.
 MAIL_HOSTNAME=smtp.mailhub.example.com
 SENDING_IP=203.0.113.10

+ 4 - 0
Dockerfile

@@ -16,6 +16,10 @@ EXPOSE 25
 EXPOSE 465
 EXPOSE 587
 EXPOSE 2525
+EXPOSE 110
+EXPOSE 143
+EXPOSE 993
+EXPOSE 995
 VOLUME ["/data"]
 
 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \

+ 4 - 0
docker-compose.yml

@@ -16,6 +16,10 @@ services:
       - "${SUBMISSION_BIND:-0.0.0.0}:465:465"
       - "${SUBMISSION_BIND:-0.0.0.0}:587:587"
       - "${SUBMISSION_BIND:-0.0.0.0}:${SUBMISSION_ALT_PORT:-2525}:2525"
+      - "${IMAP_BIND:-0.0.0.0}:143:143"
+      - "${IMAP_BIND:-0.0.0.0}:993:993"
+      - "${POP3_BIND:-0.0.0.0}:110:110"
+      - "${POP3_BIND:-0.0.0.0}:995:995"
     volumes:
       - ./data:/data
       - ./certs:/certs:ro

Plik diff jest za duży
+ 0 - 1
public/assets/index-zLWtIAEO.js


Plik diff jest za duży
+ 0 - 0
public/assets/login-CT-R_g49.js


Plik diff jest za duży
+ 0 - 0
public/assets/styles-C2bA_lrB.css


Plik diff jest za duży
+ 0 - 0
public/assets/styles-DoFV9R_h.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-DIIQ1K-Z.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
+    <script type="module" crossorigin src="/assets/index-zLWtIAEO.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-DoFV9R_h.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-C2bA_lrB.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-BesHXcx1.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
+    <script type="module" crossorigin src="/assets/login-CT-R_g49.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-DoFV9R_h.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-C2bA_lrB.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 305 - 21
src/db.js

@@ -61,6 +61,7 @@ export function initDatabase(dataDir, secret = '') {
       spf_extra TEXT NOT NULL DEFAULT '',
       dmarc_policy TEXT NOT NULL DEFAULT 'none',
       dmarc_rua TEXT NOT NULL DEFAULT '',
+      catch_all_address TEXT NOT NULL DEFAULT '',
       status_json TEXT NOT NULL DEFAULT '{}',
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL
@@ -157,6 +158,12 @@ export function initDatabase(dataDir, secret = '') {
       address TEXT NOT NULL UNIQUE,
       local_part TEXT NOT NULL,
       display_name TEXT NOT NULL DEFAULT '',
+      password_hash TEXT NOT NULL DEFAULT '',
+      password_secret TEXT NOT NULL DEFAULT '',
+      aliases_json TEXT NOT NULL DEFAULT '[]',
+      forward_to_json TEXT NOT NULL DEFAULT '[]',
+      keep_forwarded TEXT NOT NULL DEFAULT 'true',
+      quota_mb INTEGER,
       status TEXT NOT NULL DEFAULT 'active',
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL,
@@ -288,6 +295,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
   ensureColumn('domains', 'smtp_relay_id', 'INTEGER');
+  ensureColumn('domains', 'catch_all_address', "TEXT NOT NULL DEFAULT ''");
   ensureColumn('send_events', 'user_id', 'INTEGER');
   ensureColumn('send_events', 'smtp_relay_id', 'INTEGER');
   ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
@@ -299,6 +307,12 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('send_events', 'tracking_clicks', "TEXT NOT NULL DEFAULT 'false'");
   migrateSmtpCredentialsToMultiplePerUser();
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_mailboxes', 'password_hash', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_mailboxes', 'password_secret', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_mailboxes', 'aliases_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('inbound_mailboxes', 'forward_to_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('inbound_mailboxes', 'keep_forwarded', "TEXT NOT NULL DEFAULT 'true'");
+  ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
     CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
@@ -837,12 +851,20 @@ export function createInboundMailbox(userId, mailbox = {}) {
   const [localPart, domainName] = address.split('@');
   const domain = getDomainByName(domainName, { userId });
   if (!domain) throw new Error('收信域名不存在。');
+  const password = String(mailbox.password || '');
+  const passwordHash = password ? hashPassword(password) : '';
+  const passwordSecret = password ? encryptSecret(password) : '';
+  const aliases = normalizeMailboxAliases(mailbox.aliases, domain.domain, localPart);
+  const forwardTo = normalizeRecipientList(mailbox.forwardTo);
+  const keepForwarded = boolString(mailbox.keepForwarded ?? true);
+  const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
   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', ?, ?)
+        user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
+        aliases_json, forward_to_json, keep_forwarded, quota_mb, status, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
     `)
     .run(
       userId,
@@ -850,12 +872,59 @@ export function createInboundMailbox(userId, mailbox = {}) {
       address,
       localPart,
       String(mailbox.displayName || '').trim(),
+      passwordHash,
+      passwordSecret,
+      JSON.stringify(aliases),
+      JSON.stringify(forwardTo),
+      keepForwarded,
+      quotaMb,
       createdAt,
       createdAt
     );
   return getInboundMailbox(result.lastInsertRowid, userId);
 }
 
+export function updateInboundMailbox(userId, id, patch = {}) {
+  const current = getInboundMailbox(id, userId, { includeSecret: true });
+  if (!current) return null;
+  const password = Object.hasOwn(patch, 'password') ? String(patch.password || '') : null;
+  const next = {
+    displayName: patch.displayName === undefined ? current.displayName : String(patch.displayName || '').trim(),
+    passwordHash: password ? hashPassword(password) : current.passwordHash,
+    passwordSecret: password ? encryptSecret(password) : current.passwordSecret,
+    aliases: Object.hasOwn(patch, 'aliases')
+      ? normalizeMailboxAliases(patch.aliases, current.domain, current.localPart)
+      : current.aliases,
+    forwardTo: Object.hasOwn(patch, 'forwardTo') ? normalizeRecipientList(patch.forwardTo) : current.forwardTo,
+    keepForwarded: Object.hasOwn(patch, 'keepForwarded') ? Boolean(patch.keepForwarded) : current.keepForwarded,
+    quotaMb: Object.hasOwn(patch, 'quotaMb') ? normalizeQuotaMb(patch.quotaMb) : current.quotaMb,
+    status: patch.status === undefined ? current.status : normalizeInboundMailboxStatus(patch.status),
+    updatedAt: now()
+  };
+  if (!next.passwordHash) next.passwordSecret = '';
+  requireDb()
+    .prepare(`
+      UPDATE inbound_mailboxes
+      SET display_name = ?, password_hash = ?, password_secret = ?, aliases_json = ?, forward_to_json = ?,
+          keep_forwarded = ?, quota_mb = ?, status = ?, updated_at = ?
+      WHERE id = ? AND user_id = ? AND deleted_at IS NULL
+    `)
+    .run(
+      next.displayName,
+      next.passwordHash || '',
+      next.passwordSecret || '',
+      JSON.stringify(next.aliases),
+      JSON.stringify(next.forwardTo),
+      boolString(next.keepForwarded),
+      next.quotaMb,
+      next.status,
+      next.updatedAt,
+      Number(id),
+      userId
+    );
+  return getInboundMailbox(id, userId);
+}
+
 export function listInboundMailboxes(userId) {
   return requireDb()
     .prepare(`
@@ -876,7 +945,7 @@ export function listInboundMailboxes(userId) {
     .map(publicInboundMailbox);
 }
 
-export function getInboundMailbox(id, userId) {
+export function getInboundMailbox(id, userId, { includeHash = false, includeSecret = false } = {}) {
   const row = requireDb()
     .prepare(`
       SELECT
@@ -892,10 +961,10 @@ export function getInboundMailbox(id, userId) {
       GROUP BY m.id
     `)
     .get(Number(id), userId);
-  return publicInboundMailbox(row);
+  return publicInboundMailbox(row, { includeHash, includeSecret });
 }
 
-export function getInboundMailboxByAddress(address) {
+export function getInboundMailboxByAddress(address, { includeHash = false, includeSecret = false } = {}) {
   const cleanAddress = normalizeInboundAddress(address);
   if (!cleanAddress) return null;
   const row = requireDb()
@@ -911,7 +980,90 @@ export function getInboundMailboxByAddress(address) {
       LIMIT 1
     `)
     .get(cleanAddress);
-  return publicInboundMailbox(row);
+  return publicInboundMailbox(row, { includeHash, includeSecret });
+}
+
+export function verifyInboundMailboxCredential(username, password) {
+  const mailboxAddress = normalizeInboundAddress(username);
+  if (!mailboxAddress) return null;
+  const row = requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        0 AS message_count,
+        0 AS unread_count,
+        NULL AS last_message_at,
+        u.id AS auth_user_id,
+        u.username AS auth_username,
+        u.email,
+        u.role,
+        u.status AS user_status
+      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
+      LIMIT 1
+    `)
+    .get(mailboxAddress);
+  if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
+  return {
+    user: {
+      id: row.auth_user_id,
+      username: row.auth_username,
+      email: row.email,
+      role: row.role,
+      status: row.user_status
+    },
+    mailbox: publicInboundMailbox(row)
+  };
+}
+
+export function resolveInboundRecipient(address) {
+  const recipient = normalizeInboundAddress(address);
+  if (!recipient) return null;
+  const exactMailbox = getInboundMailboxByAddress(recipient);
+  if (exactMailbox) return inboundRouteForMailbox(recipient, exactMailbox);
+
+  const aliasMailbox = getInboundMailboxByAliasAddress(recipient);
+  if (aliasMailbox) return inboundRouteForMailbox(recipient, aliasMailbox, { alias: true });
+
+  const [, domainName] = recipient.split('@');
+  const domain = getDomainByName(domainName);
+  const catchAllAddress = normalizeCatchAllAddress(domain?.catchAllAddress);
+  if (!domain || !catchAllAddress) return null;
+  if (catchAllAddress === '/dev/null') {
+    return {
+      recipient,
+      domainId: domain.id,
+      userId: domain.userId,
+      mailbox: null,
+      forwardTo: [],
+      keepForwarded: false,
+      drop: true,
+      catchAll: true,
+      alias: false
+    };
+  }
+
+  const catchAllMailbox = getInboundMailboxByAddress(catchAllAddress);
+  if (catchAllMailbox) return inboundRouteForMailbox(recipient, catchAllMailbox, { catchAll: true });
+
+  const forwardTo = normalizeRecipientList(catchAllAddress);
+  if (!forwardTo.length) return null;
+  return {
+    recipient,
+    domainId: domain.id,
+    userId: domain.userId,
+    mailbox: null,
+    forwardTo,
+    keepForwarded: false,
+    drop: false,
+    catchAll: true,
+    alias: false
+  };
 }
 
 export function createInboundMessage(mailbox, message = {}) {
@@ -979,6 +1131,21 @@ export function getInboundMessage(userId, id) {
   return publicInboundMessage(row, { includeBody: true });
 }
 
+export function listInboundMailboxProtocolMessages(mailbox) {
+  if (!mailbox?.id || !mailbox?.userId) return [];
+  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 msg.mailbox_id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+      ORDER BY msg.id ASC
+    `)
+    .all(Number(mailbox.id), mailbox.userId)
+    .map((row) => publicInboundMessage(row, { includeBody: true }));
+}
+
 export function markInboundMessageRead(userId, id, read = true) {
   const updatedAt = now();
   const result = requireDb()
@@ -988,6 +1155,23 @@ export function markInboundMessageRead(userId, id, read = true) {
   return getInboundMessage(userId, id);
 }
 
+export function softDeleteInboundMessages(userId, mailboxId, ids) {
+  const cleanIds = [...new Set((Array.isArray(ids) ? ids : [ids])
+    .map((id) => Number(id))
+    .filter((id) => Number.isInteger(id) && id > 0))];
+  if (!cleanIds.length) return 0;
+  const placeholders = cleanIds.map(() => '?').join(', ');
+  const updatedAt = now();
+  const result = requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET deleted_at = ?, updated_at = ?
+      WHERE user_id = ? AND mailbox_id = ? AND deleted_at IS NULL AND id IN (${placeholders})
+    `)
+    .run(updatedAt, updatedAt, userId, Number(mailboxId), ...cleanIds);
+  return result.changes;
+}
+
 export function updateDomain(id, userId, patch) {
   const current = getDomain(id, { userId, includePrivate: true });
   if (!current) return null;
@@ -1000,13 +1184,16 @@ export function updateDomain(id, userId, patch) {
     spfExtra: patch.spfExtra ?? current.spfExtra,
     dmarcPolicy: patch.dmarcPolicy ?? current.dmarcPolicy,
     dmarcRua: patch.dmarcRua ?? current.dmarcRua,
+    catchAllAddress: patch.catchAllAddress === undefined
+      ? current.catchAllAddress
+      : normalizeCatchAllAddress(patch.catchAllAddress),
     updatedAt: now()
   };
   requireDb()
     .prepare(`
       UPDATE domains
       SET selector = ?, dns_credential_id = ?, smtp_relay_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
-          dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
+          dmarc_policy = ?, dmarc_rua = ?, catch_all_address = ?, updated_at = ?
       WHERE id = ? AND user_id = ?
     `)
     .run(
@@ -1018,6 +1205,7 @@ export function updateDomain(id, userId, patch) {
       next.spfExtra,
       next.dmarcPolicy,
       next.dmarcRua,
+      next.catchAllAddress,
       next.updatedAt,
       id,
       userId
@@ -2496,6 +2684,7 @@ function clearDefaultSmtpRelay(userId) {
 }
 
 export function verifySmtpCredential(username, password) {
+  const cleanUsername = String(username || '').trim();
   const row = requireDb()
     .prepare(`
       SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
@@ -2503,18 +2692,29 @@ export function verifySmtpCredential(username, password) {
       JOIN users u ON u.id = c.user_id
       WHERE c.username = ?
     `)
-    .get(String(username || '').trim());
-  if (!row || row.status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
-  return {
-    user: {
-      id: row.auth_user_id,
-      username: row.auth_username,
-      email: row.email,
-      role: row.role,
-      status: row.status
-    },
-    credential: publicSmtpCredential(row)
-  };
+    .get(cleanUsername);
+  if (row && row.status === 'active' && verifyPassword(password, row.password_hash)) {
+    return {
+      user: {
+        id: row.auth_user_id,
+        username: row.auth_username,
+        email: row.email,
+        role: row.role,
+        status: row.status
+      },
+      credential: publicSmtpCredential(row)
+    };
+  }
+
+  const mailboxAuth = verifyInboundMailboxCredential(cleanUsername, password);
+  return mailboxAuth ? {
+    user: mailboxAuth.user,
+    mailbox: mailboxAuth.mailbox,
+    credential: {
+      username: mailboxAuth.mailbox.address,
+      type: 'inbound_mailbox'
+    }
+  } : null;
 }
 
 export function createApiToken(userId, name) {
@@ -3024,6 +3224,7 @@ function publicDomainRow(row) {
     spfExtra: row.spf_extra,
     dmarcPolicy: row.dmarc_policy,
     dmarcRua: row.dmarc_rua,
+    catchAllAddress: row.catch_all_address || '',
     status: safeJson(row.status_json, {}),
     createdAt: row.created_at,
     updatedAt: row.updated_at
@@ -3035,8 +3236,9 @@ function privateDomainRow(row) {
   return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
 }
 
-function publicInboundMailbox(row) {
+function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
   if (!row) return null;
+  const passwordRecoverable = Boolean(row.password_secret && decryptSecret(row.password_secret));
   return {
     id: row.id,
     userId: row.user_id,
@@ -3045,10 +3247,18 @@ function publicInboundMailbox(row) {
     address: row.address,
     localPart: row.local_part,
     displayName: row.display_name,
+    aliases: safeJson(row.aliases_json, []),
+    forwardTo: safeJson(row.forward_to_json, []),
+    keepForwarded: row.keep_forwarded !== 'false',
+    quotaMb: row.quota_mb === null || row.quota_mb === undefined ? null : Number(row.quota_mb),
+    passwordSet: Boolean(row.password_hash),
+    passwordRecoverable,
     status: row.status,
     messageCount: Number(row.message_count || 0),
     unreadCount: Number(row.unread_count || 0),
     lastMessageAt: row.last_message_at || null,
+    ...(includeHash ? { passwordHash: row.password_hash } : {}),
+    ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
     createdAt: row.created_at,
     updatedAt: row.updated_at
   };
@@ -3467,9 +3677,83 @@ function normalizeInboundAddress(value) {
   return `${localPart}@${domain}`;
 }
 
+function normalizeCatchAllAddress(value) {
+  const clean = String(value || '').trim().toLowerCase();
+  if (!clean) return '';
+  if (clean === '/dev/null') return clean;
+  return normalizeInboundAddress(clean);
+}
+
+function normalizeInboundMailboxStatus(value) {
+  const clean = String(value || '').trim().toLowerCase();
+  if (['active', 'disabled'].includes(clean)) return clean;
+  throw new Error('收信邮箱状态不正确。');
+}
+
+function normalizeMailboxAliases(values, domain, ownLocalPart) {
+  const list = Array.isArray(values)
+    ? values
+    : String(values || '').split(/[\s,;]+/);
+  const cleanDomain = String(domain || '').toLowerCase();
+  const own = String(ownLocalPart || '').toLowerCase();
+  const aliases = [];
+  for (const value of list) {
+    const raw = String(value || '').trim().toLowerCase();
+    if (!raw) continue;
+    const localPart = raw.includes('@')
+      ? (normalizeInboundAddress(raw).endsWith(`@${cleanDomain}`) ? normalizeInboundAddress(raw).split('@')[0] : '')
+      : raw;
+    if (!localPart || localPart === own || !/^[^@\s]+$/.test(localPart)) continue;
+    if (!aliases.includes(localPart)) aliases.push(localPart);
+  }
+  return aliases;
+}
+
+function normalizeQuotaMb(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const quota = Number(value);
+  if (!Number.isFinite(quota) || quota < 0) return null;
+  return Math.floor(quota);
+}
+
 function normalizeRecipientList(values) {
   const list = Array.isArray(values) ? values : [values];
-  return [...new Set(list.map(normalizeEmail).filter(Boolean))];
+  return [...new Set(list.flatMap((value) => String(value || '').split(/[\s,;]+/)).map(normalizeEmail).filter(Boolean))];
+}
+
+function getInboundMailboxByAliasAddress(address) {
+  const cleanAddress = normalizeInboundAddress(address);
+  if (!cleanAddress) return null;
+  const [localPart, domainName] = cleanAddress.split('@');
+  const rows = 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 d.domain = ?
+        AND m.status = 'active'
+        AND m.deleted_at IS NULL
+        AND u.status = 'active'
+    `)
+    .all(domainName);
+  const row = rows.find((item) => safeJson(item.aliases_json, []).includes(localPart));
+  return publicInboundMailbox(row);
+}
+
+function inboundRouteForMailbox(recipient, mailbox, meta = {}) {
+  return {
+    recipient,
+    domainId: mailbox.domainId,
+    userId: mailbox.userId,
+    mailbox,
+    forwardTo: normalizeRecipientList(mailbox.forwardTo)
+      .filter((target) => target !== mailbox.address && target !== recipient),
+    keepForwarded: mailbox.keepForwarded,
+    drop: false,
+    catchAll: Boolean(meta.catchAll),
+    alias: Boolean(meta.alias)
+  };
 }
 
 function inboundPreview(value) {

+ 12 - 2
src/frontend/App.tsx

@@ -30,6 +30,7 @@ import type {
   DomainPatchPayload,
   InboundMailbox,
   InboundMessage,
+  MailboxClientConfig,
   RuntimeConfig,
   SmtpCredential,
   SmtpRelay,
@@ -398,14 +399,22 @@ function MailHubConsole() {
     }));
   }
 
-  async function createInboundMailbox(values: { address: string; displayName?: string }) {
+  async function createInboundMailbox(values: {
+    address: string;
+    displayName?: string;
+    password: string;
+    aliases?: string;
+    forwardTo?: string;
+    keepForwarded?: boolean;
+    quotaMb?: number | string | null;
+  }): Promise<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig } | null> {
     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;
+    return result;
   }
 
   async function loadInboundMessages(mailboxId?: number | null) {
@@ -631,6 +640,7 @@ function MailHubConsole() {
           messages={data.inboundMessages}
           loading={actionLoading}
           onCreateMailbox={createInboundMailbox}
+          onPatchDomain={patchDomain}
           onLoadMessages={loadInboundMessages}
           onLoadMessage={loadInboundMessage}
           onCopy={copy}

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

@@ -5,12 +5,14 @@ const messages = {
   'zh-CN': {
     'common.account': '账号',
     'common.addDomain': '添加域名',
+    'common.actions': '操作',
     'common.cancel': '取消',
     'common.confirm': '确认',
     'common.copy': '复制',
     'common.copied': '已复制',
     'common.delete': '删除',
     'common.details': '详情',
+    'common.edit': '修改',
     'common.error': '操作失败',
     'common.loadingConfig': '加载运行配置中',
     'common.logout': '退出登录',
@@ -237,8 +239,46 @@ const messages = {
     'inbox.subtitle': '管理本地收信邮箱,查看通过 MailHub SMTP 接收的邮件。',
     'inbox.createMailbox': '新增收信邮箱',
     'inbox.inboundDisabled': '当前运行配置已关闭入站收信。请设置 INBOUND_ENABLED=true 后重启服务。',
+    'inbox.clientHelpTitle': '客户端配置帮助',
+    'inbox.clientHelpIntro': '创建邮箱后,点击“客户端配置”可复制第三方客户端所需的收信/发信参数。',
+    'inbox.clientHelpImap': '推荐优先使用 IMAP;它会和 MailHub 收件箱保持同步。',
+    'inbox.clientHelpPop3': 'POP3 适合把邮件下载到本地;客户端删除邮件后,MailHub 中对应邮件会在退出时移除。',
+    'inbox.clientHelpAuth': '用户名使用完整邮箱地址,密码使用创建或重置邮箱时设置的密码。',
+    'inbox.clientHelpSecurity': '优先使用 SSL/TLS 端口 993/995/465,或在 143/110/587 上启用 STARTTLS/STLS。',
+    'inbox.clientHelpPorts': '服务器防火墙和云安全组需放行 IMAP 143/993、POP3 110/995、SMTP 587/465。',
     'inbox.mailboxes': '收信邮箱',
+    'inbox.domainRoutes': '域名收信路由',
+    'inbox.mailboxUnit': '邮箱',
+    'inbox.catchAllAddress': '收取未知邮件的邮箱',
+    'inbox.catchAllDisabled': '未启用',
+    'inbox.catchAllTitle': '收取未知邮件',
+    'inbox.catchAllExtra': '留空表示拒收未知地址;填写邮箱地址表示投递/转发;填写 /dev/null 表示接收后丢弃。',
     'inbox.mailboxAddress': '邮箱地址',
+    'inbox.password': '密码',
+    'inbox.passwordRequired': '请输入邮箱密码',
+    'inbox.passwordMin': '邮箱密码至少需要 8 位',
+    'inbox.generatePassword': '生成',
+    'inbox.quotaMb': '配额',
+    'inbox.unlimited': '无限制',
+    'inbox.aliases': '别名',
+    'inbox.aliasesExtra': '每行一个本地部分,例如 sales 会接收 sales@当前域名。',
+    'inbox.forwardTo': '转发到',
+    'inbox.forwardToExtra': '每行一个或多个邮箱地址,也可用逗号、空格分隔。',
+    'inbox.keepForwarded': '保留已转发的邮件',
+    'inbox.forwardOnly': '仅转发',
+    'inbox.clientConfig': '客户端配置',
+    'inbox.clientConfigHelpSummary': '推荐优先配置 IMAP 收信;POP3 仅在需要下载到本地时使用。密码只在创建或重置时显示。',
+    'inbox.configUsername': '用户名',
+    'inbox.configPassword': '密码',
+    'inbox.passwordNotShown': '仅创建或重置时显示',
+    'inbox.incomingConfig': 'IMAP 配置',
+    'inbox.pop3Config': 'POP3 配置',
+    'inbox.outgoingConfig': '发信配置',
+    'inbox.configProtocol': '协议',
+    'inbox.configHost': '主机',
+    'inbox.configPort': '端口',
+    'inbox.configSecurity': '安全',
+    'inbox.configAuthMethod': '认证方式',
     'inbox.unread': '未读',
     'inbox.messageCount': '邮件数',
     'inbox.lastMessageAt': '最近收信',
@@ -504,12 +544,14 @@ const messages = {
   'en-US': {
     'common.account': 'Account',
     'common.addDomain': 'Add Domain',
+    'common.actions': 'Actions',
     'common.cancel': 'Cancel',
     'common.confirm': 'Confirm',
     'common.copy': 'Copy',
     'common.copied': 'Copied',
     'common.delete': 'Delete',
     'common.details': 'Details',
+    'common.edit': 'Edit',
     'common.error': 'Operation failed',
     'common.loadingConfig': 'Loading runtime config',
     'common.logout': 'Log out',
@@ -750,8 +792,46 @@ const messages = {
     '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.clientHelpTitle': 'Client setup help',
+    'inbox.clientHelpIntro': 'After creating a mailbox, open Client configuration to copy the receiving and sending settings for third-party mail apps.',
+    'inbox.clientHelpImap': 'Use IMAP first when possible; it stays synchronized with the MailHub inbox.',
+    'inbox.clientHelpPop3': 'Use POP3 when you want to download mail locally; messages deleted by the client are removed from MailHub on quit.',
+    'inbox.clientHelpAuth': 'Use the full email address as the username and the password set when the mailbox was created or reset.',
+    'inbox.clientHelpSecurity': 'Prefer SSL/TLS ports 993/995/465, or enable STARTTLS/STLS on 143/110/587.',
+    'inbox.clientHelpPorts': 'Allow IMAP 143/993, POP3 110/995, and SMTP 587/465 in the server firewall and cloud security group.',
     'inbox.mailboxes': 'Mailboxes',
+    'inbox.domainRoutes': 'Domain receiving routes',
+    'inbox.mailboxUnit': 'mailboxes',
+    'inbox.catchAllAddress': 'Catch-all mailbox',
+    'inbox.catchAllDisabled': 'Disabled',
+    'inbox.catchAllTitle': 'Catch-all mailbox',
+    'inbox.catchAllExtra': 'Leave empty to reject unknown recipients. Use an email address to deliver/forward, or /dev/null to accept and discard.',
     'inbox.mailboxAddress': 'Mailbox address',
+    'inbox.password': 'Password',
+    'inbox.passwordRequired': 'Enter the mailbox password',
+    'inbox.passwordMin': 'Mailbox password must be at least 8 characters',
+    'inbox.generatePassword': 'Generate',
+    'inbox.quotaMb': 'Quota',
+    'inbox.unlimited': 'Unlimited',
+    'inbox.aliases': 'Aliases',
+    'inbox.aliasesExtra': 'One local part per line. For example, sales accepts sales@this domain.',
+    'inbox.forwardTo': 'Forward to',
+    'inbox.forwardToExtra': 'One or more email addresses, separated by line breaks, commas, or spaces.',
+    'inbox.keepForwarded': 'Keep forwarded mail',
+    'inbox.forwardOnly': 'Forward only',
+    'inbox.clientConfig': 'Client configuration',
+    'inbox.clientConfigHelpSummary': 'Configure IMAP first for receiving mail. Use POP3 only when you need local download behavior. Passwords are shown only on create or reset.',
+    'inbox.configUsername': 'Username',
+    'inbox.configPassword': 'Password',
+    'inbox.passwordNotShown': 'Shown only on create or reset',
+    'inbox.incomingConfig': 'IMAP configuration',
+    'inbox.pop3Config': 'POP3 configuration',
+    'inbox.outgoingConfig': 'Outgoing configuration',
+    'inbox.configProtocol': 'Protocol',
+    'inbox.configHost': 'Host',
+    'inbox.configPort': 'Port',
+    'inbox.configSecurity': 'Security',
+    'inbox.configAuthMethod': 'Auth method',
     'inbox.unread': 'Unread',
     'inbox.messageCount': 'Messages',
     'inbox.lastMessageAt': 'Last received',

+ 22 - 2
src/frontend/services/api.ts

@@ -10,6 +10,7 @@ import type {
   DomainPatchPayload,
   InboundMailbox,
   InboundMessage,
+  MailboxClientConfig,
   RuntimeConfig,
   SendEvent,
   SmtpCredential,
@@ -98,8 +99,27 @@ export const api = {
   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 }),
+  createInboundMailbox: (data: {
+    address: string;
+    displayName?: string;
+    password: string;
+    aliases?: string | string[];
+    forwardTo?: string | string[];
+    keepForwarded?: boolean;
+    quotaMb?: number | string | null;
+  }) => request<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig }>('/api/inbound-mailboxes', { method: 'POST', data }),
+  updateInboundMailbox: (id: number, data: Partial<{
+    displayName: string;
+    password: string;
+    aliases: string | string[];
+    forwardTo: string | string[];
+    keepForwarded: boolean;
+    quotaMb: number | string | null;
+    status: string;
+  }>) => request<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig }>(`/api/inbound-mailboxes/${id}`, {
+    method: 'PATCH',
+    data
+  }),
   inboundMessages: (mailboxId?: number | null) => {
     const query = mailboxId ? `?mailboxId=${mailboxId}` : '';
     return request<{ messages: InboundMessage[] }>(`/api/inbound-messages${query}`);

+ 29 - 0
src/frontend/styles.css

@@ -515,6 +515,31 @@ body {
   margin-bottom: 16px;
 }
 
+.inbox-help .ant-collapse-content-box {
+  padding: 12px 16px;
+}
+
+.inbox-help-intro {
+  margin-bottom: 0;
+}
+
+.inbox-help-list {
+  color: var(--mh-text);
+  line-height: 1.7;
+  margin: 0;
+  padding-left: 20px;
+}
+
+.inbox-help-list li + li {
+  margin-top: 4px;
+}
+
+.inbox-form-grid {
+  display: grid;
+  gap: 12px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
 .inbox-message-body {
   background: #f8fafc;
   border: 1px solid var(--mh-border);
@@ -865,6 +890,10 @@ body {
     width: 100%;
   }
 
+  .inbox-form-grid {
+    grid-template-columns: 1fr;
+  }
+
   .delivery-funnel-row {
     grid-template-columns: 1fr;
   }

+ 53 - 0
src/frontend/types.ts

@@ -67,6 +67,19 @@ export interface RuntimeConfig {
     tls: boolean;
     requireTlsForAuth: boolean;
   };
+  mailAccess?: {
+    host: string;
+    tls: boolean;
+    requireTlsForAuth: boolean;
+    imap: {
+      enabled: boolean;
+      ports: Array<{ port: number; protocol: string }>;
+    };
+    pop3: {
+      enabled: boolean;
+      ports: Array<{ port: number; protocol: string }>;
+    };
+  };
   apiTokenSet?: boolean;
   usingDefaultAdminPassword?: boolean;
 }
@@ -123,6 +136,7 @@ export interface Domain {
   spfExtra: string;
   dmarcPolicy: string;
   dmarcRua: string;
+  catchAllAddress: string;
   status: DomainStatus;
   createdAt: string;
   updatedAt: string;
@@ -278,6 +292,12 @@ export interface InboundMailbox {
   address: string;
   localPart: string;
   displayName: string;
+  aliases: string[];
+  forwardTo: string[];
+  keepForwarded: boolean;
+  quotaMb: number | null;
+  passwordSet: boolean;
+  passwordRecoverable: boolean;
   status: string;
   messageCount: number;
   unreadCount: number;
@@ -286,6 +306,38 @@ export interface InboundMailbox {
   updatedAt: string;
 }
 
+export interface MailboxClientConfig {
+  username: string;
+  password?: string;
+  incoming: {
+    protocol: string;
+    host: string;
+    port: number;
+    security: string;
+    authMethod: string;
+    username: string;
+    password?: string;
+  };
+  pop3?: {
+    protocol: string;
+    host: string;
+    port: number;
+    security: string;
+    authMethod: string;
+    username: string;
+    password?: string;
+  };
+  outgoing: {
+    protocol: string;
+    host: string;
+    port: number;
+    security: string;
+    authMethod: string;
+    username: string;
+    password?: string;
+  };
+}
+
 export interface InboundMessage {
   id: number;
   mailboxId: number;
@@ -551,6 +603,7 @@ export interface DomainPatchPayload {
   spfExtra?: string;
   dmarcPolicy?: string;
   dmarcRua?: string;
+  catchAllAddress?: string;
 }
 
 export type WebhookEvent = 'sent' | 'bounced' | 'failed' | 'opened' | 'clicked';

+ 785 - 0
src/mail-access.js

@@ -0,0 +1,785 @@
+import net from 'node:net';
+import tls from 'node:tls';
+import { readFileSync } from 'node:fs';
+import {
+  listInboundMailboxProtocolMessages,
+  markInboundMessageRead,
+  softDeleteInboundMessages,
+  verifyInboundMailboxCredential
+} from './db.js';
+
+export function startMailboxAccessServers(config) {
+  const tlsMaterial = loadTlsMaterial(config);
+  return [
+    ...startProtocolServers('imap', config.imapEnabled, config.imapListeners, config, tlsMaterial),
+    ...startProtocolServers('pop3', config.pop3Enabled, config.pop3Listeners, config, tlsMaterial)
+  ];
+}
+
+export function parseMailboxAccessListeners(value, fallback) {
+  return String(value || fallback || '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((item) => {
+      const [portRaw, protocolRaw = ''] = item.split(':');
+      const port = Number(portRaw);
+      const protocol = protocolRaw.toLowerCase();
+      if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
+      if (!['imap', 'imaps', 'pop3', 'pop3s'].includes(protocol)) return null;
+      return { port, protocol };
+    })
+    .filter(Boolean);
+}
+
+export function publicMailboxAccessListeners(listeners, { tls: tlsEnabled = false } = {}) {
+  return listeners.map((listener) => ({
+    port: listener.port,
+    protocol: publicProtocolLabel(listener.protocol, tlsEnabled)
+  }));
+}
+
+function startProtocolServers(kind, enabled, listeners = [], config, tlsMaterial) {
+  if (!enabled) return [];
+  const servers = [];
+  for (const listener of listeners.filter((item) => item.protocol.startsWith(kind))) {
+    const implicitTls = listener.protocol.endsWith('s');
+    if (implicitTls && !tlsMaterial) {
+      console.warn(`MailHub ${listener.protocol.toUpperCase()} listener on ${listener.port} skipped; TLS certificate is not configured.`);
+      continue;
+    }
+    const listenerConfig = {
+      ...config,
+      port: listener.port,
+      protocol: listener.protocol,
+      secureContext: tlsMaterial?.secureContext || null,
+      tlsActive: implicitTls,
+      startTlsAvailable: !implicitTls && Boolean(tlsMaterial?.secureContext)
+    };
+    const handler = (socket) => (
+      kind === 'imap'
+        ? new ImapSession(socket, listenerConfig)
+        : new Pop3Session(socket, listenerConfig)
+    );
+    const server = implicitTls
+      ? tls.createServer({ key: tlsMaterial.key, cert: tlsMaterial.cert }, handler)
+      : net.createServer(handler);
+    server.listen(listener.port, '0.0.0.0', () => {
+      console.log(`MailHub ${listener.protocol.toUpperCase()} listening on 0.0.0.0:${listener.port}`);
+    });
+    servers.push(server);
+  }
+  return servers;
+}
+
+class ImapSession {
+  constructor(socket, config) {
+    this.socket = socket;
+    this.config = config;
+    this.buffer = '';
+    this.authenticated = false;
+    this.user = null;
+    this.mailbox = null;
+    this.selected = false;
+    this.messages = [];
+    this.deletedUids = new Set();
+    this.authContinuation = null;
+    this.idleTag = '';
+    this.onDataBound = (chunk) => this.onData(chunk);
+    socket.setEncoding('utf8');
+    socket.on('data', this.onDataBound);
+    socket.on('error', () => null);
+    this.write(`* OK ${config.hostname} MailHub IMAP ready`);
+  }
+
+  onData(chunk) {
+    this.buffer += chunk;
+    let index;
+    while ((index = this.buffer.indexOf('\n')) !== -1) {
+      const line = this.buffer.slice(0, index).replace(/\r$/, '');
+      this.buffer = this.buffer.slice(index + 1);
+      this.onLine(line);
+    }
+  }
+
+  onLine(line) {
+    if (this.idleTag) {
+      if (line.toUpperCase() === 'DONE') {
+        const tag = this.idleTag;
+        this.idleTag = '';
+        this.write(`${tag} OK IDLE completed`);
+      }
+      return;
+    }
+
+    if (this.authContinuation) {
+      const continuation = this.authContinuation;
+      this.authContinuation = null;
+      return this.finishAuthenticatePlain(continuation.tag, line);
+    }
+
+    const parsed = line.match(/^(\S+)\s+(\S+)(?:\s+(.*))?$/);
+    if (!parsed) return this.write('* BAD Invalid command');
+    const [, tag, rawCommand, rest = ''] = parsed;
+    const command = rawCommand.toUpperCase();
+
+    if (command === 'CAPABILITY') return this.capability(tag);
+    if (command === 'NOOP') return this.write(`${tag} OK NOOP completed`);
+    if (command === 'LOGOUT') {
+      this.write('* BYE MailHub IMAP closing connection');
+      this.write(`${tag} OK LOGOUT completed`);
+      return this.socket.end();
+    }
+    if (command === 'STARTTLS') return this.startTls(tag);
+    if (command === 'LOGIN') return this.login(tag, rest);
+    if (command === 'AUTHENTICATE') return this.authenticate(tag, rest);
+    if (!this.authenticated) return this.write(`${tag} NO Authentication required`);
+    if (command === 'LIST' || command === 'LSUB') return this.list(tag);
+    if (command === 'NAMESPACE') return this.namespace(tag);
+    if (command === 'ID') return this.write(`${tag} OK ID completed`);
+    if (command === 'SELECT' || command === 'EXAMINE') return this.select(tag, rest, command === 'EXAMINE');
+    if (command === 'STATUS') return this.status(tag, rest);
+    if (command === 'SEARCH') return this.search(tag, rest, false);
+    if (command === 'UID') return this.uid(tag, rest);
+    if (!this.selected) return this.write(`${tag} NO Select INBOX first`);
+    if (command === 'FETCH') return this.fetch(tag, rest, false);
+    if (command === 'STORE') return this.store(tag, rest, false);
+    if (command === 'EXPUNGE') return this.expunge(tag);
+    if (command === 'CLOSE') return this.closeMailbox(tag);
+    if (command === 'IDLE') return this.idle(tag);
+    return this.write(`${tag} BAD Command not implemented`);
+  }
+
+  capability(tag) {
+    const capabilities = ['IMAP4rev1', 'UIDPLUS', 'IDLE', 'NAMESPACE'];
+    if (this.config.startTlsAvailable && !this.config.tlsActive) capabilities.push('STARTTLS');
+    if (this.canAuthenticate()) capabilities.push('AUTH=PLAIN');
+    this.write(`* CAPABILITY ${capabilities.join(' ')}`);
+    this.write(`${tag} OK CAPABILITY completed`);
+  }
+
+  startTls(tag) {
+    if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write(`${tag} NO TLS is not available`);
+    this.write(`${tag} OK Begin TLS negotiation now`);
+    this.upgradeToTls();
+  }
+
+  login(tag, rest) {
+    if (!this.canAuthenticate()) return this.write(`${tag} NO Encryption required for authentication`);
+    const [username, password] = tokenizeImap(rest);
+    if (!username || password === undefined) return this.write(`${tag} BAD LOGIN expects username and password`);
+    const auth = verifyInboundMailboxCredential(username, password);
+    if (!auth) return this.write(`${tag} NO Authentication failed`);
+    this.user = auth.user;
+    this.mailbox = auth.mailbox;
+    this.authenticated = true;
+    this.write(`${tag} OK LOGIN completed`);
+  }
+
+  authenticate(tag, rest) {
+    if (!this.canAuthenticate()) return this.write(`${tag} NO Encryption required for authentication`);
+    const [method, initial] = tokenizeImap(rest);
+    if (String(method || '').toUpperCase() !== 'PLAIN') return this.write(`${tag} NO Unsupported authentication method`);
+    if (initial) return this.finishAuthenticatePlain(tag, initial);
+    this.authContinuation = { tag };
+    this.write('+');
+  }
+
+  finishAuthenticatePlain(tag, response) {
+    const decoded = decodeBase64(response);
+    const parts = decoded.split('\u0000');
+    const username = parts[1] || parts[0] || '';
+    const password = parts[2] || parts[1] || '';
+    const auth = verifyInboundMailboxCredential(username, password);
+    if (!auth) return this.write(`${tag} NO Authentication failed`);
+    this.user = auth.user;
+    this.mailbox = auth.mailbox;
+    this.authenticated = true;
+    this.write(`${tag} OK AUTHENTICATE completed`);
+  }
+
+  list(tag) {
+    this.write('* LIST (\\HasNoChildren) "/" "INBOX"');
+    this.write(`${tag} OK LIST completed`);
+  }
+
+  namespace(tag) {
+    this.write('* NAMESPACE (("" "/")) NIL NIL');
+    this.write(`${tag} OK NAMESPACE completed`);
+  }
+
+  select(tag, rest, readOnly) {
+    const [mailboxName] = tokenizeImap(rest);
+    if (!isInbox(mailboxName)) return this.write(`${tag} NO Only INBOX is available`);
+    this.reloadMessages();
+    this.selected = true;
+    this.write('* FLAGS (\\Seen \\Deleted)');
+    this.write(`* ${this.messages.length} EXISTS`);
+    this.write('* 0 RECENT');
+    this.write(`* OK [UIDVALIDITY ${this.mailbox.id}] UIDs valid`);
+    this.write(`* OK [UIDNEXT ${uidNext(this.messages)}] Predicted next UID`);
+    this.write('* OK [PERMANENTFLAGS (\\Seen \\Deleted)] Limited flags permitted');
+    this.write(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] SELECT completed`);
+  }
+
+  status(tag, rest) {
+    const [mailboxName] = tokenizeImap(rest);
+    if (!isInbox(mailboxName)) return this.write(`${tag} NO Only INBOX is available`);
+    const messages = mailboxProtocolMessages(this.mailbox);
+    const unseen = messages.filter((message) => !message.read).length;
+    this.write(`* STATUS "INBOX" (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
+    this.write(`${tag} OK STATUS completed`);
+  }
+
+  uid(tag, rest) {
+    const parsed = rest.match(/^(\S+)(?:\s+(.*))?$/);
+    if (!parsed) return this.write(`${tag} BAD UID expects a subcommand`);
+    const subcommand = parsed[1].toUpperCase();
+    const args = parsed[2] || '';
+    if (subcommand === 'FETCH') return this.fetch(tag, args, true);
+    if (subcommand === 'STORE') return this.store(tag, args, true);
+    if (subcommand === 'SEARCH') return this.search(tag, args, true);
+    return this.write(`${tag} BAD UID subcommand not implemented`);
+  }
+
+  search(tag, _rest, byUid) {
+    if (!this.selected) this.reloadMessages();
+    const values = this.messages.map((message, index) => byUid ? message.id : index + 1);
+    this.write(`* SEARCH ${values.join(' ')}`.trimEnd());
+    this.write(`${tag} OK SEARCH completed`);
+  }
+
+  fetch(tag, rest, byUid) {
+    if (!this.selected) return this.write(`${tag} NO Select INBOX first`);
+    const [set, items = ''] = splitFirst(rest);
+    const entries = resolveMessageSet(set, this.messages, byUid);
+    for (const entry of entries) this.sendFetch(entry, items, byUid);
+    this.write(`${tag} OK FETCH completed`);
+  }
+
+  sendFetch(entry, items, byUid) {
+    const upper = String(items || '').toUpperCase();
+    const attrs = [];
+    if (byUid || /\bUID\b/.test(upper)) attrs.push(`UID ${entry.message.id}`);
+    if (!upper || /\bFLAGS\b/.test(upper)) attrs.push(`FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')})`);
+    if (/\bINTERNALDATE\b/.test(upper)) attrs.push(`INTERNALDATE "${imapDate(entry.message.receivedAt)}"`);
+    if (/RFC822\.SIZE|BODY|RFC822/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
+    if (/\bENVELOPE\b/.test(upper)) attrs.push(`ENVELOPE ${imapEnvelope(entry.message)}`);
+
+    const literal = resolveFetchLiteral(items, entry.message);
+    if (!literal) {
+      this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
+      return;
+    }
+    const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${Buffer.byteLength(literal.value, 'utf8')}}`].join(' ')}\r\n`;
+    this.socket.write(prefix);
+    this.socket.write(literal.value);
+    this.socket.write('\r\n)\r\n');
+  }
+
+  store(tag, rest, byUid) {
+    const parsed = rest.match(/^(\S+)\s+(\S+)\s+(.+)$/);
+    if (!parsed) return this.write(`${tag} BAD STORE expects sequence, item, and flags`);
+    const [, set, itemRaw, flagsRaw] = parsed;
+    const item = itemRaw.toUpperCase();
+    const silent = item.includes('.SILENT');
+    const entries = resolveMessageSet(set, this.messages, byUid);
+    const flags = parseFlags(flagsRaw);
+    for (const entry of entries) {
+      if (flags.has('\\SEEN')) {
+        const read = !item.startsWith('-FLAGS');
+        markInboundMessageRead(this.mailbox.userId, entry.message.id, read);
+        entry.message.read = read;
+      }
+      if (flags.has('\\DELETED')) {
+        if (item.startsWith('-FLAGS')) this.deletedUids.delete(entry.message.id);
+        else this.deletedUids.add(entry.message.id);
+      }
+      if (!silent) this.write(`* ${entry.seq} FETCH (FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')}))`);
+    }
+    this.write(`${tag} OK STORE completed`);
+  }
+
+  expunge(tag) {
+    const entries = this.messages
+      .map((message, index) => ({ message, seq: index + 1 }))
+      .filter((entry) => this.deletedUids.has(entry.message.id));
+    softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, entries.map((entry) => entry.message.id));
+    for (const entry of entries.reverse()) this.write(`* ${entry.seq} EXPUNGE`);
+    this.deletedUids.clear();
+    this.reloadMessages();
+    this.write(`${tag} OK EXPUNGE completed`);
+  }
+
+  closeMailbox(tag) {
+    const ids = [...this.deletedUids];
+    if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids);
+    this.deletedUids.clear();
+    this.selected = false;
+    this.messages = [];
+    this.write(`${tag} OK CLOSE completed`);
+  }
+
+  idle(tag) {
+    this.idleTag = tag;
+    this.write('+ idling');
+  }
+
+  reloadMessages() {
+    this.messages = mailboxProtocolMessages(this.mailbox);
+  }
+
+  upgradeToTls() {
+    this.socket.removeListener('data', this.onDataBound);
+    const secureSocket = new tls.TLSSocket(this.socket, {
+      isServer: true,
+      secureContext: this.config.secureContext
+    });
+    this.socket = secureSocket;
+    this.buffer = '';
+    this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
+    secureSocket.setEncoding('utf8');
+    secureSocket.on('data', this.onDataBound);
+    secureSocket.on('error', () => null);
+  }
+
+  canAuthenticate() {
+    return this.config.tlsActive || this.config.allowInsecureAuth;
+  }
+
+  write(line) {
+    this.socket.write(`${line}\r\n`);
+  }
+}
+
+class Pop3Session {
+  constructor(socket, config) {
+    this.socket = socket;
+    this.config = config;
+    this.buffer = '';
+    this.username = '';
+    this.authenticated = false;
+    this.user = null;
+    this.mailbox = null;
+    this.messages = [];
+    this.deletedIndexes = new Set();
+    this.onDataBound = (chunk) => this.onData(chunk);
+    socket.setEncoding('utf8');
+    socket.on('data', this.onDataBound);
+    socket.on('error', () => null);
+    this.write(`+OK ${config.hostname} MailHub POP3 ready`);
+  }
+
+  onData(chunk) {
+    this.buffer += chunk;
+    let index;
+    while ((index = this.buffer.indexOf('\n')) !== -1) {
+      const line = this.buffer.slice(0, index).replace(/\r$/, '');
+      this.buffer = this.buffer.slice(index + 1);
+      this.onLine(line);
+    }
+  }
+
+  onLine(line) {
+    const [rawCommand, ...parts] = line.split(' ');
+    const command = String(rawCommand || '').toUpperCase();
+    const rest = parts.join(' ').trim();
+    if (command === 'CAPA') return this.capa();
+    if (command === 'QUIT') return this.quit();
+    if (command === 'NOOP') return this.write('+OK');
+    if (command === 'STLS') return this.startTls();
+    if (command === 'USER') return this.userCommand(rest);
+    if (command === 'PASS') return this.pass(rest);
+    if (command === 'AUTH') return this.auth(rest);
+    if (!this.authenticated) return this.write('-ERR Authentication required');
+    if (command === 'STAT') return this.stat();
+    if (command === 'LIST') return this.list(rest);
+    if (command === 'UIDL') return this.uidl(rest);
+    if (command === 'RETR') return this.retr(rest);
+    if (command === 'TOP') return this.top(rest);
+    if (command === 'DELE') return this.dele(rest);
+    if (command === 'RSET') {
+      this.deletedIndexes.clear();
+      return this.write('+OK');
+    }
+    return this.write('-ERR Command not implemented');
+  }
+
+  capa() {
+    this.write('+OK Capability list follows');
+    this.write('USER');
+    this.write('UIDL');
+    this.write('TOP');
+    if (this.config.startTlsAvailable && !this.config.tlsActive) this.write('STLS');
+    this.write('.');
+  }
+
+  startTls() {
+    if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write('-ERR TLS is not available');
+    this.write('+OK Begin TLS negotiation now');
+    this.upgradeToTls();
+  }
+
+  userCommand(username) {
+    if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
+    this.username = username;
+    this.write('+OK User accepted');
+  }
+
+  pass(password) {
+    if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
+    if (!this.username) return this.write('-ERR USER required before PASS');
+    return this.finishAuth(this.username, password);
+  }
+
+  auth(rest) {
+    const [method, response] = rest.split(/\s+/, 2);
+    if (String(method || '').toUpperCase() !== 'PLAIN' || !response) return this.write('-ERR Unsupported authentication method');
+    const parts = decodeBase64(response).split('\u0000');
+    return this.finishAuth(parts[1] || parts[0] || '', parts[2] || parts[1] || '');
+  }
+
+  finishAuth(username, password) {
+    const auth = verifyInboundMailboxCredential(username, password);
+    if (!auth) return this.write('-ERR Authentication failed');
+    this.user = auth.user;
+    this.mailbox = auth.mailbox;
+    this.authenticated = true;
+    this.messages = mailboxProtocolMessages(this.mailbox);
+    this.deletedIndexes.clear();
+    return this.write('+OK Mailbox locked and ready');
+  }
+
+  stat() {
+    const active = this.activeMessages();
+    this.write(`+OK ${active.length} ${active.reduce((total, item) => total + messageBytes(item.message), 0)}`);
+  }
+
+  list(rest) {
+    if (rest) {
+      const entry = this.messageByNumber(rest);
+      if (!entry) return this.write('-ERR No such message');
+      return this.write(`+OK ${entry.index} ${messageBytes(entry.message)}`);
+    }
+    this.write('+OK Message list follows');
+    for (const entry of this.activeMessages()) this.write(`${entry.index} ${messageBytes(entry.message)}`);
+    this.write('.');
+  }
+
+  uidl(rest) {
+    if (rest) {
+      const entry = this.messageByNumber(rest);
+      if (!entry) return this.write('-ERR No such message');
+      return this.write(`+OK ${entry.index} ${pop3Uid(entry.message)}`);
+    }
+    this.write('+OK Unique IDs follow');
+    for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3Uid(entry.message)}`);
+    this.write('.');
+  }
+
+  retr(rest) {
+    const entry = this.messageByNumber(rest);
+    if (!entry) return this.write('-ERR No such message');
+    const rawMessage = normalizeRawMessage(entry.message);
+    this.write(`+OK ${Buffer.byteLength(rawMessage, 'utf8')} octets`);
+    this.socket.write(`${dotStuff(rawMessage)}\r\n.\r\n`);
+  }
+
+  top(rest) {
+    const [messageNumber, lineCountRaw] = rest.split(/\s+/, 2);
+    const entry = this.messageByNumber(messageNumber);
+    if (!entry) return this.write('-ERR No such message');
+    const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
+    const preview = topLines(normalizeRawMessage(entry.message), lineCount);
+    this.write('+OK Top of message follows');
+    this.socket.write(`${dotStuff(preview)}\r\n.\r\n`);
+  }
+
+  dele(rest) {
+    const entry = this.messageByNumber(rest);
+    if (!entry) return this.write('-ERR No such message');
+    this.deletedIndexes.add(entry.index);
+    this.write(`+OK Message ${entry.index} deleted`);
+  }
+
+  quit() {
+    if (this.authenticated && this.deletedIndexes.size) {
+      const ids = [...this.deletedIndexes]
+        .map((index) => this.messages[index - 1]?.id)
+        .filter(Boolean);
+      softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids);
+    }
+    this.write('+OK Bye');
+    this.socket.end();
+  }
+
+  activeMessages() {
+    return this.messages
+      .map((message, index) => ({ message, index: index + 1 }))
+      .filter((entry) => !this.deletedIndexes.has(entry.index));
+  }
+
+  messageByNumber(value) {
+    const index = Number(value);
+    if (!Number.isInteger(index) || index < 1 || index > this.messages.length || this.deletedIndexes.has(index)) return null;
+    return { message: this.messages[index - 1], index };
+  }
+
+  upgradeToTls() {
+    this.socket.removeListener('data', this.onDataBound);
+    const secureSocket = new tls.TLSSocket(this.socket, {
+      isServer: true,
+      secureContext: this.config.secureContext
+    });
+    this.socket = secureSocket;
+    this.buffer = '';
+    this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
+    secureSocket.setEncoding('utf8');
+    secureSocket.on('data', this.onDataBound);
+    secureSocket.on('error', () => null);
+  }
+
+  canAuthenticate() {
+    return this.config.tlsActive || this.config.allowInsecureAuth;
+  }
+
+  write(line) {
+    this.socket.write(`${line}\r\n`);
+  }
+}
+
+function loadTlsMaterial(config) {
+  if (!config.tlsKeyPath || !config.tlsCertPath) return null;
+  try {
+    const key = readFileSync(config.tlsKeyPath);
+    const cert = readFileSync(config.tlsCertPath);
+    return {
+      key,
+      cert,
+      secureContext: tls.createSecureContext({ key, cert })
+    };
+  } catch (error) {
+    console.warn(`Unable to load mailbox access TLS certificate: ${error.message}`);
+    return null;
+  }
+}
+
+function publicProtocolLabel(protocol, tlsEnabled) {
+  if (protocol === 'imaps') return 'IMAPS';
+  if (protocol === 'pop3s') return 'POP3S';
+  if (protocol === 'imap') return tlsEnabled ? 'IMAP + STARTTLS' : 'IMAP';
+  return tlsEnabled ? 'POP3 + STLS' : 'POP3';
+}
+
+function mailboxProtocolMessages(mailbox) {
+  return listInboundMailboxProtocolMessages(mailbox).map((message) => ({
+    ...message,
+    rawMessage: normalizeRawMessage(message)
+  }));
+}
+
+function tokenizeImap(value) {
+  const tokens = [];
+  const input = String(value || '');
+  let token = '';
+  let quoted = false;
+  let escaping = false;
+  for (const char of input) {
+    if (escaping) {
+      token += char;
+      escaping = false;
+      continue;
+    }
+    if (quoted && char === '\\') {
+      escaping = true;
+      continue;
+    }
+    if (char === '"') {
+      quoted = !quoted;
+      continue;
+    }
+    if (!quoted && /\s/.test(char)) {
+      if (token) {
+        tokens.push(token);
+        token = '';
+      }
+      continue;
+    }
+    token += char;
+  }
+  if (token) tokens.push(token);
+  return tokens;
+}
+
+function splitFirst(value) {
+  const input = String(value || '').trim();
+  const index = input.search(/\s/);
+  if (index === -1) return [input, ''];
+  return [input.slice(0, index), input.slice(index + 1).trim()];
+}
+
+function isInbox(value) {
+  return String(value || '').replace(/^"|"$/g, '').toUpperCase() === 'INBOX';
+}
+
+function resolveMessageSet(set, messages, byUid) {
+  const max = messages.length;
+  const entries = [];
+  for (const part of String(set || '').split(',').filter(Boolean)) {
+    const [startRaw, endRaw] = part.split(':');
+    const start = resolveSetValue(startRaw, messages, byUid);
+    const end = endRaw === undefined ? start : resolveSetValue(endRaw, messages, byUid);
+    if (start === null || end === null) continue;
+    const low = Math.min(start, end);
+    const high = Math.max(start, end);
+    for (let index = 0; index < max; index += 1) {
+      const value = byUid ? messages[index].id : index + 1;
+      if (value >= low && value <= high) entries.push({ seq: index + 1, message: messages[index] });
+    }
+  }
+  return [...new Map(entries.map((entry) => [entry.message.id, entry])).values()];
+}
+
+function resolveSetValue(value, messages, byUid) {
+  const clean = String(value || '').trim();
+  if (clean === '*') return byUid ? messages.at(-1)?.id || 0 : messages.length;
+  const number = Number(clean);
+  return Number.isInteger(number) && number >= 0 ? number : null;
+}
+
+function resolveFetchLiteral(items, message) {
+  const raw = normalizeRawMessage(message);
+  if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: raw };
+  if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: headerBlock(raw) };
+  if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: bodyBlock(raw) };
+  const bodyMatch = String(items || '').match(/BODY(?:\.PEEK)?\[([^\]]*)\]/i);
+  if (!bodyMatch) return null;
+  const section = bodyMatch[1] || '';
+  return {
+    label: `BODY[${section}]`,
+    value: bodySection(raw, section)
+  };
+}
+
+function bodySection(raw, section) {
+  const clean = String(section || '').trim().toUpperCase();
+  if (!clean) return raw;
+  if (clean === 'HEADER') return headerBlock(raw);
+  if (clean === 'TEXT') return bodyBlock(raw);
+  if (clean.startsWith('HEADER.FIELDS')) return selectedHeaders(raw, clean);
+  return raw;
+}
+
+function selectedHeaders(raw, section) {
+  const names = new Set((section.match(/\(([^)]*)\)/)?.[1] || '')
+    .split(/\s+/)
+    .map((name) => name.toLowerCase())
+    .filter(Boolean));
+  if (!names.size) return headerBlock(raw);
+  const output = [];
+  let keep = false;
+  for (const line of headerBlock(raw).split('\r\n')) {
+    if (!line) continue;
+    if (/^[\t ]/.test(line)) {
+      if (keep) output.push(line);
+      continue;
+    }
+    const name = line.slice(0, line.indexOf(':')).toLowerCase();
+    keep = names.has(name);
+    if (keep) output.push(line);
+  }
+  return `${output.join('\r\n')}\r\n\r\n`;
+}
+
+function headerBlock(raw) {
+  return `${raw.split('\r\n\r\n', 1)[0] || ''}\r\n\r\n`;
+}
+
+function bodyBlock(raw) {
+  const index = raw.indexOf('\r\n\r\n');
+  return index === -1 ? '' : raw.slice(index + 4);
+}
+
+function parseFlags(value) {
+  return new Set(String(value || '').toUpperCase().match(/\\[A-Z]+/g) || []);
+}
+
+function imapFlags(message, deletedUids) {
+  return [
+    message.read ? '\\Seen' : '',
+    deletedUids.has(message.id) ? '\\Deleted' : ''
+  ].filter(Boolean);
+}
+
+function imapEnvelope(message) {
+  return `("${imapDate(message.receivedAt)}" ${imapNString(message.subject)} ${addressList(message.sender)} NIL NIL ${addressList(message.sender)} ${addressList(message.sender)} NIL NIL ${imapNString(message.messageId)})`;
+}
+
+function addressList(address) {
+  const clean = String(address || '');
+  const [localPart, domain] = clean.split('@');
+  if (!localPart || !domain) return 'NIL';
+  return `((NIL NIL ${imapNString(localPart)} ${imapNString(domain)}))`;
+}
+
+function imapNString(value) {
+  if (!value) return 'NIL';
+  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+}
+
+function imapDate(value) {
+  const date = value ? new Date(value) : new Date();
+  return date.toUTCString().replace(',', '');
+}
+
+function uidNext(messages) {
+  return Math.max(0, ...messages.map((message) => Number(message.id) || 0)) + 1;
+}
+
+function messageBytes(message) {
+  return Buffer.byteLength(normalizeRawMessage(message), 'utf8');
+}
+
+function normalizeRawMessage(message) {
+  const raw = String(message.rawMessage || fallbackRawMessage(message) || '').replace(/\r?\n/g, '\r\n');
+  return raw.endsWith('\r\n') ? raw : `${raw}\r\n`;
+}
+
+function fallbackRawMessage(message) {
+  return [
+    message.sender ? `From: ${message.sender}` : '',
+    message.recipients?.length ? `To: ${message.recipients.join(', ')}` : '',
+    message.subject ? `Subject: ${message.subject}` : '',
+    message.messageId ? `Message-ID: ${message.messageId}` : '',
+    message.receivedAt ? `Date: ${new Date(message.receivedAt).toUTCString()}` : '',
+    '',
+    message.textBody || message.preview || ''
+  ].filter((line, index) => line || index >= 5).join('\r\n');
+}
+
+function dotStuff(rawMessage) {
+  return String(rawMessage || '')
+    .replace(/\r?\n/g, '\r\n')
+    .split('\r\n')
+    .map((line) => line.startsWith('.') ? `.${line}` : line)
+    .join('\r\n')
+    .replace(/\r\n$/, '');
+}
+
+function topLines(rawMessage, lineCount) {
+  const header = headerBlock(rawMessage).replace(/\r\n\r\n$/, '');
+  const lines = bodyBlock(rawMessage).split('\r\n').slice(0, lineCount).join('\r\n');
+  return `${header}\r\n\r\n${lines}`;
+}
+
+function pop3Uid(message) {
+  return `mh-${message.id}`;
+}
+
+function decodeBase64(value) {
+  try {
+    return Buffer.from(String(value || ''), 'base64').toString('utf8');
+  } catch {
+    return '';
+  }
+}

+ 389 - 21
src/pages/Inbox.tsx

@@ -1,11 +1,31 @@
 import {
   CopyOutlined,
+  KeyOutlined,
   InboxOutlined,
   PlusOutlined,
   ReloadOutlined,
-  SearchOutlined
+  SearchOutlined,
+  SettingOutlined
 } from '@ant-design/icons';
-import { Alert, Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Spin, Table, Tabs, Tag, Typography } from 'antd';
+import {
+  Alert,
+  Button,
+  Checkbox,
+  Collapse,
+  Descriptions,
+  Drawer,
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Select,
+  Space,
+  Spin,
+  Table,
+  Tabs,
+  Tag,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useMemo, useState } from 'react';
 
@@ -14,7 +34,7 @@ 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';
+import type { Domain, DomainPatchPayload, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
 
 interface InboxProps {
   config: RuntimeConfig | null;
@@ -22,7 +42,16 @@ interface InboxProps {
   mailboxes: InboundMailbox[];
   messages: InboundMessage[];
   loading?: boolean;
-  onCreateMailbox: (values: { address: string; displayName?: string }) => Promise<InboundMailbox | null>;
+  onCreateMailbox: (values: {
+    address: string;
+    displayName?: string;
+    password: string;
+    aliases?: string;
+    forwardTo?: string;
+    keepForwarded?: boolean;
+    quotaMb?: number | string | null;
+  }) => Promise<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig } | null>;
+  onPatchDomain: (domain: Domain, values: DomainPatchPayload) => Promise<void>;
   onLoadMessages: (mailboxId?: number | null) => Promise<InboundMessage[]>;
   onLoadMessage: (id: number) => Promise<InboundMessage | null>;
   onCopy: (value: string) => void;
@@ -32,7 +61,16 @@ interface InboxProps {
 interface MailboxFormValues {
   localPart: string;
   domain: string;
+  password: string;
   displayName?: string;
+  quotaMb?: number | null;
+  aliases?: string;
+  forwardTo?: string;
+  keepForwarded?: boolean;
+}
+
+interface CatchAllFormValues {
+  catchAllAddress?: string;
 }
 
 export default function Inbox({
@@ -42,6 +80,7 @@ export default function Inbox({
   messages,
   loading,
   onCreateMailbox,
+  onPatchDomain,
   onLoadMessages,
   onLoadMessage,
   onCopy,
@@ -49,8 +88,12 @@ export default function Inbox({
 }: InboxProps) {
   const { t } = useI18n();
   const [form] = Form.useForm<MailboxFormValues>();
+  const [catchAllForm] = Form.useForm<CatchAllFormValues>();
   const [mailboxOpen, setMailboxOpen] = useState(false);
   const [mailboxLoading, setMailboxLoading] = useState(false);
+  const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
+  const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
+  const [catchAllLoading, setCatchAllLoading] = useState(false);
   const [selectedMailboxId, setSelectedMailboxId] = useState<number | null>(null);
   const [query, setQuery] = useState('');
   const [selectedMessage, setSelectedMessage] = useState<InboundMessage | null>(null);
@@ -69,6 +112,39 @@ export default function Inbox({
     ].some((value) => String(value || '').toLowerCase().includes(cleanQuery)));
   }, [messages, query]);
 
+  const domainMailboxCounts = useMemo(() => {
+    const counts = new Map<number, number>();
+    for (const mailbox of mailboxes) counts.set(mailbox.domainId, (counts.get(mailbox.domainId) || 0) + 1);
+    return counts;
+  }, [mailboxes]);
+
+  const domainColumns: ColumnsType<Domain> = [
+    {
+      title: t('domains.domain'),
+      dataIndex: 'domain',
+      render: (value: string, domain) => (
+        <Space wrap>
+          <Typography.Text strong>{value}</Typography.Text>
+          <Typography.Text type="secondary">{domainMailboxCounts.get(domain.id) || 0} {t('inbox.mailboxUnit')}</Typography.Text>
+        </Space>
+      )
+    },
+    {
+      title: t('inbox.catchAllAddress'),
+      dataIndex: 'catchAllAddress',
+      render: (value: string) => value ? <Tag color={value === '/dev/null' ? 'default' : 'blue'}>{value}</Tag> : <Tag>{t('inbox.catchAllDisabled')}</Tag>
+    },
+    {
+      title: t('common.actions'),
+      width: 130,
+      render: (_value, domain) => (
+        <Button icon={<SettingOutlined />} onClick={() => openCatchAllModal(domain)}>
+          {t('common.edit')}
+        </Button>
+      )
+    }
+  ];
+
   const mailboxColumns: ColumnsType<InboundMailbox> = [
     {
       title: t('inbox.mailboxAddress'),
@@ -80,6 +156,25 @@ export default function Inbox({
         </Space>
       )
     },
+    {
+      title: t('inbox.forwardTo'),
+      dataIndex: 'forwardTo',
+      width: 240,
+      render: (value: string[], mailbox) => value?.length ? (
+        <Space direction="vertical" size={2}>
+          <Typography.Text ellipsis>{value.join(', ')}</Typography.Text>
+          <Tag color={mailbox.keepForwarded ? 'blue' : 'orange'}>
+            {mailbox.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')}
+          </Tag>
+        </Space>
+      ) : '-'
+    },
+    {
+      title: t('inbox.quotaMb'),
+      dataIndex: 'quotaMb',
+      width: 120,
+      render: (value: number | null) => value === null ? t('inbox.unlimited') : `${value} MB`
+    },
     {
       title: t('inbox.unread'),
       dataIndex: 'unreadCount',
@@ -94,6 +189,15 @@ export default function Inbox({
       dataIndex: 'lastMessageAt',
       width: 190,
       render: formatOptionalTime
+    },
+    {
+      title: t('common.actions'),
+      width: 120,
+      render: (_value, mailbox) => (
+        <Button icon={<KeyOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(mailbox, config))}>
+          {t('inbox.clientConfig')}
+        </Button>
+      )
     }
   ];
 
@@ -149,6 +253,53 @@ export default function Inbox({
           <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} />
         ) : null}
 
+        <Collapse
+          className="inbox-help"
+          size="small"
+          defaultActiveKey={['client']}
+          items={[
+            {
+              key: 'client',
+              label: t('inbox.clientHelpTitle'),
+              children: (
+                <Space direction="vertical" size={8} className="full-width">
+                  <Typography.Paragraph type="secondary" className="inbox-help-intro">
+                    {t('inbox.clientHelpIntro')}
+                  </Typography.Paragraph>
+                  <ul className="inbox-help-list">
+                    <li>{t('inbox.clientHelpImap')}</li>
+                    <li>{t('inbox.clientHelpPop3')}</li>
+                    <li>{t('inbox.clientHelpAuth')}</li>
+                    <li>{t('inbox.clientHelpSecurity')}</li>
+                    <li>{t('inbox.clientHelpPorts')}</li>
+                  </ul>
+                </Space>
+              )
+            }
+          ]}
+        />
+
+        <SectionCard
+          title={t('inbox.domainRoutes')}
+          extra={<Typography.Text type="secondary">{domains.length}</Typography.Text>}
+        >
+          {domains.length ? (
+            <Table
+              rowKey="id"
+              columns={domainColumns}
+              dataSource={domains}
+              pagination={false}
+              scroll={{ x: 720 }}
+            />
+          ) : (
+            <EmptyState
+              icon={<InboxOutlined />}
+              description={t('inbox.noDomain')}
+              action={<Button type="primary" onClick={onAddDomain}>{t('common.addDomain')}</Button>}
+            />
+          )}
+        </SectionCard>
+
         <SectionCard
           title={t('inbox.mailboxes')}
           extra={
@@ -163,7 +314,7 @@ export default function Inbox({
               columns={mailboxColumns}
               dataSource={mailboxes}
               pagination={{ pageSize: 5 }}
-              scroll={{ x: 760 }}
+              scroll={{ x: 1180 }}
             />
           ) : (
             <EmptyState
@@ -218,30 +369,125 @@ export default function Inbox({
         confirmLoading={mailboxLoading}
         onOk={saveMailbox}
         onCancel={closeMailboxModal}
+        width={760}
       >
         <Form form={form} layout="vertical">
+          <div className="inbox-form-grid">
+            <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>
+          </div>
           <Form.Item
-            name="localPart"
-            label={t('inbox.localPart')}
+            name="password"
+            label={t('inbox.password')}
             rules={[
-              { required: true, message: t('inbox.localPartRequired') },
-              { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }
+              { required: true, message: t('inbox.passwordRequired') },
+              { min: 8, message: t('inbox.passwordMin') }
             ]}
           >
-            <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"
+            <Input.Password
+              autoComplete="new-password"
+              addonAfter={<Button type="link" size="small" onClick={generatePassword}>{t('inbox.generatePassword')}</Button>}
             />
           </Form.Item>
-          <Form.Item name="displayName" label={t('inbox.displayName')}>
-            <Input placeholder="Support" />
+          <div className="inbox-form-grid">
+            <Form.Item name="displayName" label={t('inbox.displayName')}>
+              <Input placeholder="Support" />
+            </Form.Item>
+            <Form.Item name="quotaMb" label={t('inbox.quotaMb')}>
+              <InputNumber min={0} precision={0} className="full-width" placeholder={t('inbox.unlimited')} addonAfter="MB" />
+            </Form.Item>
+          </div>
+          <Form.Item name="aliases" label={t('inbox.aliases')} extra={t('inbox.aliasesExtra')}>
+            <Input.TextArea rows={3} placeholder={'sales\nhelp'} />
+          </Form.Item>
+          <Form.Item name="forwardTo" label={t('inbox.forwardTo')} extra={t('inbox.forwardToExtra')}>
+            <Input.TextArea rows={3} placeholder={'archive@example.net\nops@example.net'} />
+          </Form.Item>
+          <Form.Item name="keepForwarded" valuePropName="checked">
+            <Checkbox>{t('inbox.keepForwarded')}</Checkbox>
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      <Modal
+        title={catchAllDomain ? `${t('inbox.catchAllTitle')} · ${catchAllDomain.domain}` : t('inbox.catchAllTitle')}
+        open={Boolean(catchAllDomain)}
+        confirmLoading={catchAllLoading}
+        onOk={saveCatchAll}
+        onCancel={() => setCatchAllDomain(null)}
+      >
+        <Form form={catchAllForm} layout="vertical">
+          <Form.Item name="catchAllAddress" label={t('inbox.catchAllAddress')} extra={t('inbox.catchAllExtra')}>
+            <Input placeholder={`share@${catchAllDomain?.domain || 'example.com'} 或 /dev/null`} />
           </Form.Item>
         </Form>
       </Modal>
 
+      <Modal
+        title={t('inbox.clientConfig')}
+        open={Boolean(clientConfig)}
+        footer={null}
+        onCancel={() => setClientConfig(null)}
+        width={760}
+      >
+        {clientConfig ? (
+          <Space direction="vertical" size={16} className="full-width">
+            <Alert type="info" showIcon message={t('inbox.clientConfigHelpSummary')} />
+            <Descriptions bordered size="small" column={1}>
+              <Descriptions.Item label={t('inbox.configUsername')}>
+                <ConfigValue value={clientConfig.username} onCopy={onCopy} />
+              </Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configPassword')}>
+                <ConfigValue value={clientConfig.password || t('inbox.passwordNotShown')} onCopy={clientConfig.password ? onCopy : undefined} />
+              </Descriptions.Item>
+            </Descriptions>
+            <Descriptions bordered size="small" column={1} title={t('inbox.incomingConfig')}>
+              <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.incoming.protocol}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configHost')}>
+                <ConfigValue value={clientConfig.incoming.host} onCopy={onCopy} />
+              </Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.incoming.port}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.incoming.security}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.incoming.authMethod}</Descriptions.Item>
+            </Descriptions>
+            {clientConfig.pop3 ? (
+              <Descriptions bordered size="small" column={1} title={t('inbox.pop3Config')}>
+                <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.pop3.protocol}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.configHost')}>
+                  <ConfigValue value={clientConfig.pop3.host} onCopy={onCopy} />
+                </Descriptions.Item>
+                <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.pop3.port}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.pop3.security}</Descriptions.Item>
+                <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.pop3.authMethod}</Descriptions.Item>
+              </Descriptions>
+            ) : null}
+            <Descriptions bordered size="small" column={1} title={t('inbox.outgoingConfig')}>
+              <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.outgoing.protocol}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configHost')}>
+                <ConfigValue value={clientConfig.outgoing.host} onCopy={onCopy} />
+              </Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.outgoing.port}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.outgoing.security}</Descriptions.Item>
+              <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.outgoing.authMethod}</Descriptions.Item>
+            </Descriptions>
+          </Space>
+        ) : null}
+      </Modal>
+
       <Drawer
         title={selectedMessage ? `${t('inbox.messageDetail')} · mh-in-${selectedMessage.id}` : t('inbox.messageDetail')}
         open={Boolean(selectedMessage)}
@@ -294,7 +540,16 @@ export default function Inbox({
   );
 
   function openMailboxModal() {
-    form.setFieldsValue({ localPart: '', domain: domains[0]?.domain || '', displayName: '' });
+    form.setFieldsValue({
+      localPart: '',
+      domain: domains[0]?.domain || '',
+      password: generateMailboxPassword(),
+      displayName: '',
+      quotaMb: null,
+      aliases: '',
+      forwardTo: '',
+      keepForwarded: true
+    });
     setMailboxOpen(true);
   }
 
@@ -307,17 +562,47 @@ export default function Inbox({
     const values = await form.validateFields();
     setMailboxLoading(true);
     try {
-      const mailbox = await onCreateMailbox({
+      const result = await onCreateMailbox({
         address: `${values.localPart.trim()}@${values.domain}`,
-        displayName: values.displayName?.trim()
+        displayName: values.displayName?.trim(),
+        password: values.password,
+        aliases: values.aliases,
+        forwardTo: values.forwardTo,
+        keepForwarded: values.keepForwarded !== false,
+        quotaMb: values.quotaMb ?? null
       });
-      if (!mailbox) return;
+      if (!result?.mailbox) return;
+      setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
       closeMailboxModal();
     } finally {
       setMailboxLoading(false);
     }
   }
 
+  function generatePassword() {
+    form.setFieldValue('password', generateMailboxPassword());
+  }
+
+  function openCatchAllModal(domain: Domain) {
+    setCatchAllDomain(domain);
+    catchAllForm.setFieldsValue({ catchAllAddress: domain.catchAllAddress || '' });
+  }
+
+  async function saveCatchAll() {
+    if (!catchAllDomain) return;
+    const values = await catchAllForm.validateFields();
+    setCatchAllLoading(true);
+    try {
+      await onPatchDomain(catchAllDomain, {
+        catchAllAddress: String(values.catchAllAddress || '').trim()
+      });
+      setCatchAllDomain(null);
+      catchAllForm.resetFields();
+    } finally {
+      setCatchAllLoading(false);
+    }
+  }
+
   async function selectMailbox(mailboxId: number | null) {
     setSelectedMailboxId(mailboxId);
     await onLoadMessages(mailboxId);
@@ -339,6 +624,89 @@ export default function Inbox({
   }
 }
 
+function ConfigValue({ value, onCopy }: { value: string | number; onCopy?: (value: string) => void }) {
+  return (
+    <Space>
+      <Typography.Text code>{value}</Typography.Text>
+      {onCopy ? (
+        <Button size="small" icon={<CopyOutlined />} aria-label="Copy" onClick={() => onCopy(String(value))} />
+      ) : null}
+    </Space>
+  );
+}
+
+function buildMailboxClientConfig(
+  mailbox: InboundMailbox,
+  config: RuntimeConfig | null,
+  password = ''
+): MailboxClientConfig {
+  const smtpPort = preferredSubmissionPort(config, ['SMTP + STARTTLS', 'SMTPS']);
+  const imapPort = preferredAccessPort(config?.mailAccess?.imap.ports || [], ['IMAPS', 'IMAP + STARTTLS', 'IMAP']);
+  const pop3Port = preferredAccessPort(config?.mailAccess?.pop3.ports || [], ['POP3S', 'POP3 + STLS', 'POP3']);
+  const accessHost = config?.mailAccess?.host || config?.submission?.host || config?.mailHostname || mailbox.domain;
+  return {
+    username: mailbox.address,
+    password,
+    incoming: {
+      protocol: 'IMAP',
+      host: accessHost,
+      port: imapPort?.port || 143,
+      security: imapPort?.protocol || 'IMAP + STARTTLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    },
+    pop3: {
+      protocol: 'POP3',
+      host: accessHost,
+      port: pop3Port?.port || 110,
+      security: pop3Port?.protocol || 'POP3 + STLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    },
+    outgoing: {
+      protocol: 'SMTP',
+      host: config?.submission?.host || config?.mailHostname || mailbox.domain,
+      port: smtpPort?.port || 587,
+      security: smtpPort?.protocol || 'SMTP + STARTTLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    }
+  };
+}
+
+function preferredAccessPort(ports: Array<{ port: number; protocol: string }>, protocols: string[]) {
+  for (const protocol of protocols) {
+    const match = ports.find((port) => port.protocol === protocol && [993, 995].includes(port.port)) ||
+      ports.find((port) => port.protocol === protocol);
+    if (match) return match;
+  }
+  return ports[0] || null;
+}
+
+function preferredSubmissionPort(config: RuntimeConfig | null, protocols: string[]) {
+  const ports = config?.submission?.ports || [];
+  for (const protocol of protocols) {
+    const match = ports.find((port) => port.protocol === protocol && port.port === 587) ||
+      ports.find((port) => port.protocol === protocol && port.port === 465) ||
+      ports.find((port) => port.protocol === protocol);
+    if (match) return match;
+  }
+  return ports[0] || null;
+}
+
+function generateMailboxPassword() {
+  const bytes = new Uint8Array(10);
+  if (globalThis.crypto?.getRandomValues) {
+    globalThis.crypto.getRandomValues(bytes);
+  } else {
+    for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
+  }
+  return Array.from(bytes, (value) => (value % 36).toString(36)).join('');
+}
+
 function MessageBody({ value, empty }: { value?: string; empty: string }) {
   if (!value) return <EmptyState description={empty} />;
   return <pre className="inbox-message-body">{value}</pre>;

+ 142 - 5
src/server.js

@@ -71,6 +71,7 @@ import {
   transferApiTokens,
   transferDnsCredential,
   transferDomain,
+  updateInboundMailbox,
   updateDkim,
   updateDomain,
   updateUser,
@@ -100,6 +101,11 @@ import {
   sendViaSmtp,
   signMessageForDomain
 } from './mailer.js';
+import {
+  parseMailboxAccessListeners,
+  publicMailboxAccessListeners,
+  startMailboxAccessServers
+} from './mail-access.js';
 import {
   parseSubmissionListeners,
   publicSubmissionListeners,
@@ -162,6 +168,12 @@ const envConfig = {
   submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
   submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
   submissionMaxMessageBytes: Number(process.env.SUBMISSION_MAX_MESSAGE_BYTES || 50 * 1024 * 1024),
+  imapEnabled: String(process.env.IMAP_ENABLED || 'true').toLowerCase() !== 'false',
+  imapListeners: parseMailboxAccessListeners(process.env.IMAP_PORTS, '143:imap,993:imaps'),
+  pop3Enabled: String(process.env.POP3_ENABLED || 'true').toLowerCase() !== 'false',
+  pop3Listeners: parseMailboxAccessListeners(process.env.POP3_PORTS, '110:pop3,995:pop3s'),
+  mailboxAccessAllowInsecureAuth:
+    String(process.env.MAIL_ACCESS_ALLOW_INSECURE_AUTH || process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
   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,
@@ -307,6 +319,17 @@ startSubmissionServer({
   }
 });
 
+startMailboxAccessServers({
+  hostname: envConfig.submissionHost,
+  imapEnabled: envConfig.imapEnabled,
+  imapListeners: envConfig.imapListeners,
+  pop3Enabled: envConfig.pop3Enabled,
+  pop3Listeners: envConfig.pop3Listeners,
+  allowInsecureAuth: envConfig.mailboxAccessAllowInsecureAuth,
+  tlsCertPath: envConfig.submissionTlsCert,
+  tlsKeyPath: envConfig.submissionTlsKey
+});
+
 async function handleApi(req, res, url, user) {
   const method = req.method || 'GET';
   const pathname = url.pathname;
@@ -365,18 +388,36 @@ async function handleApi(req, res, url, user) {
   }
   if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
     const body = await readJson(req);
+    const password = String(body.password || '');
+    if (password.length < 8) return sendJson(res, 400, { error: '邮箱密码至少需要 8 位。' });
     try {
+      const mailbox = createInboundMailbox(user.id, inboundMailboxPatch({
+        ...body,
+        password
+      }));
       return sendJson(res, 201, {
-        mailbox: createInboundMailbox(user.id, {
-          address: body.address,
-          displayName: body.displayName
-        })
+        mailbox,
+        clientConfig: mailboxClientConfig(mailbox, { password })
       });
     } catch (error) {
       if (isUniqueError(error)) return sendJson(res, 409, { error: '该收信邮箱已存在。' });
       return sendJson(res, 400, { error: error.message || '收信邮箱创建失败。' });
     }
   }
+  const inboundMailboxMatch = pathname.match(/^\/api\/inbound-mailboxes\/(\d+)$/);
+  if (inboundMailboxMatch && (method === 'PATCH' || method === 'PUT')) {
+    const body = await readJson(req);
+    try {
+      const mailbox = updateInboundMailbox(user.id, Number(inboundMailboxMatch[1]), inboundMailboxPatch(body));
+      if (!mailbox) return sendJson(res, 404, { error: '收信邮箱不存在。' });
+      return sendJson(res, 200, {
+        mailbox,
+        clientConfig: body.password ? mailboxClientConfig(mailbox, { password: String(body.password || '') }) : undefined
+      });
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || '收信邮箱更新失败。' });
+    }
+  }
   if (method === 'GET' && pathname === '/api/inbound-messages') {
     return sendJson(res, 200, {
       messages: listInboundMessages(user.id, {
@@ -665,6 +706,10 @@ async function handleApi(req, res, url, user) {
       if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
         return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
       }
+      const catchAllAddress = body.catchAllAddress !== undefined ? normalizeCatchAllAddress(body.catchAllAddress) : undefined;
+      if (body.catchAllAddress && !catchAllAddress) {
+        return sendJson(res, 400, { error: '收取未知邮件的邮箱格式不正确。' });
+      }
       const row = updateDomain(id, user.id, {
         selector: body.selector ? normalizeSelector(body.selector) : undefined,
         dnsCredentialId,
@@ -673,7 +718,8 @@ async function handleApi(req, res, url, user) {
         sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
         spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
         dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
-        dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
+        dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined,
+        catchAllAddress
       });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
       return sendJson(res, 200, { domain: row });
@@ -1540,6 +1586,23 @@ function publicConfig(user) {
       tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
       requireTlsForAuth: !envConfig.submissionAllowInsecureAuth
     },
+    mailAccess: {
+      host: envConfig.submissionHost,
+      tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
+      requireTlsForAuth: !envConfig.mailboxAccessAllowInsecureAuth,
+      imap: {
+        enabled: envConfig.imapEnabled,
+        ports: publicMailboxAccessListeners(envConfig.imapListeners, {
+          tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey)
+        })
+      },
+      pop3: {
+        enabled: envConfig.pop3Enabled,
+        ports: publicMailboxAccessListeners(envConfig.pop3Listeners, {
+          tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey)
+        })
+      }
+    },
     apiTokenSet: Boolean(envConfig.legacyApiToken),
     usingDefaultAdminPassword: user.role === 'admin' && envConfig.adminPassword === 'change-this-admin-password'
   };
@@ -1818,6 +1881,80 @@ function smtpRelayPatch(body) {
   return patch;
 }
 
+function inboundMailboxPatch(body) {
+  const patch = {};
+  for (const key of ['address', 'displayName', 'password', 'aliases', 'forwardTo', 'keepForwarded', 'quotaMb', 'status']) {
+    if (Object.hasOwn(body, key)) patch[key] = body[key];
+  }
+  return patch;
+}
+
+function normalizeCatchAllAddress(value) {
+  const clean = String(value || '').trim().toLowerCase();
+  if (!clean) return '';
+  if (clean === '/dev/null') return clean;
+  return extractAddress(clean);
+}
+
+function mailboxClientConfig(mailbox, { password = '' } = {}) {
+  const smtpPort = preferredSubmissionPort(['SMTP + STARTTLS', 'SMTPS']);
+  const imapPort = preferredMailboxAccessPort(envConfig.imapListeners, ['IMAPS', 'IMAP + STARTTLS', 'IMAP']);
+  const pop3Port = preferredMailboxAccessPort(envConfig.pop3Listeners, ['POP3S', 'POP3 + STLS', 'POP3']);
+  return {
+    username: mailbox.address,
+    password,
+    incoming: {
+      protocol: 'IMAP',
+      host: envConfig.submissionHost,
+      port: imapPort?.port || 143,
+      security: imapPort?.protocol || 'IMAP + STARTTLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    },
+    pop3: {
+      protocol: 'POP3',
+      host: envConfig.submissionHost,
+      port: pop3Port?.port || 110,
+      security: pop3Port?.protocol || 'POP3 + STLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    },
+    outgoing: {
+      protocol: 'SMTP',
+      host: envConfig.submissionHost,
+      port: smtpPort?.port || 587,
+      security: smtpPort?.protocol || 'SMTP + STARTTLS',
+      authMethod: 'Normal password',
+      username: mailbox.address,
+      password
+    }
+  };
+}
+
+function preferredMailboxAccessPort(listeners, protocols) {
+  const tlsEnabled = Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey);
+  const publicListeners = publicMailboxAccessListeners(listeners, { tls: tlsEnabled });
+  for (const protocol of protocols) {
+    const match = publicListeners.find((listener) => listener.protocol === protocol && [993, 995].includes(listener.port)) ||
+      publicListeners.find((listener) => listener.protocol === protocol);
+    if (match) return match;
+  }
+  return publicListeners[0] || null;
+}
+
+function preferredSubmissionPort(protocols) {
+  const listeners = publicSubmissionListeners(envConfig.submissionListeners);
+  for (const protocol of protocols) {
+    const match = listeners.find((listener) => listener.protocol === protocol && listener.port === 587) ||
+      listeners.find((listener) => listener.protocol === protocol && listener.port === 465) ||
+      listeners.find((listener) => listener.protocol === protocol);
+    if (match) return match;
+  }
+  return listeners[0] || null;
+}
+
 function settingsPatchFromBody(body) {
   const patch = {};
   for (const key of [

+ 57 - 11
src/submission.js

@@ -7,8 +7,8 @@ import {
   createTrackingLink,
   finalizeSendEvent,
   getDomainByName,
-  getInboundMailboxByAddress,
   logSendEvent,
+  resolveInboundRecipient,
   verifySmtpCredential
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
@@ -220,6 +220,7 @@ class SubmissionSession {
     this.mailFromAccepted = false;
     this.recipients = [];
     this.inboundMailboxes = [];
+    this.inboundRoutes = [];
     this.dataBytes = 0;
     this.dataTooLarge = false;
     this.remoteAddress = socket.remoteAddress || '';
@@ -382,6 +383,7 @@ class SubmissionSession {
     this.mailFromAccepted = true;
     this.recipients = [];
     this.inboundMailboxes = [];
+    this.inboundRoutes = [];
     return this.write(250, 'Sender OK');
   }
 
@@ -392,9 +394,10 @@ class SubmissionSession {
     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);
+      const route = resolveInboundRecipient(address);
+      if (!route) return this.write(550, 'Recipient is not a local MailHub mailbox');
+      if (route.mailbox) this.inboundMailboxes.push(route.mailbox);
+      this.inboundRoutes.push(route);
       this.recipients.push(address);
       return this.write(250, 'Recipient OK');
     }
@@ -537,15 +540,39 @@ 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');
+    if (!this.inboundRoutes.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
-        });
+      let storedCount = 0;
+      let forwardedCount = 0;
+      const forwardErrors = [];
+
+      for (const route of this.inboundRoutes) {
+        if (route.drop) continue;
+        const forwardTo = route.forwardTo || [];
+        const shouldStore = route.mailbox && (route.keepForwarded || !forwardTo.length);
+        if (shouldStore) {
+          createInboundMessage(route.mailbox, {
+            ...parsedMessage,
+            recipients: [route.recipient],
+            sender: parsedMessage.sender || this.mailFrom
+          });
+          storedCount += 1;
+        }
+        if (forwardTo.length) {
+          try {
+            await this.forwardInboundMessage(rawMessage, parsedMessage, route, forwardTo);
+            forwardedCount += forwardTo.length;
+          } catch (error) {
+            forwardErrors.push({ route, error });
+            console.warn(`Inbound forward failed for ${route.recipient}: ${error.message}`);
+          }
+        }
+      }
+
+      const hasForwardOnlyFailure = forwardErrors.some(({ route }) => !route.mailbox || !route.keepForwarded);
+      if (hasForwardOnlyFailure || (!storedCount && !forwardedCount && forwardErrors.length)) {
+        return this.write(451, 'Temporary local delivery error');
       }
       this.resetEnvelope(false);
       return this.write(250, 'Message accepted');
@@ -555,11 +582,30 @@ class SubmissionSession {
     }
   }
 
+  async forwardInboundMessage(rawMessage, parsedMessage, route, recipients) {
+    const sender = extractAddress(this.mailFrom) ||
+      extractAddress(parsedMessage.sender) ||
+      route.mailbox?.address ||
+      `postmaster@${route.mailbox?.domain || this.config.hostname}`;
+    await sendViaSmtp({
+      host: this.config.relayHost,
+      port: this.config.relayPort,
+      secure: this.config.relaySecure,
+      username: this.config.relayUsername,
+      password: this.config.relayPassword,
+      helo: this.config.relayHelo,
+      mailFrom: sender,
+      recipients,
+      rawMessage
+    });
+  }
+
   resetEnvelope(reply = true) {
     this.mailFrom = '';
     this.mailFromAccepted = false;
     this.recipients = [];
     this.inboundMailboxes = [];
+    this.inboundRoutes = [];
     this.dataMode = false;
     this.dataLines = [];
     this.dataBytes = 0;

+ 53 - 2
test/inbound-db.test.js

@@ -14,7 +14,10 @@ import {
   initDatabase,
   listInboundMailboxes,
   listInboundMessages,
-  markInboundMessageRead
+  markInboundMessageRead,
+  resolveInboundRecipient,
+  updateDomain,
+  verifySmtpCredential
 } from '../src/db.js';
 
 test('users can create inbound mailboxes and read received messages', () => {
@@ -35,16 +38,29 @@ test('users can create inbound mailboxes and read received messages', () => {
 
   const mailbox = createInboundMailbox(user.id, {
     address: 'Support@Inbound.Example',
-    displayName: 'Support'
+    displayName: 'Support',
+    password: 'mailbox-pass-123',
+    aliases: 'help desk',
+    forwardTo: 'archive@example.net, ops@example.net',
+    keepForwarded: false,
+    quotaMb: 512
   });
   assert.equal(mailbox.address, 'support@inbound.example');
   assert.equal(mailbox.displayName, 'Support');
+  assert.equal(mailbox.passwordSet, true);
+  assert.deepEqual(mailbox.aliases, ['help', 'desk']);
+  assert.deepEqual(mailbox.forwardTo, ['archive@example.net', 'ops@example.net']);
+  assert.equal(mailbox.keepForwarded, false);
+  assert.equal(mailbox.quotaMb, 512);
   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);
+  assert.equal(verifySmtpCredential('support@inbound.example', 'mailbox-pass-123').user.id, user.id);
+  assert.equal(verifySmtpCredential('support@inbound.example', 'wrong-password'), null);
+  assert.equal(resolveInboundRecipient('help@inbound.example').mailbox.id, mailbox.id);
 
   const message = createInboundMessage(resolved, {
     sender: 'alice@example.net',
@@ -79,6 +95,41 @@ test('users can create inbound mailboxes and read received messages', () => {
   assert.equal(markInboundMessageRead(999999, message.id, true), null);
 });
 
+test('domains can route unknown inbound recipients to catch-all targets', () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-catchall-')), 'inbound-secret');
+  const user = createUser({ username: 'catch-user', email: 'catch@example.com', password: 'password123' });
+  const domain = createDomain(user.id, {
+    domain: 'catch.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.catch.example',
+    sendingIp: '192.0.2.20',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, { address: 'share@catch.example' });
+
+  const updated = updateDomain(domain.id, user.id, { catchAllAddress: 'share@catch.example' });
+  assert.equal(updated.catchAllAddress, 'share@catch.example');
+  const localRoute = resolveInboundRecipient('missing@catch.example');
+  assert.equal(localRoute.catchAll, true);
+  assert.equal(localRoute.mailbox.id, mailbox.id);
+  assert.equal(localRoute.recipient, 'missing@catch.example');
+
+  updateDomain(domain.id, user.id, { catchAllAddress: '/dev/null' });
+  const dropRoute = resolveInboundRecipient('drop@catch.example');
+  assert.equal(dropRoute.drop, true);
+  assert.equal(dropRoute.mailbox, null);
+
+  updateDomain(domain.id, user.id, { catchAllAddress: 'external@example.net' });
+  const forwardRoute = resolveInboundRecipient('forward@catch.example');
+  assert.deepEqual(forwardRoute.forwardTo, ['external@example.net']);
+  assert.equal(forwardRoute.mailbox, 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' });

+ 188 - 0
test/mail-access.test.js

@@ -0,0 +1,188 @@
+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,
+  createInboundMessage,
+  createUser,
+  initDatabase,
+  listInboundMessages
+} from '../src/db.js';
+import { startMailboxAccessServers } from '../src/mail-access.js';
+
+test('IMAP clients can log in and fetch mailbox messages', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
+  const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
+  createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: ['admin@imap.example'],
+    subject: 'IMAP hello',
+    messageId: '<imap-hello@example.net>',
+    rawMessage: [
+      'From: Alice <alice@example.net>',
+      'To: admin@imap.example',
+      'Subject: IMAP hello',
+      'Message-ID: <imap-hello@example.net>',
+      '',
+      'Hello through IMAP.'
+    ].join('\r\n'),
+    textBody: 'Hello through IMAP.'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.imap.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: false,
+    pop3Listeners: [],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  try {
+    const port = server.address().port;
+    const client = await connectClient(port);
+    await client.readUntil(/\* OK .* IMAP ready\r\n/);
+    assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
+    const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
+    assert.match(selected, /\* 1 EXISTS/);
+    const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
+    assert.match(fetched, /\* 1 FETCH/);
+    assert.match(fetched, /UID 1/);
+    assert.match(fetched, /Subject: IMAP hello/);
+    assert.match(fetched, /Hello through IMAP\./);
+    await client.command('A4 LOGOUT', /A4 OK/);
+    client.close();
+    assert.equal(listInboundMessages(user.id).length, 1);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('POP3 clients can retrieve and delete messages on quit', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
+  const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
+  createInboundMessage(mailbox, {
+    sender: 'bob@example.net',
+    recipients: ['admin@pop3.example'],
+    subject: 'POP3 hello',
+    messageId: '<pop3-hello@example.net>',
+    rawMessage: [
+      'From: Bob <bob@example.net>',
+      'To: admin@pop3.example',
+      'Subject: POP3 hello',
+      'Message-ID: <pop3-hello@example.net>',
+      '',
+      'Hello through POP3.'
+    ].join('\r\n'),
+    textBody: 'Hello through POP3.'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.pop3.example',
+    imapEnabled: false,
+    imapListeners: [],
+    pop3Enabled: true,
+    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  try {
+    const client = await connectClient(server.address().port);
+    await client.readUntil(/\+OK .* POP3 ready\r\n/);
+    assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
+    assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
+    assert.match(await client.command('STAT', /\+OK \d+ \d+/), /\+OK 1 /);
+    assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
+    const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
+    assert.match(retrieved, /Subject: POP3 hello/);
+    assert.match(retrieved, /Hello through POP3\./);
+    assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
+    await client.command('QUIT', /\+OK Bye/);
+    client.close();
+    assert.equal(listInboundMessages(user.id).length, 0);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+function createMailboxFixture(domainName, username) {
+  const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
+  createDomain(user.id, {
+    domain: domainName,
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domainName}`,
+    sendingIp: '192.0.2.30',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: `admin@${domainName}`,
+    password: 'mailbox-pass-123'
+  });
+  return { user, mailbox };
+}
+
+function connectClient(port) {
+  return new Promise((resolve, reject) => {
+    const socket = net.createConnection({ host: '127.0.0.1', port });
+    socket.setEncoding('utf8');
+    socket.setTimeout(5000);
+    let buffer = '';
+    const waiters = [];
+
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      for (const waiter of [...waiters]) {
+        if (waiter.pattern.test(buffer)) {
+          waiters.splice(waiters.indexOf(waiter), 1);
+          const output = buffer;
+          buffer = '';
+          waiter.resolve(output);
+        }
+      }
+    });
+    socket.once('connect', () => resolve({
+      command(command, pattern) {
+        socket.write(`${command}\r\n`);
+        return this.readUntil(pattern);
+      },
+      readUntil(pattern) {
+        if (pattern.test(buffer)) {
+          const output = buffer;
+          buffer = '';
+          return Promise.resolve(output);
+        }
+        return new Promise((waitResolve, waitReject) => {
+          waiters.push({ pattern, resolve: waitResolve, reject: waitReject });
+        });
+      },
+      close() {
+        socket.destroy();
+      }
+    }));
+    socket.once('error', reject);
+    socket.once('timeout', () => reject(new Error('Mail access client timed out')));
+  });
+}
+
+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());
+  });
+}

+ 20 - 5
test/server-admin-api.test.js

@@ -16,7 +16,9 @@ test('admin API routes respond once and keep the server alive', async () => {
       PORT: String(port),
       DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
       ADMIN_PASSWORD: 'password123',
-      SUBMISSION_ENABLED: 'false'
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false'
     },
     stdio: ['ignore', 'pipe', 'pipe']
   });
@@ -61,7 +63,9 @@ test('built auth assets are served before authentication', async () => {
       PORT: String(port),
       DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
       ADMIN_PASSWORD: 'password123',
-      SUBMISSION_ENABLED: 'false'
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false'
     },
     stdio: ['ignore', 'pipe', 'pipe']
   });
@@ -161,14 +165,23 @@ test('users can manage inbound mailboxes and read inbound messages', async () =>
       },
       body: JSON.stringify({
         address: 'Support@inbound-api.example',
-        displayName: 'Support'
+        displayName: 'Support',
+        password: 'mailbox-pass-123',
+        forwardTo: 'archive@example.net',
+        keepForwarded: true
       })
     });
     assert.equal(createMailbox.status, 201);
-    const mailbox = (await createMailbox.json()).mailbox;
+    const createMailboxBody = await createMailbox.json();
+    const mailbox = createMailboxBody.mailbox;
     assert.equal(mailbox.address, 'support@inbound-api.example');
     assert.equal(mailbox.displayName, 'Support');
+    assert.equal(mailbox.passwordSet, true);
+    assert.deepEqual(mailbox.forwardTo, ['archive@example.net']);
     assert.equal(mailbox.unreadCount, 0);
+    assert.equal(createMailboxBody.clientConfig.username, 'support@inbound-api.example');
+    assert.equal(createMailboxBody.clientConfig.password, 'mailbox-pass-123');
+    assert.equal(createMailboxBody.clientConfig.outgoing.authMethod, 'Normal password');
 
     const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } });
     assert.equal(mailboxes.status, 200);
@@ -1385,7 +1398,9 @@ async function startTestServer() {
       ADMIN_PASSWORD: 'password123',
       SESSION_SECRET: sessionSecret,
       DNS_AUTO_CHECK_ENABLED: 'false',
-      SUBMISSION_ENABLED: 'false'
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false'
     },
     stdio: ['ignore', 'pipe', 'pipe']
   });

+ 2 - 0
test/server-landing.test.js

@@ -82,6 +82,8 @@ function spawnServer(port) {
       DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-landing-test-')),
       ADMIN_PASSWORD: 'password123',
       SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
       WEBHOOK_WORKER_ENABLED: '0'
     },
     stdio: ['ignore', 'pipe', 'pipe']

+ 4 - 0
test/server-send-tracking.test.js

@@ -25,6 +25,8 @@ test('instruments tracked API HTML before DKIM and supports per-send opt-out', a
       SMTP_HELO: 'mail.track-send.example',
       SEND_REQUIRES_VERIFIED: 'false',
       SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
       WEBHOOK_WORKER_ENABLED: '0',
       DNS_AUTO_CHECK_ENABLED: 'false',
       DELIVERY_TRACKING_ENABLED: 'false'
@@ -118,6 +120,8 @@ test('API sends configured deliverability headers without changing the default e
       SMTP_HELO: 'mail.headers.example',
       SEND_REQUIRES_VERIFIED: 'false',
       SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
       WEBHOOK_WORKER_ENABLED: '0',
       DNS_AUTO_CHECK_ENABLED: 'false',
       DELIVERY_TRACKING_ENABLED: 'false'

+ 2 - 0
test/server-tracking.test.js

@@ -134,6 +134,8 @@ async function trackingServerFixture() {
       TRACKING_SECRET: 'tracking-secret',
       TRUST_PROXY: 'true',
       SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
       WEBHOOK_WORKER_ENABLED: '0',
       DNS_AUTO_CHECK_ENABLED: 'false',
       DELIVERY_TRACKING_ENABLED: 'false'

+ 2 - 0
test/server-webhooks-api.test.js

@@ -412,6 +412,8 @@ async function startTestServer() {
       SESSION_SECRET: sessionSecret,
       DNS_AUTO_CHECK_ENABLED: 'false',
       SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
       WEBHOOK_WORKER_ENABLED: '0',
       WEBHOOK_ALLOW_HTTP_LOCAL: '1',
       DELIVERY_TRACKING_ENABLED: 'false'

+ 198 - 1
test/submission-inbound.test.js

@@ -10,7 +10,8 @@ import {
   createInboundMailbox,
   createUser,
   initDatabase,
-  listInboundMessages
+  listInboundMessages,
+  updateDomain
 } from '../src/db.js';
 import { sendViaSmtp } from '../src/mailer.js';
 import { startSubmissionServer } from '../src/submission.js';
@@ -104,6 +105,152 @@ test('SMTP rejects unauthenticated inbound mail for unknown recipients', async (
   }
 });
 
+test('SMTP authenticates with a mailbox account address and password', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-auth-')), 'inbound-secret');
+  const user = createUser({ username: 'mailbox-auth', email: 'mailbox-auth@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'authmail.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.authmail.example',
+    sendingIp: '192.0.2.16',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, {
+    address: 'admin@authmail.example',
+    password: 'mailbox-pass-123'
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.authmail.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  try {
+    const auth = Buffer.from('\u0000admin@authmail.example\u0000mailbox-pass-123').toString('base64');
+    const transcript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${auth}`
+    ]);
+    assert.match(transcript.at(-1), /^235 /);
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP routes unknown inbound recipients to the domain catch-all mailbox', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-catchall-')), 'inbound-secret');
+  const user = createUser({ username: 'catchall-smtp', email: 'catchall-smtp@example.com', password: 'password123' });
+  const domain = createDomain(user.id, {
+    domain: 'catchall.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.catchall.example',
+    sendingIp: '192.0.2.17',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, { address: 'share@catchall.example' });
+  updateDomain(domain.id, user.id, { catchAllAddress: 'share@catchall.example' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.catchall.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: 'alice@example.net',
+      recipients: ['missing@catchall.example'],
+      rawMessage: 'From: alice@example.net\r\nSubject: Catch all\r\n\r\nHello catch-all.'
+    });
+
+    const [message] = listInboundMessages(user.id);
+    assert.equal(message.mailboxAddress, 'share@catchall.example');
+    assert.deepEqual(message.recipients, ['missing@catchall.example']);
+    assert.equal(message.subject, 'Catch all');
+  } finally {
+    await closeServer(server);
+  }
+});
+
+test('SMTP forwards inbound messages without storing them when keepForwarded is false', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-forward-')), 'inbound-secret');
+  const relay = await startFakeSmtpServer();
+  const user = createUser({ username: 'forward-smtp', email: 'forward-smtp@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'forward.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.forward.example',
+    sendingIp: '192.0.2.18',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(user.id, {
+    address: 'ops@forward.example',
+    forwardTo: 'archive@example.net',
+    keepForwarded: false
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.forward.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    relayHost: '127.0.0.1',
+    relayPort: relay.port,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mx.forward.example'
+  });
+  await waitForListening(server);
+
+  try {
+    await sendViaSmtp({
+      host: '127.0.0.1',
+      port: server.address().port,
+      secure: false,
+      username: '',
+      password: '',
+      helo: 'sender.example.net',
+      mailFrom: 'alice@example.net',
+      recipients: ['ops@forward.example'],
+      rawMessage: 'From: alice@example.net\r\nSubject: Forward only\r\n\r\nForward this.'
+    });
+
+    assert.equal(listInboundMessages(user.id).length, 0);
+    assert.ok(relay.commands.includes('RCPT TO:<archive@example.net>'));
+    assert.match(relay.messages[0], /Subject: Forward only/);
+  } finally {
+    await closeServer(server);
+    await relay.close();
+  }
+});
+
 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' });
@@ -306,3 +453,53 @@ async function smtpTranscript(port, commands) {
     socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
   });
 }
+
+function startFakeSmtpServer() {
+  const commands = [];
+  const messages = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+    let messageLines = [];
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            messages.push(messageLines.join('\n'));
+            messageLines = [];
+            socket.write('250 2.0.0 queued as FORWARD123\r\n');
+          } else {
+            messageLines.push(line);
+          }
+          continue;
+        }
+        commands.push(line);
+        if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
+        else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
+        else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end with dot\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => resolve({
+      port: server.address().port,
+      commands,
+      messages,
+      close: () => closeServer(server)
+    }));
+  });
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików