Explorar el Código

feat: add mailbox receipt webhooks

AI-Co-Authored-By: Codex
chendeben hace 1 mes
padre
commit
2019f7fa35

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 1
public/assets/index-BJZnYPOR.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
public/assets/login-qcNsJrb8.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
public/assets/styles-Dz22uXjV.js


+ 2 - 2
public/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-C9xTFghB.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CroXClmW.js">
+    <script type="module" crossorigin src="/assets/index-BJZnYPOR.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-Dz22uXjV.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">

+ 2 - 2
public/login.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-DDwQSt-8.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CroXClmW.js">
+    <script type="module" crossorigin src="/assets/login-qcNsJrb8.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-Dz22uXjV.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
   </head>

+ 236 - 24
src/db.js

@@ -263,6 +263,7 @@ export function initDatabase(dataDir, secret = '') {
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
       domain_id INTEGER,
+      mailbox_id INTEGER,
       name TEXT NOT NULL,
       url TEXT NOT NULL,
       secret_ciphertext TEXT NOT NULL,
@@ -279,6 +280,7 @@ export function initDatabase(dataDir, secret = '') {
       webhook_id INTEGER NOT NULL,
       user_id INTEGER NOT NULL,
       send_event_id INTEGER NOT NULL,
+      inbound_message_id INTEGER,
       event_type TEXT NOT NULL,
       payload_json TEXT NOT NULL,
       status TEXT NOT NULL,
@@ -289,8 +291,7 @@ export function initDatabase(dataDir, secret = '') {
       response_body_preview TEXT NOT NULL DEFAULT '',
       error TEXT NOT NULL DEFAULT '',
       created_at TEXT NOT NULL,
-      FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE,
-      UNIQUE(webhook_id, send_event_id, event_type)
+      FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
     );
 
     CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
@@ -339,6 +340,8 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
   ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
   ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
+  ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
+  migrateWebhookDeliveriesForInbound();
   ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
   ensureColumn('api_tokens', 'expires_at', 'TEXT');
   ensureColumn('api_tokens', 'revoked_at', 'TEXT');
@@ -358,6 +361,15 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
+    CREATE INDEX IF NOT EXISTS idx_webhooks_user_mailbox ON webhooks(user_id, mailbox_id);
+    CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
+    CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_user_created ON webhook_deliveries(user_id, created_at);
+    CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_deliveries_send_event_unique
+      ON webhook_deliveries(webhook_id, send_event_id, event_type)
+      WHERE inbound_message_id IS NULL;
+    CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_deliveries_inbound_message_unique
+      ON webhook_deliveries(webhook_id, inbound_message_id, event_type)
+      WHERE inbound_message_id IS NOT NULL;
   `);
   normalizeSendEventQueueIds();
   normalizeDkimPublicKeys();
@@ -1773,20 +1785,27 @@ export function pruneTrackingEvents({ days = 180, now: currentTime } = {}) {
   return Number(requireDb().prepare('DELETE FROM tracking_events WHERE occurred_at < ?').run(cutoff.toISOString()).changes || 0);
 }
 
-export function listWebhooks(userId, { domainId } = {}) {
+export function listWebhooks(userId, { domainId, mailboxId } = {}) {
+  if (domainId !== undefined && mailboxId !== undefined) {
+    throw new Error('Webhook 不能同时绑定域名和收信邮箱。');
+  }
   const params = [userId];
-  let domainClause = '';
-  if (domainId === null) {
-    domainClause = ' AND domain_id IS NULL';
+  let scopeClause = '';
+  if (mailboxId !== undefined) {
+    const resolvedMailboxId = normalizeWebhookMailboxId(userId, mailboxId);
+    scopeClause = ' AND mailbox_id = ?';
+    params.push(resolvedMailboxId);
+  } else if (domainId === null) {
+    scopeClause = ' AND domain_id IS NULL AND mailbox_id IS NULL';
   } else if (domainId !== undefined) {
-    domainClause = ' AND domain_id = ?';
+    scopeClause = ' AND domain_id = ? AND mailbox_id IS NULL';
     params.push(domainId);
   }
   return requireDb()
     .prepare(`
       SELECT *
       FROM webhooks
-      WHERE user_id = ?${domainClause}
+      WHERE user_id = ?${scopeClause}
       ORDER BY created_at DESC, id DESC
     `)
     .all(...params)
@@ -1798,25 +1817,27 @@ export function getWebhook(id, userId) {
   return publicWebhook(row);
 }
 
-export function createWebhook(userId, { name, url, events, domainId = null, enabled = true } = {}) {
+export function createWebhook(userId, { name, url, events, domainId = null, mailboxId = null, enabled = true } = {}) {
   const cleanName = String(name || '').trim();
   const cleanUrl = String(url || '').trim();
   if (!cleanName) throw new Error('Webhook 名称不能为空。');
   if (!cleanUrl) throw new Error('Webhook URL 不能为空。');
   const normalizedEvents = normalizeWebhookEvents(events);
-  const resolvedDomainId = normalizeWebhookDomainId(userId, domainId);
+  const scope = normalizeWebhookScope(userId, { domainId, mailboxId });
+  assertWebhookEventsMatchScope(normalizedEvents, scope);
   const secret = generateWebhookSecret();
   const createdAt = now();
   const result = requireDb()
     .prepare(`
       INSERT INTO webhooks (
-        user_id, domain_id, name, url, secret_ciphertext, secret_prefix,
+        user_id, domain_id, mailbox_id, name, url, secret_ciphertext, secret_prefix,
         events_json, enabled, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       userId,
-      resolvedDomainId,
+      scope.domainId,
+      scope.mailboxId,
       cleanName,
       cleanUrl,
       encryptSecret(secret),
@@ -1842,21 +1863,101 @@ export function updateWebhook(userId, id, patch = {}) {
   const nextEvents = patch.events !== undefined
     ? normalizeWebhookEvents(patch.events)
     : parseWebhookEventsJson(current.events_json);
-  const nextDomainId = patch.domainId !== undefined
-    ? normalizeWebhookDomainId(userId, patch.domainId)
-    : current.domain_id;
+  const scope = normalizeWebhookScope(userId, {
+    domainId: patch.domainId === undefined ? current.domain_id : patch.domainId,
+    mailboxId: patch.mailboxId === undefined ? current.mailbox_id : patch.mailboxId
+  });
+  assertWebhookEventsMatchScope(nextEvents, scope);
   const nextEnabled = patch.enabled !== undefined ? boolString(patch.enabled) : current.enabled;
   const updatedAt = now();
   requireDb()
     .prepare(`
       UPDATE webhooks
-      SET domain_id = ?, name = ?, url = ?, events_json = ?, enabled = ?, updated_at = ?
+      SET domain_id = ?, mailbox_id = ?, name = ?, url = ?, events_json = ?, enabled = ?, updated_at = ?
       WHERE id = ? AND user_id = ?
     `)
-    .run(nextDomainId, nextName, nextUrl, JSON.stringify(nextEvents), nextEnabled, updatedAt, id, userId);
+    .run(
+      scope.domainId,
+      scope.mailboxId,
+      nextName,
+      nextUrl,
+      JSON.stringify(nextEvents),
+      nextEnabled,
+      updatedAt,
+      id,
+      userId
+    );
   return getWebhook(id, userId);
 }
 
+/**
+ * Enqueue a mailbox-scoped callback after an inbound message has been stored.
+ * Inbound webhooks deliberately do not participate in account/domain send-event fallback.
+ */
+export function enqueueInboundWebhookDeliveries(inboundMessage, options = {}) {
+  if (!inboundMessage?.id || !inboundMessage?.userId || !inboundMessage?.mailboxId) return [];
+  const targets = listWebhookRowsForMailbox(inboundMessage.userId, inboundMessage.mailboxId)
+    .filter((webhook) => webhook.enabled && webhook.events.includes('received'));
+  const database = options.database || requireDb();
+  const manageTransactions = options.manageTransactions !== false;
+  const created = [];
+
+  for (const webhook of targets) {
+    if (manageTransactions) database.exec('BEGIN');
+    try {
+      const existing = database
+        .prepare(`
+          SELECT id FROM webhook_deliveries
+          WHERE webhook_id = ? AND inbound_message_id = ? AND event_type = 'received'
+        `)
+        .get(webhook.id, inboundMessage.id);
+      if (existing) {
+        if (manageTransactions) database.exec('COMMIT');
+        continue;
+      }
+
+      const createdAt = now();
+      const insert = database
+        .prepare(`
+          INSERT INTO webhook_deliveries (
+            webhook_id, user_id, send_event_id, inbound_message_id, event_type, payload_json, status,
+            attempt_count, next_attempt_at, last_attempt_at, response_status,
+            response_body_preview, error, created_at
+          ) VALUES (?, ?, 0, ?, 'received', '{}', 'pending', 0, ?, NULL, NULL, '', '', ?)
+        `)
+        .run(webhook.id, inboundMessage.userId, inboundMessage.id, createdAt, createdAt);
+      if (insert.changes !== 1) {
+        if (manageTransactions) database.exec('ROLLBACK');
+        continue;
+      }
+      const deliveryId = insert.lastInsertRowid;
+      const payload = buildWebhookPayload({
+        deliveryId,
+        eventType: 'email.received',
+        createdAt,
+        inboundMessage
+      });
+      database
+        .prepare('UPDATE webhook_deliveries SET payload_json = ? WHERE id = ?')
+        .run(JSON.stringify(payload), deliveryId);
+      if (manageTransactions) database.exec('COMMIT');
+      created.push(publicWebhookDelivery(
+        database.prepare('SELECT * FROM webhook_deliveries WHERE id = ?').get(deliveryId)
+      ));
+    } catch (error) {
+      if (manageTransactions) {
+        try {
+          database.exec('ROLLBACK');
+        } catch {
+          // ignore rollback errors when no transaction is open
+        }
+      }
+      throw error;
+    }
+  }
+  return created;
+}
+
 export function rotateWebhookSecret(userId, id) {
   const current = getWebhookRow(id, userId);
   if (!current) return null;
@@ -2196,6 +2297,7 @@ export function enqueueWebhookTestDelivery(userId, webhookId) {
   const events = parseWebhookEventsJson(webhook.events_json);
   const eventType = events[0] || 'sent';
   const externalType = eventTypeForStatus(eventType) || 'email.sent';
+  const mailbox = webhook.mailbox_id == null ? null : getInboundMailbox(webhook.mailbox_id, userId);
   const firstDomain = requireDb()
     .prepare('SELECT domain FROM domains WHERE user_id = ? ORDER BY created_at ASC, id ASC LIMIT 1')
     .get(userId);
@@ -2211,6 +2313,19 @@ export function enqueueWebhookTestDelivery(userId, webhookId) {
     detail: 'test delivery',
     deliveredAt: null
   };
+  const inboundMessage = mailbox ? {
+    id: 0,
+    mailboxId: mailbox.id,
+    mailboxAddress: mailbox.address,
+    domain: mailbox.domain,
+    sender: 'sender@example.com',
+    recipients: [mailbox.address],
+    subject: 'MailHub inbound webhook test',
+    messageId: '<mailhub-inbound-test@example.com>',
+    textBody: 'MailHub inbound webhook test',
+    htmlBody: '<p>MailHub inbound webhook test</p>',
+    receivedAt: null
+  } : null;
 
   const database = requireDb();
   database.exec('BEGIN');
@@ -2218,7 +2333,9 @@ export function enqueueWebhookTestDelivery(userId, webhookId) {
     const existing = database
       .prepare(`
         SELECT * FROM webhook_deliveries
-        WHERE webhook_id = ? AND send_event_id = 0 AND event_type = ?
+        WHERE webhook_id = ?
+          AND event_type = ?
+          AND ${mailbox ? 'inbound_message_id = 0' : 'inbound_message_id IS NULL AND send_event_id = 0'}
       `)
       .get(webhook.id, eventType);
 
@@ -2251,12 +2368,12 @@ export function enqueueWebhookTestDelivery(userId, webhookId) {
     const insert = database
       .prepare(`
         INSERT INTO webhook_deliveries (
-          webhook_id, user_id, send_event_id, event_type, payload_json, status,
+          webhook_id, user_id, send_event_id, inbound_message_id, event_type, payload_json, status,
           attempt_count, next_attempt_at, last_attempt_at, response_status,
           response_body_preview, error, created_at
-        ) VALUES (?, ?, 0, ?, '{}', 'pending', 0, ?, NULL, NULL, '', '', ?)
+        ) VALUES (?, ?, 0, ?, ?, '{}', 'pending', 0, ?, NULL, NULL, '', '', ?)
       `)
-      .run(webhook.id, userId, eventType, createdAt, createdAt);
+      .run(webhook.id, userId, mailbox ? 0 : null, eventType, createdAt, createdAt);
     if (insert.changes !== 1) {
       database.exec('ROLLBACK');
       return null;
@@ -2267,6 +2384,7 @@ export function enqueueWebhookTestDelivery(userId, webhookId) {
       eventType: externalType,
       createdAt,
       sendEvent,
+      inboundMessage,
       test: true
     });
     database
@@ -3289,6 +3407,54 @@ function migrateSmtpCredentialsToMultiplePerUser() {
   `);
 }
 
+function migrateWebhookDeliveriesForInbound() {
+  if (!tableExists('webhook_deliveries') || columnExists('webhook_deliveries', 'inbound_message_id')) return;
+  const database = requireDb();
+  database.exec('BEGIN');
+  try {
+    database.exec(`
+      ALTER TABLE webhook_deliveries RENAME TO webhook_deliveries_legacy;
+      CREATE TABLE webhook_deliveries (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        webhook_id INTEGER NOT NULL,
+        user_id INTEGER NOT NULL,
+        send_event_id INTEGER NOT NULL,
+        inbound_message_id INTEGER,
+        event_type TEXT NOT NULL,
+        payload_json TEXT NOT NULL,
+        status TEXT NOT NULL,
+        attempt_count INTEGER NOT NULL DEFAULT 0,
+        next_attempt_at TEXT NOT NULL,
+        last_attempt_at TEXT,
+        response_status INTEGER,
+        response_body_preview TEXT NOT NULL DEFAULT '',
+        error TEXT NOT NULL DEFAULT '',
+        created_at TEXT NOT NULL,
+        FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
+      );
+      INSERT INTO webhook_deliveries (
+        id, webhook_id, user_id, send_event_id, inbound_message_id, event_type, payload_json, status,
+        attempt_count, next_attempt_at, last_attempt_at, response_status,
+        response_body_preview, error, created_at
+      )
+      SELECT
+        id, webhook_id, user_id, send_event_id, NULL, event_type, payload_json, status,
+        attempt_count, next_attempt_at, last_attempt_at, response_status,
+        response_body_preview, error, created_at
+      FROM webhook_deliveries_legacy;
+      DROP TABLE webhook_deliveries_legacy;
+    `);
+    database.exec('COMMIT');
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
 function smtpCredentialsHasUserIdUniqueConstraint() {
   const row = requireDb()
     .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'smtp_credentials'")
@@ -3740,6 +3906,7 @@ function publicWebhook(row) {
     id: row.id,
     userId: row.user_id,
     domainId: row.domain_id ?? null,
+    mailboxId: row.mailbox_id ?? null,
     name: row.name,
     url: row.url,
     secretPrefix: row.secret_prefix,
@@ -3757,6 +3924,7 @@ function publicWebhookDelivery(row) {
     webhookId: row.webhook_id,
     userId: row.user_id,
     sendEventId: row.send_event_id,
+    inboundMessageId: row.inbound_message_id ?? null,
     eventType: row.event_type,
     payloadJson: row.payload_json,
     status: row.status,
@@ -3773,10 +3941,10 @@ function publicWebhookDelivery(row) {
 function listWebhookRowsForResolve(userId, domainId) {
   const rows = domainId == null
     ? requireDb()
-      .prepare('SELECT * FROM webhooks WHERE user_id = ? AND domain_id IS NULL')
+      .prepare('SELECT * FROM webhooks WHERE user_id = ? AND domain_id IS NULL AND mailbox_id IS NULL')
       .all(userId)
     : requireDb()
-      .prepare('SELECT * FROM webhooks WHERE user_id = ? AND domain_id = ?')
+      .prepare('SELECT * FROM webhooks WHERE user_id = ? AND domain_id = ? AND mailbox_id IS NULL')
       .all(userId, domainId);
   return rows.map((row) => ({
     id: row.id,
@@ -3786,6 +3954,50 @@ function listWebhookRowsForResolve(userId, domainId) {
   }));
 }
 
+function listWebhookRowsForMailbox(userId, mailboxId) {
+  return requireDb()
+    .prepare('SELECT * FROM webhooks WHERE user_id = ? AND mailbox_id = ?')
+    .all(userId, mailboxId)
+    .map((row) => ({
+      id: row.id,
+      mailboxId: row.mailbox_id,
+      enabled: row.enabled === 'true' || row.enabled === true || row.enabled === 1,
+      events: parseWebhookEventsJson(row.events_json)
+    }));
+}
+
+function normalizeWebhookScope(userId, { domainId, mailboxId }) {
+  const hasDomain = domainId != null && domainId !== '';
+  const hasMailbox = mailboxId != null && mailboxId !== '';
+  if (hasDomain && hasMailbox) throw new Error('Webhook 不能同时绑定域名和收信邮箱。');
+  return {
+    domainId: hasDomain ? normalizeWebhookDomainId(userId, domainId) : null,
+    mailboxId: hasMailbox ? normalizeWebhookMailboxId(userId, mailboxId) : null
+  };
+}
+
+function normalizeWebhookMailboxId(userId, mailboxId) {
+  const id = Number(mailboxId);
+  if (!Number.isInteger(id) || id <= 0) throw new Error('收信邮箱不存在。');
+  const mailbox = requireDb()
+    .prepare('SELECT id FROM inbound_mailboxes WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
+    .get(id, userId);
+  if (!mailbox) throw new Error('收信邮箱不存在。');
+  return id;
+}
+
+function assertWebhookEventsMatchScope(events, scope) {
+  if (scope.mailboxId != null) {
+    if (events.length !== 1 || events[0] !== 'received') {
+      throw new Error('邮箱 Webhook 仅支持 received 事件。');
+    }
+    return;
+  }
+  if (events.includes('received')) {
+    throw new Error('received 事件只能绑定到收信邮箱。');
+  }
+}
+
 function normalizeWebhookDomainId(userId, domainId) {
   if (domainId == null || domainId === '') return null;
   const id = Number(domainId);

+ 1 - 1
src/frontend/App.tsx

@@ -677,7 +677,7 @@ function MailHubConsole() {
       return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} onLoadEvent={loadSendEvent} />;
     }
     if (activeView === 'webhooks') {
-      return <Webhooks domains={data.domains} onCopy={copy} />;
+      return <Webhooks domains={data.domains} mailboxes={data.inboundMailboxes} onCopy={copy} />;
     }
     if (activeView === 'admin') {
       return <AdminPage me={data.me} />;

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

@@ -267,6 +267,7 @@ const messages = {
     'inbox.keepForwarded': '保留已转发的邮件',
     'inbox.forwardOnly': '仅转发',
     'inbox.clientConfig': '客户端配置',
+    'inbox.mailboxWebhooks': '邮件到达 Webhook',
     'inbox.clientConfigHelpSummary': '推荐优先配置 IMAP 收信;POP3 仅在需要下载到本地时使用。密码只在创建或重置时显示。',
     'inbox.configUsername': '用户名',
     'inbox.configPassword': '密码',
@@ -476,14 +477,17 @@ const messages = {
     'webhooks.eventFailed': '失败',
     'webhooks.eventOpened': '已打开',
     'webhooks.eventClicked': '已点击',
+    'webhooks.eventReceived': '邮件到达',
     'webhooks.enabled': '启用',
     'webhooks.disabled': '已停用',
     'webhooks.scope': '作用域',
     'webhooks.scopeAccount': '账号级',
     'webhooks.scopeDomain': '域名级',
+    'webhooks.scopeMailbox': '收信邮箱',
     'webhooks.domain': '域名',
     'webhooks.domainAccount': '整个账号',
     'webhooks.domainOverrideHelp': '若某域名对某一事件存在任何已启用的端点,则该事件不会再投递到账号级端点。',
+    'webhooks.mailboxReceiptHelp': '仅在邮件成功保存到该邮箱后投递 email.received;仅转发而未保留的邮件不会触发。',
     'webhooks.secret': '签名密钥',
     'webhooks.secretPrefix': '密钥前缀',
     'webhooks.secretCreatedTitle': 'Webhook 密钥已创建',
@@ -521,7 +525,7 @@ const messages = {
     'webhooks.viewDeliveries': '查看投递',
     'webhooks.docsTitle': '回调说明',
     'webhooks.docsSignature': '使用 HMAC-SHA256 校验请求体;签名放在 X-MailHub-Signature。',
-    'webhooks.docsEvents': '事件类型:sent、bounced、failed(终端状态)。',
+    'webhooks.docsEvents': '事件类型:sent、bounced、failed、opened、clicked;收信邮箱可额外订阅 received。',
     'testMail.title': '发送测试邮件',
     'testMail.fromRequired': '请输入发件人',
     'testMail.toRequired': '请输入收件人',
@@ -844,6 +848,7 @@ const messages = {
     'inbox.keepForwarded': 'Keep forwarded mail',
     'inbox.forwardOnly': 'Forward only',
     'inbox.clientConfig': 'Client configuration',
+    'inbox.mailboxWebhooks': 'Mail arrival webhook',
     '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',
@@ -1039,14 +1044,17 @@ const messages = {
     'webhooks.eventFailed': 'Failed',
     'webhooks.eventOpened': 'Opened',
     'webhooks.eventClicked': 'Clicked',
+    'webhooks.eventReceived': 'Received',
     'webhooks.enabled': 'Enabled',
     'webhooks.disabled': 'Disabled',
     'webhooks.scope': 'Scope',
     'webhooks.scopeAccount': 'Account',
     'webhooks.scopeDomain': 'Domain',
+    'webhooks.scopeMailbox': 'Mailbox',
     'webhooks.domain': 'Domain',
     'webhooks.domainAccount': 'Entire account',
     'webhooks.domainOverrideHelp': 'If this domain has any enabled endpoint for an event, account-level endpoints for that event are skipped.',
+    'webhooks.mailboxReceiptHelp': 'email.received is delivered only after mail is stored in this mailbox. Forward-only mail does not trigger it.',
     'webhooks.secret': 'Signing secret',
     'webhooks.secretPrefix': 'Secret prefix',
     'webhooks.secretCreatedTitle': 'Webhook secret created',
@@ -1084,7 +1092,7 @@ const messages = {
     'webhooks.viewDeliveries': 'View deliveries',
     'webhooks.docsTitle': 'Callback notes',
     'webhooks.docsSignature': 'Verify the body with HMAC-SHA256; the signature is in X-MailHub-Signature.',
-    'webhooks.docsEvents': 'Event types: sent, bounced, and failed (terminal statuses).',
+    'webhooks.docsEvents': 'Event types: sent, bounced, failed, opened, and clicked. Mailboxes can additionally subscribe to received.',
     'testMail.title': 'Send test email',
     'testMail.fromRequired': 'Enter the sender',
     'testMail.toRequired': 'Enter the recipient',

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

@@ -247,10 +247,11 @@ export const api = {
   resetPassword: (token: string, password: string) =>
     request<{ message: string }>('/api/auth/reset-password', { method: 'POST', data: { token, password } }),
   logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' }),
-  webhooks: (domainId?: number | null) => {
+  webhooks: (domainId?: number | null, mailboxId?: number) => {
     const params = new URLSearchParams();
     if (domainId === null) params.set('domainId', 'null');
     else if (domainId !== undefined) params.set('domainId', String(domainId));
+    if (mailboxId !== undefined) params.set('mailboxId', String(mailboxId));
     const query = params.toString();
     return request<{ webhooks: Webhook[] }>(`/api/webhooks${query ? `?${query}` : ''}`);
   },

+ 5 - 1
src/frontend/types.ts

@@ -613,13 +613,14 @@ export interface DomainPatchPayload {
   catchAllAddress?: string;
 }
 
-export type WebhookEvent = 'sent' | 'bounced' | 'failed' | 'opened' | 'clicked';
+export type WebhookEvent = 'sent' | 'bounced' | 'failed' | 'opened' | 'clicked' | 'received';
 export type WebhookDeliveryStatus = 'pending' | 'processing' | 'success' | 'dead';
 
 export interface Webhook {
   id: number;
   userId: number;
   domainId: number | null;
+  mailboxId: number | null;
   name: string;
   url: string;
   secretPrefix: string;
@@ -636,6 +637,7 @@ export interface WebhookPayload {
   url: string;
   events: WebhookEvent[];
   domainId?: number | string | null;
+  mailboxId?: number | string | null;
   enabled?: boolean;
 }
 
@@ -644,6 +646,7 @@ export interface WebhookPatchPayload {
   url?: string;
   events?: WebhookEvent[];
   domainId?: number | string | null;
+  mailboxId?: number | string | null;
   enabled?: boolean;
 }
 
@@ -652,6 +655,7 @@ export interface WebhookDelivery {
   webhookId: number;
   userId: number;
   sendEventId: number;
+  inboundMessageId?: number | null;
   eventType: WebhookEvent | string;
   payloadJson?: string;
   status: WebhookDeliveryStatus | string;

+ 30 - 5
src/pages/Inbox.tsx

@@ -5,7 +5,8 @@ import {
   PlusOutlined,
   ReloadOutlined,
   SearchOutlined,
-  SettingOutlined
+  SettingOutlined,
+  ThunderboltOutlined
 } from '@ant-design/icons';
 import {
   Alert,
@@ -35,6 +36,7 @@ import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
 import type { Domain, DomainPatchPayload, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
+import Webhooks from './Webhooks';
 
 interface InboxProps {
   config: RuntimeConfig | null;
@@ -92,6 +94,7 @@ export default function Inbox({
   const [mailboxOpen, setMailboxOpen] = useState(false);
   const [mailboxLoading, setMailboxLoading] = useState(false);
   const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
+  const [webhookMailbox, setWebhookMailbox] = useState<InboundMailbox | null>(null);
   const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
   const [catchAllLoading, setCatchAllLoading] = useState(false);
   const [selectedMailboxId, setSelectedMailboxId] = useState<number | null>(null);
@@ -192,11 +195,16 @@ export default function Inbox({
     },
     {
       title: t('common.actions'),
-      width: 120,
+      width: 230,
       render: (_value, mailbox) => (
-        <Button icon={<KeyOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(mailbox, config))}>
-          {t('inbox.clientConfig')}
-        </Button>
+        <Space size={4} wrap>
+          <Button icon={<KeyOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(mailbox, config))}>
+            {t('inbox.clientConfig')}
+          </Button>
+          <Button icon={<ThunderboltOutlined />} onClick={() => setWebhookMailbox(mailbox)}>
+            {t('inbox.mailboxWebhooks')}
+          </Button>
+        </Space>
       )
     }
   ];
@@ -488,6 +496,23 @@ export default function Inbox({
         ) : null}
       </Modal>
 
+      <Drawer
+        title={webhookMailbox ? `${t('inbox.mailboxWebhooks')} · ${webhookMailbox.address}` : t('inbox.mailboxWebhooks')}
+        open={Boolean(webhookMailbox)}
+        width="min(1240px, 100vw)"
+        destroyOnHidden
+        onClose={() => setWebhookMailbox(null)}
+      >
+        {webhookMailbox ? (
+          <Webhooks
+            mailboxId={webhookMailbox.id}
+            domains={domains}
+            mailboxes={mailboxes}
+            onCopy={onCopy}
+          />
+        ) : null}
+      </Drawer>
+
       <Drawer
         title={selectedMessage ? `${t('inbox.messageDetail')} · mh-in-${selectedMessage.id}` : t('inbox.messageDetail')}
         open={Boolean(selectedMessage)}

+ 28 - 13
src/pages/Webhooks.tsx

@@ -37,6 +37,7 @@ import { useI18n } from '../frontend/i18n/react';
 import { api } from '../frontend/services/api';
 import type {
   Domain,
+  InboundMailbox,
   Webhook,
   WebhookDelivery,
   WebhookDeliveryStatus,
@@ -44,13 +45,17 @@ import type {
   WebhookPayload
 } from '../frontend/types';
 
-const ALL_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked'];
+const DELIVERY_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked'];
+const ALL_EVENTS: WebhookEvent[] = [...DELIVERY_EVENTS, 'received'];
 const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead'];
 
 interface WebhooksProps {
   /** When set, list/create are scoped to this domain (no global page chrome). */
   domainId?: number;
+  /** When set, only receipt callbacks for this mailbox are shown. */
+  mailboxId?: number;
   domains?: Domain[];
+  mailboxes?: InboundMailbox[];
   onCopy?: (value: string) => void;
 }
 
@@ -67,7 +72,7 @@ interface SecretReveal {
   mode: 'created' | 'rotated';
 }
 
-export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksProps) {
+export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes = [], onCopy }: WebhooksProps) {
   const { message } = AntApp.useApp();
   const { t } = useI18n();
   const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -82,17 +87,20 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
   const [filterStatus, setFilterStatus] = useState<WebhookDeliveryStatus | 'all'>('all');
   const [filterEvent, setFilterEvent] = useState<WebhookEvent | 'all'>('all');
 
-  const scoped = domainId != null;
+  const mailboxScoped = mailboxId != null;
+  const scoped = domainId != null || mailboxScoped;
+  const selectableEvents: WebhookEvent[] = mailboxScoped ? ['received'] : DELIVERY_EVENTS;
   const domainMap = useMemo(() => new Map(domains.map((d) => [d.id, d.domain])), [domains]);
+  const mailboxMap = useMemo(() => new Map(mailboxes.map((m) => [m.id, m.address])), [mailboxes]);
 
   const loadData = useCallback(async () => {
     setLoading(true);
     try {
       const [webhooksResult, deliveriesResult] = await Promise.all([
-        api.webhooks(scoped ? domainId : undefined),
+        api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId),
         api.webhookDeliveries({ limit: 100 })
       ]);
-      const nextWebhooks = webhooksResult.webhooks || [];
+      const nextWebhooks = (webhooksResult.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null);
       setWebhooks(nextWebhooks);
       const webhookIds = new Set(nextWebhooks.map((w) => w.id));
       const nextDeliveries = (deliveriesResult.deliveries || []).filter((d) =>
@@ -104,7 +112,7 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
     } finally {
       setLoading(false);
     }
-  }, [domainId, message, scoped, t]);
+  }, [domainId, mailboxId, mailboxScoped, message, scoped, t]);
 
   useEffect(() => {
     void loadData();
@@ -148,8 +156,8 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
     form.setFieldsValue({
       name: '',
       url: '',
-      events: [...ALL_EVENTS],
-      domainId: scoped ? domainId : undefined,
+      events: [...selectableEvents],
+      domainId: mailboxScoped ? undefined : (domainId != null ? domainId : undefined),
       enabled: true
     });
     setDrawerOpen(true);
@@ -160,7 +168,7 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
     form.setFieldsValue({
       name: webhook.name,
       url: webhook.url,
-      events: webhook.events?.length ? [...webhook.events] : [...ALL_EVENTS],
+      events: webhook.events?.length ? [...webhook.events] : [...selectableEvents],
       domainId: webhook.domainId ?? undefined,
       enabled: webhook.enabled
     });
@@ -181,7 +189,8 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
         name: values.name.trim(),
         url: values.url.trim(),
         events: values.events,
-        domainId: scoped ? domainId : (values.domainId ?? null),
+        domainId: mailboxScoped ? null : (domainId != null ? domainId : (values.domainId ?? null)),
+        mailboxId: mailboxScoped ? mailboxId : null,
         enabled: values.enabled
       };
       if (editing) {
@@ -283,6 +292,10 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
   }
 
   function scopeLabel(webhook: Webhook) {
+    if (webhook.mailboxId != null) {
+      const address = mailboxMap.get(webhook.mailboxId);
+      return address ? `${t('webhooks.scopeMailbox')} · ${address}` : t('webhooks.scopeMailbox');
+    }
     if (webhook.domainId == null) return t('webhooks.scopeAccount');
     const name = domainMap.get(webhook.domainId);
     return name ? `${t('webhooks.scopeDomain')} · ${name}` : t('webhooks.scopeDomain');
@@ -294,6 +307,7 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
     if (event === 'failed') return t('webhooks.eventFailed');
     if (event === 'opened') return t('webhooks.eventOpened');
     if (event === 'clicked') return t('webhooks.eventClicked');
+    if (event === 'received') return t('webhooks.eventReceived');
     return event;
   }
 
@@ -496,7 +510,7 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
         />
       ) : (
         <Space direction="vertical" size={12} className="full-width">
-          <Alert type="info" showIcon message={t('webhooks.domainOverrideHelp')} />
+          <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
           <Space>
             <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
               {t('common.refresh')}
@@ -613,7 +627,7 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
           </div>
         }
       >
-        <Form form={form} layout="vertical" initialValues={{ enabled: true, events: ALL_EVENTS }}>
+        <Form form={form} layout="vertical" initialValues={{ enabled: true, events: DELIVERY_EVENTS }}>
           <Form.Item
             name="name"
             label={t('webhooks.name')}
@@ -635,10 +649,11 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
             rules={[{ required: true, type: 'array', min: 1, message: t('webhooks.eventsRequired') }]}
           >
             <Checkbox.Group
-              options={ALL_EVENTS.map((event) => ({
+              options={selectableEvents.map((event) => ({
                 value: event,
                 label: eventLabel(event)
               }))}
+              disabled={mailboxScoped}
             />
           </Form.Item>
           {!scoped ? (

+ 18 - 1
src/server.js

@@ -639,6 +639,7 @@ async function handleApi(req, res, url, user) {
 
   if (method === 'GET' && pathname === '/api/webhooks') {
     let domainId;
+    let mailboxId;
     if (url.searchParams.has('domainId')) {
       const raw = url.searchParams.get('domainId');
       if (raw === '' || raw === 'null') {
@@ -650,7 +651,21 @@ async function handleApi(req, res, url, user) {
         }
       }
     }
-    return sendJson(res, 200, { webhooks: listWebhooks(user.id, { domainId }) });
+    if (url.searchParams.has('mailboxId')) {
+      const raw = url.searchParams.get('mailboxId');
+      mailboxId = Number(raw);
+      if (!Number.isInteger(mailboxId) || mailboxId <= 0) {
+        return sendJson(res, 400, { error: 'mailboxId 无效。' });
+      }
+    }
+    if (domainId !== undefined && mailboxId !== undefined) {
+      return sendJson(res, 400, { error: 'Webhook 不能同时按域名和收信邮箱筛选。' });
+    }
+    try {
+      return sendJson(res, 200, { webhooks: listWebhooks(user.id, { domainId, mailboxId }) });
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || 'Webhook 查询失败。' });
+    }
   }
   if (method === 'POST' && pathname === '/api/webhooks') {
     const body = await readJson(req);
@@ -661,6 +676,7 @@ async function handleApi(req, res, url, user) {
         url: body.url,
         events: body.events,
         domainId: body.domainId === undefined ? null : body.domainId,
+        mailboxId: body.mailboxId === undefined ? null : body.mailboxId,
         enabled: body.enabled
       });
       return sendJson(res, 201, { webhook });
@@ -683,6 +699,7 @@ async function handleApi(req, res, url, user) {
         if (body.url !== undefined) patch.url = body.url;
         if (body.events !== undefined) patch.events = body.events;
         if (body.domainId !== undefined) patch.domainId = body.domainId;
+        if (body.mailboxId !== undefined) patch.mailboxId = body.mailboxId;
         if (body.enabled !== undefined) patch.enabled = body.enabled;
         const webhook = updateWebhook(user.id, id, patch);
         if (!webhook) return sendJson(res, 404, { error: 'Webhook 不存在。' });

+ 8 - 1
src/submission.js

@@ -5,6 +5,7 @@ import {
   createSendEvent,
   createInboundMessage,
   createTrackingLink,
+  enqueueInboundWebhookDeliveries,
   finalizeSendEvent,
   getDomainByName,
   logSendEvent,
@@ -552,11 +553,17 @@ class SubmissionSession {
         const forwardTo = route.forwardTo || [];
         const shouldStore = route.mailbox && (route.keepForwarded || !forwardTo.length);
         if (shouldStore) {
-          createInboundMessage(route.mailbox, {
+          const inboundMessage = createInboundMessage(route.mailbox, {
             ...parsedMessage,
             recipients: [route.recipient],
             sender: parsedMessage.sender || this.mailFrom
           });
+          try {
+            enqueueInboundWebhookDeliveries(inboundMessage);
+          } catch (error) {
+            // The message is already durable; webhook retries must not turn receipt into an SMTP failure.
+            console.error(`Inbound webhook enqueue failed for ${route.recipient}: ${error.message}`);
+          }
           storedCount += 1;
         }
         if (forwardTo.length) {

+ 36 - 3
src/webhook-model.js

@@ -1,7 +1,7 @@
 import crypto from 'node:crypto';
 
 export const TERMINAL_WEBHOOK_EVENTS = ['sent', 'bounced', 'failed'];
-export const WEBHOOK_EVENTS = [...TERMINAL_WEBHOOK_EVENTS, 'opened', 'clicked'];
+export const WEBHOOK_EVENTS = [...TERMINAL_WEBHOOK_EVENTS, 'opened', 'clicked', 'received'];
 export const MAX_WEBHOOK_ATTEMPTS = 8;
 export const WEBHOOK_LEASE_MS = 2 * 60 * 1000;
 
@@ -24,6 +24,7 @@ export function eventTypeForStatus(status) {
   if (status === 'failed') return 'email.failed';
   if (status === 'opened') return 'email.opened';
   if (status === 'clicked') return 'email.clicked';
+  if (status === 'received') return 'email.received';
   return null;
 }
 
@@ -45,7 +46,39 @@ export function resolveWebhooksForEvent({ accountWebhooks, domainWebhooks, event
   return matches(accountWebhooks);
 }
 
-export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEvent, engagement = null, test = false }) {
+export function buildWebhookPayload({
+  deliveryId,
+  eventType,
+  createdAt,
+  sendEvent,
+  inboundMessage = null,
+  engagement = null,
+  test = false
+}) {
+  if (inboundMessage) {
+    const rfcMessageId = String(inboundMessage.messageId || '').trim();
+    return {
+      id: `whd_${deliveryId}`,
+      type: String(eventType || '').startsWith('email.') ? eventType : eventTypeForStatus('received'),
+      created_at: createdAt,
+      data: {
+        ...(test ? { test: true } : {}),
+        message_id: rfcMessageId || (test ? 'mh-test' : `mh-in-${inboundMessage.id}`),
+        rfc_message_id: rfcMessageId || null,
+        inbound_message_id: inboundMessage.id,
+        mailbox_id: inboundMessage.mailboxId,
+        mailbox: inboundMessage.mailboxAddress || '',
+        domain: inboundMessage.domain || '',
+        from: inboundMessage.sender || '',
+        to: inboundMessage.recipients || [],
+        subject: inboundMessage.subject || '',
+        text: inboundMessage.textBody || '',
+        html: inboundMessage.htmlBody || '',
+        received_at: inboundMessage.receivedAt || null
+      }
+    };
+  }
+
   const status = sendEvent.status;
   const externalType = String(eventType || '').startsWith('email.')
     ? eventType
@@ -131,5 +164,5 @@ function publicEngagement(engagement) {
 }
 
 function webhookEventsError() {
-  return new Error('events must be a non-empty array of sent|bounced|failed|opened|clicked');
+  return new Error('events must be a non-empty array of sent|bounced|failed|opened|clicked|received');
 }

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

@@ -34,6 +34,81 @@ test('webhook API requires authentication', async () => {
   }
 });
 
+test('mailbox webhook API confines email.received to its owned mailbox', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'mailbox-alice', email: 'mailbox-alice@example.com', password: 'password123', status: 'active' },
+      { username: 'mailbox-bob', email: 'mailbox-bob@example.com', password: 'password123', status: 'active' }
+    ]);
+    const aliceCookie = await login(baseUrl, 'mailbox-alice', 'password123');
+    const bobCookie = await login(baseUrl, 'mailbox-bob', 'password123');
+    await createSendingDomain(baseUrl, aliceCookie, { domain: 'mailbox-hooks.example' });
+
+    const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
+      body: JSON.stringify({ address: 'support@mailbox-hooks.example', password: 'mailbox-pass-123' })
+    });
+    assert.equal(createMailbox.status, 201);
+    const mailbox = (await createMailbox.json()).mailbox;
+
+    const createWebhook = await fetch(`${baseUrl}/api/webhooks`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
+      body: JSON.stringify({
+        name: 'Support arrival',
+        url: 'http://127.0.0.1:9/receipt',
+        events: ['received'],
+        mailboxId: mailbox.id
+      })
+    });
+    assert.equal(createWebhook.status, 201);
+    const webhook = (await createWebhook.json()).webhook;
+    assert.equal(webhook.mailboxId, mailbox.id);
+    assert.equal(webhook.domainId, null);
+    assert.deepEqual(webhook.events, ['received']);
+
+    const filtered = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
+      headers: { Cookie: aliceCookie }
+    });
+    assert.equal(filtered.status, 200);
+    assert.equal((await filtered.json()).webhooks[0].id, webhook.id);
+
+    const bobLookup = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
+      headers: { Cookie: bobCookie }
+    });
+    assert.equal(bobLookup.status, 400);
+
+    const accountReceipt = await fetch(`${baseUrl}/api/webhooks`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
+      body: JSON.stringify({
+        name: 'Invalid account arrival',
+        url: 'http://127.0.0.1:9/invalid-account',
+        events: ['received']
+      })
+    });
+    assert.equal(accountReceipt.status, 400);
+
+    const mailboxSend = await fetch(`${baseUrl}/api/webhooks`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
+      body: JSON.stringify({
+        name: 'Invalid mailbox send',
+        url: 'http://127.0.0.1:9/invalid-mailbox',
+        events: ['sent'],
+        mailboxId: mailbox.id
+      })
+    });
+    assert.equal(mailboxSend.status, 400);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
 test('webhook API isolates users and returns secret only on create/rotate', async () => {
   const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
 

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

@@ -9,8 +9,10 @@ import {
   createDomain,
   createInboundMailbox,
   createUser,
+  createWebhook,
   initDatabase,
   listInboundMessages,
+  listWebhookDeliveries,
   updateDomain
 } from '../src/db.js';
 import { sendViaSmtp } from '../src/mailer.js';
@@ -81,6 +83,71 @@ test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () =
   }
 });
 
+test('SMTP queues a mailbox receipt webhook only after the inbound message is stored', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-webhook-')), 'inbound-secret');
+  const user = createUser({ username: 'inbound-webhook', email: 'inbound-webhook@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'receipt-hook.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.receipt-hook.example',
+    sendingIp: '192.0.2.12',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, { address: 'support@receipt-hook.example' });
+  createWebhook(user.id, {
+    name: 'Support received',
+    url: 'https://hooks.example.com/receipt',
+    events: ['received'],
+    mailboxId: mailbox.id
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.receipt-hook.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: 'sender@example.net',
+      recipients: ['support@receipt-hook.example'],
+      rawMessage: [
+        'Message-ID: <smtp-receipt-hook@example.net>',
+        'From: sender@example.net',
+        'To: support@receipt-hook.example',
+        'Subject: Stored before callback',
+        '',
+        'Inbound body'
+      ].join('\r\n')
+    });
+
+    const [message] = listInboundMessages(user.id);
+    assert.ok(message);
+    const [delivery] = listWebhookDeliveries(user.id, { eventType: 'received' });
+    assert.ok(delivery);
+    assert.equal(delivery.inboundMessageId, message.id);
+    const payload = JSON.parse(delivery.payloadJson);
+    assert.equal(payload.type, 'email.received');
+    assert.equal(payload.data.message_id, '<smtp-receipt-hook@example.net>');
+    assert.equal(payload.data.text, 'Inbound body');
+  } finally {
+    await closeServer(server);
+  }
+});
+
 test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
   const [server] = startSubmissionServer({

+ 161 - 0
test/webhook-db.test.js

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
 import { mkdtempSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
 import { test } from 'node:test';
 
 import {
@@ -9,10 +10,13 @@ import {
   completeWebhookDeliveryFailure,
   completeWebhookDeliverySuccess,
   createDomain,
+  createInboundMailbox,
+  createInboundMessage,
   createUser,
   createWebhook,
   deleteWebhook,
   enqueueWebhookDeliveries,
+  enqueueInboundWebhookDeliveries,
   enqueueWebhookTestDelivery,
   getWebhook,
   initDatabase,
@@ -57,6 +61,67 @@ test('isolates webhooks by user and supports domain scope filter', () => {
   assert.equal(listWebhooks(bob.id, { domainId: aliceDomain.id }).length, 0);
 });
 
+test('migrates legacy webhook deliveries without losing send-event records', () => {
+  const dataDir = tempDataDir();
+  const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
+  database.exec(`
+    CREATE TABLE users (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      username TEXT NOT NULL UNIQUE,
+      email TEXT NOT NULL UNIQUE,
+      password_hash TEXT NOT NULL,
+      role TEXT NOT NULL DEFAULT 'user',
+      status TEXT NOT NULL DEFAULT 'active',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+    CREATE TABLE webhooks (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      user_id INTEGER NOT NULL,
+      domain_id INTEGER,
+      name TEXT NOT NULL,
+      url TEXT NOT NULL,
+      secret_ciphertext TEXT NOT NULL,
+      secret_prefix TEXT NOT NULL,
+      events_json TEXT NOT NULL,
+      enabled TEXT NOT NULL DEFAULT 'true',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+    CREATE TABLE webhook_deliveries (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      webhook_id INTEGER NOT NULL,
+      user_id INTEGER NOT NULL,
+      send_event_id INTEGER NOT NULL,
+      event_type TEXT NOT NULL,
+      payload_json TEXT NOT NULL,
+      status TEXT NOT NULL,
+      attempt_count INTEGER NOT NULL DEFAULT 0,
+      next_attempt_at TEXT NOT NULL,
+      last_attempt_at TEXT,
+      response_status INTEGER,
+      response_body_preview TEXT NOT NULL DEFAULT '',
+      error TEXT NOT NULL DEFAULT '',
+      created_at TEXT NOT NULL,
+      FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE,
+      UNIQUE(webhook_id, send_event_id, event_type)
+    );
+    INSERT INTO users (id, username, email, password_hash, role, status, created_at, updated_at)
+    VALUES (1, 'legacy', 'legacy@example.com', 'hash', 'user', 'active', '2026-07-14T00:00:00.000Z', '2026-07-14T00:00:00.000Z');
+    INSERT INTO webhooks (id, user_id, name, url, secret_ciphertext, secret_prefix, events_json, enabled, created_at, updated_at)
+    VALUES (1, 1, 'Legacy', 'https://hooks.example.com/legacy', 'secret', 'whsec_12', '["sent"]', 'true', '2026-07-14T00:00:00.000Z', '2026-07-14T00:00:00.000Z');
+    INSERT INTO webhook_deliveries (id, webhook_id, user_id, send_event_id, event_type, payload_json, status, attempt_count, next_attempt_at, response_body_preview, error, created_at)
+    VALUES (1, 1, 1, 42, 'sent', '{}', 'success', 1, '2026-07-14T00:00:00.000Z', '', '', '2026-07-14T00:00:00.000Z');
+  `);
+  database.close();
+
+  initDatabase(dataDir, 'test-secret');
+  const [delivery] = listWebhookDeliveries(1);
+  assert.equal(delivery.sendEventId, 42);
+  assert.equal(delivery.inboundMessageId, null);
+  assert.equal(listWebhooks(1)[0].mailboxId, null);
+});
+
 test('create returns secret once; list and get omit secret', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
@@ -137,6 +202,102 @@ test('enqueueWebhookDeliveries is idempotent per webhook+event+send_event', () =
   assert.equal(payload.data.queue_id, 'QUEUE42');
 });
 
+test('mailbox webhooks only enqueue idempotent receipt callbacks for their stored mail', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'inbound-alice', email: 'inbound-alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'inbound-bob', email: 'inbound-bob@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('inbound-hook.example'));
+  const mailbox = createInboundMailbox(alice.id, { address: 'support@inbound-hook.example' });
+  const otherMailbox = createInboundMailbox(alice.id, { address: 'sales@inbound-hook.example' });
+  const webhook = createWebhook(alice.id, {
+    name: 'Support receipt',
+    url: 'https://hooks.example.com/inbound',
+    events: ['received'],
+    mailboxId: mailbox.id
+  });
+  const sendWebhook = createWebhook(alice.id, {
+    name: 'Sending only',
+    url: 'https://hooks.example.com/send',
+    events: ['sent']
+  });
+
+  assert.equal(webhook.domainId, null);
+  assert.equal(webhook.mailboxId, mailbox.id);
+  assert.equal(listWebhooks(alice.id, { mailboxId: mailbox.id }).length, 1);
+  assert.equal(listWebhooks(alice.id, { domainId: null }).length, 1);
+  assert.throws(() => createWebhook(alice.id, {
+    name: 'Invalid account receipt',
+    url: 'https://hooks.example.com/invalid-account',
+    events: ['received']
+  }), /received/);
+  assert.throws(() => createWebhook(alice.id, {
+    name: 'Invalid mailbox send',
+    url: 'https://hooks.example.com/invalid-mailbox',
+    events: ['sent'],
+    mailboxId: mailbox.id
+  }), /邮箱 Webhook/);
+  assert.throws(() => createWebhook(bob.id, {
+    name: 'Other user mailbox',
+    url: 'https://hooks.example.com/other-user',
+    events: ['received'],
+    mailboxId: mailbox.id
+  }), /收信邮箱/);
+
+  const inboundMessage = createInboundMessage(mailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@inbound-hook.example'],
+    subject: 'Receipt callback',
+    messageId: '<inbound-message@example.net>',
+    textBody: 'Plain text',
+    htmlBody: '<p>HTML</p>'
+  });
+  const unrelatedMessage = createInboundMessage(otherMailbox, {
+    sender: 'sender@example.net',
+    recipients: ['sales@inbound-hook.example'],
+    subject: 'Other mailbox'
+  });
+
+  assert.equal(enqueueInboundWebhookDeliveries(inboundMessage).length, 1);
+  assert.equal(enqueueInboundWebhookDeliveries(inboundMessage).length, 0);
+  assert.equal(enqueueInboundWebhookDeliveries(unrelatedMessage).length, 0);
+  enqueueWebhookDeliveries({
+    id: 99,
+    userId: alice.id,
+    domainId: domain.id,
+    status: 'sent',
+    sender: 'noreply@inbound-hook.example',
+    recipients: ['reader@example.net'],
+    subject: 'Sending path'
+  });
+
+  const deliveries = listWebhookDeliveries(alice.id);
+  const received = deliveries.find((delivery) => delivery.eventType === 'received');
+  assert.ok(received);
+  assert.equal(received.webhookId, webhook.id);
+  assert.equal(received.sendEventId, 0);
+  assert.equal(received.inboundMessageId, inboundMessage.id);
+  const payload = JSON.parse(received.payloadJson);
+  assert.equal(payload.type, 'email.received');
+  assert.equal(payload.data.mailbox, 'support@inbound-hook.example');
+  assert.equal(payload.data.rfc_message_id, '<inbound-message@example.net>');
+  assert.equal(payload.data.text, 'Plain text');
+  assert.equal(payload.data.html, '<p>HTML</p>');
+  assert.equal('raw_message' in payload.data, false);
+  assert.equal(deliveries.find((delivery) => delivery.eventType === 'sent')?.webhookId, sendWebhook.id);
+
+  const testDelivery = enqueueWebhookTestDelivery(alice.id, webhook.id);
+  assert.equal(testDelivery.inboundMessageId, 0);
+  assert.equal(JSON.parse(testDelivery.payloadJson).type, 'email.received');
+
+  updateWebhook(alice.id, webhook.id, { enabled: false });
+  const disabledMessage = createInboundMessage(mailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@inbound-hook.example'],
+    subject: 'Disabled callback'
+  });
+  assert.equal(enqueueInboundWebhookDeliveries(disabledMessage).length, 0);
+});
+
 test('domain override skips account webhooks for that event', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });

+ 36 - 2
test/webhook-model.test.js

@@ -23,17 +23,51 @@ test('maps terminal statuses to email.* types', () => {
   assert.equal(eventTypeForStatus('failed'), 'email.failed');
   assert.equal(eventTypeForStatus('opened'), 'email.opened');
   assert.equal(eventTypeForStatus('clicked'), 'email.clicked');
+  assert.equal(eventTypeForStatus('received'), 'email.received');
   assert.equal(eventTypeForStatus('queued'), null);
   assert.equal(eventTypeForStatus('deferred'), null);
 });
 
-test('supports opened and clicked subscriptions without making them delivery terminal statuses', () => {
-  assert.deepEqual(WEBHOOK_EVENTS, ['sent', 'bounced', 'failed', 'opened', 'clicked']);
+test('supports engagement and receipt subscriptions without making them delivery terminal statuses', () => {
+  assert.deepEqual(WEBHOOK_EVENTS, ['sent', 'bounced', 'failed', 'opened', 'clicked', 'received']);
   assert.equal(isTerminalWebhookStatus('opened'), false);
   assert.equal(isTerminalWebhookStatus('clicked'), false);
+  assert.equal(isTerminalWebhookStatus('received'), false);
   assert.deepEqual(normalizeWebhookEvents(['clicked', 'opened', 'sent']), ['sent', 'opened', 'clicked']);
 });
 
+test('builds an inbound receipt payload without raw MIME content', () => {
+  const payload = buildWebhookPayload({
+    deliveryId: 9,
+    eventType: 'email.received',
+    createdAt: '2026-07-14T12:00:00.000Z',
+    inboundMessage: {
+      id: 55,
+      mailboxId: 4,
+      mailboxAddress: 'support@example.com',
+      domain: 'example.com',
+      sender: 'sender@example.net',
+      recipients: ['support@example.com'],
+      subject: 'Inbound test',
+      messageId: '<rfc-123@example.net>',
+      textBody: 'Plain body',
+      htmlBody: '<p>HTML body</p>',
+      rawMessage: 'secret raw MIME',
+      receivedAt: '2026-07-14T11:59:58.000Z'
+    }
+  });
+
+  assert.equal(payload.type, 'email.received');
+  assert.equal(payload.data.inbound_message_id, 55);
+  assert.equal(payload.data.mailbox, 'support@example.com');
+  assert.equal(payload.data.message_id, '<rfc-123@example.net>');
+  assert.equal(payload.data.rfc_message_id, '<rfc-123@example.net>');
+  assert.equal(payload.data.text, 'Plain body');
+  assert.equal(payload.data.html, '<p>HTML body</p>');
+  assert.equal('raw_message' in payload.data, false);
+  assert.equal(JSON.stringify(payload).includes('secret raw MIME'), false);
+});
+
 test('isTerminalWebhookStatus matches terminal set', () => {
   assert.equal(isTerminalWebhookStatus('sent'), true);
   assert.equal(isTerminalWebhookStatus('bounced'), true);

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio