瀏覽代碼

feat(analytics): add delivery tracking insights

AI-Co-Authored-By: Codex
chendeben 1 月之前
父節點
當前提交
1ee2995912

文件差異過大導致無法顯示
+ 0 - 1
public/assets/index-Bw7rCACo.js


文件差異過大導致無法顯示
+ 0 - 0
public/assets/login-CUR5060y.js


文件差異過大導致無法顯示
+ 0 - 0
public/assets/styles-HcGFnvy-.css


文件差異過大導致無法顯示
+ 0 - 0
public/assets/styles-_IX60o7a.css


文件差異過大導致無法顯示
+ 0 - 0
public/assets/styles-hME2W18a.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-BF4JJP0a.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-MGiiWi3S.js">
+    <script type="module" crossorigin src="/assets/index-Bw7rCACo.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-hME2W18a.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-_IX60o7a.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-HcGFnvy-.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-Clt8-De1.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-MGiiWi3S.js">
+    <script type="module" crossorigin src="/assets/login-CUR5060y.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-hME2W18a.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-_IX60o7a.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-HcGFnvy-.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 147 - 19
src/db.js

@@ -897,23 +897,28 @@ export function listSendEvents(userId, limit = 30) {
       LIMIT ?
     `)
     .all(userId, limit)
-    .map((row) => ({
-      id: row.id,
-      userId: row.user_id,
-      domainId: row.domain_id,
-      smtpRelayId: row.smtp_relay_id,
-      domain: row.domain,
-      sender: row.sender,
-      recipients: safeJson(row.recipients, []),
-      subject: row.subject,
-      status: row.status,
-      detail: row.detail,
-      queueId: row.queue_id,
-      deliveryLog: safeJson(row.delivery_log_json, []),
-      deliveryAttempts: safeJson(row.delivery_attempts_json, []),
-      deliveredAt: row.delivered_at,
-      createdAt: row.created_at
-    }));
+    .map(publicSendEvent);
+}
+
+export function getSendEvent(userId, eventId) {
+  const event = publicSendEvent(
+    requireDb()
+      .prepare(`
+        SELECT e.*, d.domain
+        FROM send_events e
+        LEFT JOIN domains d ON d.id = e.domain_id
+        WHERE e.user_id = ? AND e.id = ?
+      `)
+      .get(userId, Number(eventId))
+  );
+  if (!event) return null;
+  return {
+    ...event,
+    webhookDeliveries: listWebhookDeliveries(userId, {
+      sendEventId: event.id,
+      limit: 50
+    })
+  };
 }
 
 export function listWebhooks(userId, { domainId } = {}) {
@@ -1278,6 +1283,10 @@ export function listWebhookDeliveries(userId, filters = {}) {
     where.push('event_type = ?');
     params.push(String(filters.eventType));
   }
+  if (filters.sendEventId != null) {
+    where.push('send_event_id = ?');
+    params.push(Number(filters.sendEventId));
+  }
   const limit = Math.max(1, Math.min(200, Number(filters.limit) || 50));
   params.push(limit);
   return requireDb()
@@ -1444,13 +1453,24 @@ export function getSendAnalytics(userId, { days = 7 } = {}) {
     hour,
     total: 0,
     queued: 0,
-    failed: 0
+    failed: 0,
+    accepted: 0,
+    delivered: 0,
+    pending: 0,
+    terminalFailed: 0
   }));
   let recipients = 0;
   let queued = 0;
   let failed = 0;
+  let accepted = 0;
+  let delivered = 0;
+  let pending = 0;
+  let deferred = 0;
+  let bounced = 0;
+  let terminalFailed = 0;
   let today = 0;
   let last7Days = 0;
+  const failureReasons = new Map();
   const todayKey = new Date().toISOString().slice(0, 10);
   const weekStart = new Date();
   weekStart.setUTCHours(0, 0, 0, 0);
@@ -1465,19 +1485,31 @@ export function getSendAnalytics(userId, { days = 7 } = {}) {
     const dayKey = row.created_at.slice(0, 10);
     const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
     const isQueued = ['queued', 'sent'].includes(status);
+    const classification = classifySendEvent(row);
 
     recipients += recipientCount;
     queued += isQueued ? 1 : 0;
     failed += isQueued ? 0 : 1;
+    accepted += classification.accepted ? 1 : 0;
+    delivered += classification.delivered ? 1 : 0;
+    pending += classification.pending ? 1 : 0;
+    deferred += classification.deferred ? 1 : 0;
+    bounced += classification.bounced ? 1 : 0;
+    terminalFailed += classification.terminalFailed ? 1 : 0;
     today += dayKey === todayKey ? 1 : 0;
     last7Days += createdAt >= weekStart ? 1 : 0;
     byStatus[status] = (byStatus[status] || 0) + 1;
+    if (isDeliveryFailureStatus(status)) addFailureReason(failureReasons, row);
 
     if (dayBuckets.has(dayKey)) {
       const bucket = dayBuckets.get(dayKey);
       bucket.total += 1;
       bucket.queued += isQueued ? 1 : 0;
       bucket.failed += isQueued ? 0 : 1;
+      bucket.accepted += classification.accepted ? 1 : 0;
+      bucket.delivered += classification.delivered ? 1 : 0;
+      bucket.pending += classification.pending ? 1 : 0;
+      bucket.terminalFailed += classification.terminalFailed ? 1 : 0;
       bucket.recipients += recipientCount;
     }
 
@@ -1486,17 +1518,29 @@ export function getSendAnalytics(userId, { days = 7 } = {}) {
       total: 0,
       queued: 0,
       failed: 0,
+      accepted: 0,
+      delivered: 0,
+      pending: 0,
+      terminalFailed: 0,
       recipients: 0
     };
     domainBucket.total += 1;
     domainBucket.queued += isQueued ? 1 : 0;
     domainBucket.failed += isQueued ? 0 : 1;
+    domainBucket.accepted += classification.accepted ? 1 : 0;
+    domainBucket.delivered += classification.delivered ? 1 : 0;
+    domainBucket.pending += classification.pending ? 1 : 0;
+    domainBucket.terminalFailed += classification.terminalFailed ? 1 : 0;
     domainBucket.recipients += recipientCount;
     byDomain.set(domainName, domainBucket);
 
     hourly[hour].total += 1;
     hourly[hour].queued += isQueued ? 1 : 0;
     hourly[hour].failed += isQueued ? 0 : 1;
+    hourly[hour].accepted += classification.accepted ? 1 : 0;
+    hourly[hour].delivered += classification.delivered ? 1 : 0;
+    hourly[hour].pending += classification.pending ? 1 : 0;
+    hourly[hour].terminalFailed += classification.terminalFailed ? 1 : 0;
   }
 
   const recentFailures = [...rows]
@@ -1516,19 +1560,39 @@ export function getSendAnalytics(userId, { days = 7 } = {}) {
     windowDays,
     summary: {
       total: rows.length,
+      submitted: rows.length,
       queued,
       failed,
+      accepted,
+      delivered,
+      pending,
+      deferred,
+      bounced,
+      terminalFailed,
       recipients,
       today,
       last7Days,
-      successRate: rows.length ? Math.round((queued / rows.length) * 1000) / 10 : 0,
+      successRate: percent(queued, rows.length),
+      acceptanceRate: percent(accepted, rows.length),
+      deliveryRate: percent(delivered, rows.length),
+      failureRate: percent(terminalFailed, rows.length),
       domains: domains.length,
       verifiedDomains: domains.filter((domain) => domain.status?.verified).length
     },
+    deliveryFunnel: [
+      { stage: 'submitted', total: rows.length, rate: percent(rows.length, rows.length) },
+      { stage: 'accepted', total: accepted, rate: percent(accepted, rows.length) },
+      { stage: 'delivered', total: delivered, rate: percent(delivered, rows.length) },
+      { stage: 'pending', total: pending, rate: percent(pending, rows.length) },
+      { stage: 'failed', total: terminalFailed, rate: percent(terminalFailed, rows.length) }
+    ],
     byDay: [...dayBuckets.values()],
     byDomain: [...byDomain.values()].sort((a, b) => b.total - a.total).slice(0, 10),
     byStatus: Object.entries(byStatus).map(([status, total]) => ({ status, total })),
     hourly,
+    failureReasons: [...failureReasons.values()]
+      .sort((a, b) => b.total - a.total || String(b.lastSeenAt).localeCompare(String(a.lastSeenAt)))
+      .slice(0, 10),
     recentFailures
   };
 }
@@ -2238,6 +2302,27 @@ function publicDnsCredential(row) {
   };
 }
 
+function publicSendEvent(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    domainId: row.domain_id,
+    smtpRelayId: row.smtp_relay_id,
+    domain: row.domain,
+    sender: row.sender,
+    recipients: safeJson(row.recipients, []),
+    subject: row.subject,
+    status: row.status,
+    detail: row.detail,
+    queueId: row.queue_id,
+    deliveryLog: safeJson(row.delivery_log_json, []),
+    deliveryAttempts: safeJson(row.delivery_attempts_json, []),
+    deliveredAt: row.delivered_at,
+    createdAt: row.created_at
+  };
+}
+
 function publicAuditLog(row) {
   if (!row) return null;
   return {
@@ -2412,12 +2497,55 @@ function buildDayBuckets(days) {
       total: 0,
       queued: 0,
       failed: 0,
+      accepted: 0,
+      delivered: 0,
+      pending: 0,
+      terminalFailed: 0,
       recipients: 0
     });
   }
   return buckets;
 }
 
+function percent(part, total) {
+  return total ? Math.round((Number(part || 0) / total) * 1000) / 10 : 0;
+}
+
+function classifySendEvent(row) {
+  const status = String(row?.status || '').toLowerCase();
+  const hasQueueId = Boolean(normalizeQueueId(row?.queue_id));
+  const accepted = hasQueueId || ['queued', 'sent', 'deferred', 'bounced'].includes(status);
+  return {
+    status,
+    accepted,
+    delivered: status === 'sent',
+    pending: status === 'queued' || status === 'deferred',
+    deferred: status === 'deferred',
+    bounced: status === 'bounced',
+    terminalFailed: status === 'bounced' || status === 'failed'
+  };
+}
+
+function normalizeFailureReason(row) {
+  const detail = String(row?.detail || '').replace(/\s+/g, ' ').trim();
+  return detail || String(row?.status || 'unknown failure');
+}
+
+function addFailureReason(map, row) {
+  const reason = normalizeFailureReason(row);
+  const status = String(row?.status || 'unknown').toLowerCase();
+  const bucket = map.get(reason) || {
+    reason,
+    total: 0,
+    statuses: {},
+    lastSeenAt: row?.created_at || ''
+  };
+  bucket.total += 1;
+  bucket.statuses[status] = (bucket.statuses[status] || 0) + 1;
+  if (row?.created_at && row.created_at > bucket.lastSeenAt) bucket.lastSeenAt = row.created_at;
+  map.set(reason, bucket);
+}
+
 function safeJson(value, fallback) {
   try {
     return JSON.parse(value);

+ 6 - 1
src/frontend/App.tsx

@@ -250,6 +250,11 @@ function MailHubConsole() {
     setData((current) => ({ ...current, events: events.events || [], analytics: analytics.analytics || current.analytics }));
   }
 
+  async function loadSendEvent(id: number) {
+    const result = await api.event(id);
+    return result.event;
+  }
+
   async function copy(value: string) {
     if (!value || value === '-') return;
     await navigator.clipboard.writeText(value);
@@ -560,7 +565,7 @@ function MailHubConsole() {
       );
     }
     if (activeView === 'logs') {
-      return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} />;
+      return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} onLoadEvent={loadSendEvent} />;
     }
     if (activeView === 'webhooks') {
       return <Webhooks domains={data.domains} onCopy={copy} />;

+ 110 - 0
src/frontend/analytics-model.js

@@ -60,6 +60,22 @@ export function buildStatusDistribution(analytics = null) {
   }));
 }
 
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{stage: string; total: number; rate: number; tone: 'success' | 'warning' | 'error' | 'info' | 'neutral'}>}
+ */
+export function buildDeliveryFunnel(analytics = null) {
+  return (analytics?.deliveryFunnel || []).map((item) => {
+    const stage = item.stage || 'unknown';
+    return {
+      stage,
+      total: Number(item.total || 0),
+      rate: Number(item.rate || 0),
+      tone: deliveryStageTone(stage)
+    };
+  });
+}
+
 /**
  * @param {any} [analytics]
  * @returns {Array<{domain: string; total: number; accepted: number; failed: number; recipients: number}>}
@@ -88,3 +104,97 @@ export function buildHourlyHeatmap(analytics = null) {
     failed: Number(item.failed || 0)
   }));
 }
+
+/**
+ * @param {any} [event]
+ * @returns {Array<{
+ *   stage: string;
+ *   at: string;
+ *   tone: 'success' | 'warning' | 'error' | 'info' | 'neutral';
+ *   status?: string;
+ *   queueId?: string;
+ *   recipient?: string;
+ *   relay?: string;
+ *   response?: string;
+ *   webhookId?: number;
+ *   responseStatus?: number | null;
+ * }>}
+ */
+export function buildEventTimeline(event = null) {
+  if (!event) return [];
+  const timeline = [];
+  if (event.createdAt) {
+    timeline.push({
+      stage: 'submitted',
+      at: event.createdAt,
+      tone: 'info',
+      status: event.status
+    });
+  }
+  if (event.queueId) {
+    timeline.push({
+      stage: 'accepted',
+      at: event.createdAt || '',
+      tone: 'info',
+      status: 'queued',
+      queueId: event.queueId
+    });
+  }
+  const attempts = Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : [];
+  for (const attempt of attempts) {
+    const stage = deliveryAttemptStage(attempt.status);
+    timeline.push({
+      stage,
+      at: attempt.at || '',
+      tone: deliveryStageTone(stage),
+      status: attempt.status,
+      queueId: attempt.queueId,
+      recipient: attempt.recipient,
+      relay: attempt.relay,
+      response: attempt.response
+    });
+  }
+  if (!attempts.length && event.deliveredAt) {
+    timeline.push({
+      stage: 'delivered',
+      at: event.deliveredAt,
+      tone: 'success',
+      status: 'sent',
+      queueId: event.queueId
+    });
+  }
+  const webhookDeliveries = Array.isArray(event.webhookDeliveries) ? event.webhookDeliveries : [];
+  for (const delivery of webhookDeliveries) {
+    timeline.push({
+      stage: 'webhook',
+      at: delivery.lastAttemptAt || delivery.createdAt || '',
+      tone: webhookDeliveryTone(delivery.status),
+      status: delivery.status,
+      webhookId: delivery.webhookId,
+      responseStatus: delivery.responseStatus
+    });
+  }
+  return timeline;
+}
+
+function deliveryAttemptStage(status) {
+  if (status === 'sent') return 'delivered';
+  if (status === 'deferred') return 'pending';
+  if (status === 'bounced' || status === 'failed') return 'failed';
+  return 'pending';
+}
+
+function deliveryStageTone(stage) {
+  if (stage === 'delivered') return 'success';
+  if (stage === 'pending') return 'warning';
+  if (stage === 'failed') return 'error';
+  if (stage === 'submitted' || stage === 'accepted') return 'info';
+  return 'neutral';
+}
+
+function webhookDeliveryTone(status) {
+  if (status === 'success') return 'success';
+  if (status === 'dead') return 'error';
+  if (status === 'pending' || status === 'processing') return 'warning';
+  return 'neutral';
+}

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

@@ -69,6 +69,8 @@ const messages = {
     'dashboard.domainRanking': '域名发送排行',
     'dashboard.domainHealth': '域名健康',
     'dashboard.hourlyHeatmap': '小时发送分布',
+    'dashboard.deliveryFunnel': '投递漏斗',
+    'dashboard.failureReasons': '失败原因排行',
     'dashboard.recentFailures': '最近失败原因',
     'dashboard.recentLogs': '最近发送记录',
     'dashboard.noTrend': '暂无发送趋势数据',
@@ -81,6 +83,11 @@ const messages = {
     'dashboard.statusQueued': '已接收',
     'dashboard.statusFailed': '失败',
     'dashboard.statusUnknown': '未知',
+    'dashboard.stageSubmitted': '已提交',
+    'dashboard.stageAccepted': '已入队',
+    'dashboard.stageDelivered': '已送达',
+    'dashboard.stagePending': '等待投递',
+    'dashboard.stageFailed': '投递失败',
     'domains.title': '发信域名',
     'domains.domain': '域名',
     'domains.senderHost': '发信主机',
@@ -185,8 +192,20 @@ const messages = {
     'logs.messageBytes': '邮件字节数',
     'logs.queueId': 'Postfix 队列 ID',
     'logs.deliveredAt': '最终投递时间',
+    'logs.trackingTimeline': '投递时间线',
+    'logs.noTrackingTimeline': '暂无可用的投递时间线。',
     'logs.deliveryAttempts': '投递尝试',
     'logs.noDeliveryAttempts': '暂无最终投递回执,可能仍在队列中或属于历史记录。',
+    'logs.webhookDeliveries': 'Webhook 回调',
+    'logs.noWebhookDeliveries': '暂无关联 Webhook 回调。',
+    'logs.detailLoadFailed': '发送详情加载失败。',
+    'logs.detailNotFound': '未找到该发送记录。',
+    'logs.stageSubmitted': '已提交',
+    'logs.stageAccepted': '已入队',
+    'logs.stageDelivered': '已送达',
+    'logs.stagePending': '等待投递',
+    'logs.stageFailed': '投递失败',
+    'logs.stageWebhook': 'Webhook 回调',
     'logs.statusQueued': '队列中',
     'logs.statusSent': '已送达',
     'logs.statusDeferred': '暂时失败',
@@ -471,6 +490,8 @@ const messages = {
     'dashboard.domainRanking': 'Domain ranking',
     'dashboard.domainHealth': 'Domain health',
     'dashboard.hourlyHeatmap': 'Hourly distribution',
+    'dashboard.deliveryFunnel': 'Delivery funnel',
+    'dashboard.failureReasons': 'Top failure reasons',
     'dashboard.recentFailures': 'Recent failures',
     'dashboard.recentLogs': 'Recent sending logs',
     'dashboard.noTrend': 'No trend data yet',
@@ -483,6 +504,11 @@ const messages = {
     'dashboard.statusQueued': 'Accepted',
     'dashboard.statusFailed': 'Failed',
     'dashboard.statusUnknown': 'Unknown',
+    'dashboard.stageSubmitted': 'Submitted',
+    'dashboard.stageAccepted': 'Accepted',
+    'dashboard.stageDelivered': 'Delivered',
+    'dashboard.stagePending': 'Pending',
+    'dashboard.stageFailed': 'Failed',
     'domains.title': 'Sending domains',
     'domains.domain': 'Domain',
     'domains.senderHost': 'Sending host',
@@ -587,8 +613,20 @@ const messages = {
     'logs.messageBytes': 'Message bytes',
     'logs.queueId': 'Postfix Queue ID',
     'logs.deliveredAt': 'Delivered at',
+    'logs.trackingTimeline': 'Delivery timeline',
+    'logs.noTrackingTimeline': 'No delivery timeline is available.',
     'logs.deliveryAttempts': 'Delivery attempts',
     'logs.noDeliveryAttempts': 'No final delivery receipt yet. The message may still be queued or historical.',
+    'logs.webhookDeliveries': 'Webhook callbacks',
+    'logs.noWebhookDeliveries': 'No related webhook callbacks.',
+    'logs.detailLoadFailed': 'Failed to load sending detail.',
+    'logs.detailNotFound': 'Sending record not found.',
+    'logs.stageSubmitted': 'Submitted',
+    'logs.stageAccepted': 'Accepted',
+    'logs.stageDelivered': 'Delivered',
+    'logs.stagePending': 'Pending',
+    'logs.stageFailed': 'Failed',
+    'logs.stageWebhook': 'Webhook callback',
     'logs.statusQueued': 'Queued',
     'logs.statusSent': 'Sent',
     'logs.statusDeferred': 'Deferred',

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

@@ -94,6 +94,7 @@ export const api = {
   config: () => request<RuntimeConfig>('/api/config'),
   domains: () => request<{ domains: Domain[] }>('/api/domains'),
   events: () => request<{ events: SendEvent[] }>('/api/events'),
+  event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
   analytics: (days = 7) => request<{ analytics: Analytics }>(`/api/analytics?days=${days}`),
   smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
   saveSmtpCredential: (data: { username: string; password?: string }) =>

+ 24 - 0
src/frontend/styles.css

@@ -315,6 +315,26 @@ body {
   gap: 2px;
 }
 
+.delivery-funnel {
+  display: grid;
+  gap: 12px;
+}
+
+.delivery-funnel-row {
+  align-items: center;
+  display: grid;
+  gap: 12px;
+  grid-template-columns: minmax(148px, 0.8fr) minmax(120px, 1fr) 52px;
+}
+
+.delivery-funnel-row__meta {
+  align-items: center;
+  display: flex;
+  gap: 10px;
+  justify-content: space-between;
+  min-width: 0;
+}
+
 .page-toolbar {
   align-items: center;
   display: flex;
@@ -818,6 +838,10 @@ body {
     width: 100%;
   }
 
+  .delivery-funnel-row {
+    grid-template-columns: 1fr;
+  }
+
   .domain-health-stats {
     grid-template-columns: repeat(2, minmax(0, 1fr));
   }

+ 53 - 1
src/frontend/types.ts

@@ -279,6 +279,19 @@ export interface DeliveryAttempt {
   raw?: string;
 }
 
+export interface SendEventTimelineEntry {
+  stage: string;
+  at: string;
+  tone: 'success' | 'warning' | 'error' | 'info' | 'neutral';
+  status?: string;
+  queueId?: string;
+  recipient?: string;
+  relay?: string;
+  response?: string;
+  webhookId?: number;
+  responseStatus?: number | null;
+}
+
 export interface SendEvent {
   id: number;
   userId: number;
@@ -293,6 +306,7 @@ export interface SendEvent {
   queueId?: string;
   deliveryLog?: DeliveryLogEntry[];
   deliveryAttempts?: DeliveryAttempt[];
+  webhookDeliveries?: WebhookDelivery[];
   deliveredAt?: string;
   createdAt: string;
 }
@@ -301,21 +315,40 @@ export interface Analytics {
   windowDays: number;
   summary: {
     total: number;
+    submitted: number;
     queued: number;
     failed: number;
+    accepted: number;
+    delivered: number;
+    pending: number;
+    deferred: number;
+    bounced: number;
+    terminalFailed: number;
     recipients: number;
     today: number;
     last7Days: number;
     successRate: number;
+    acceptanceRate: number;
+    deliveryRate: number;
+    failureRate: number;
     domains: number;
     verifiedDomains: number;
   };
+  deliveryFunnel: Array<{
+    stage: string;
+    total: number;
+    rate: number;
+  }>;
   byDay: Array<{
     day: string;
     date?: string;
     total: number;
     queued: number;
     failed: number;
+    accepted?: number;
+    delivered?: number;
+    pending?: number;
+    terminalFailed?: number;
     recipients: number;
   }>;
   byDomain: Array<{
@@ -323,10 +356,29 @@ export interface Analytics {
     total: number;
     queued: number;
     failed: number;
+    accepted?: number;
+    delivered?: number;
+    pending?: number;
+    terminalFailed?: number;
     recipients: number;
   }>;
   byStatus: Array<{ status: string; total: number }>;
-  hourly: Array<{ hour: number; total: number; queued: number; failed: number }>;
+  hourly: Array<{
+    hour: number;
+    total: number;
+    queued: number;
+    failed: number;
+    accepted?: number;
+    delivered?: number;
+    pending?: number;
+    terminalFailed?: number;
+  }>;
+  failureReasons: Array<{
+    reason: string;
+    total: number;
+    statuses: Record<string, number>;
+    lastSeenAt: string;
+  }>;
   recentFailures: SendEvent[];
 }
 

+ 59 - 1
src/pages/Dashboard.tsx

@@ -1,5 +1,5 @@
 import { Area, Bar, Column, Pie } from '@ant-design/plots';
-import { Alert, Col, List, Row, Space, Table, Typography } from 'antd';
+import { Alert, Col, List, Progress, Row, Space, Table, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 
 import { EmptyState } from '../components/common/EmptyState';
@@ -9,6 +9,7 @@ import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
 import {
   buildDashboardSummary,
+  buildDeliveryFunnel,
   buildDomainRanking,
   buildHourlyHeatmap,
   buildStatusDistribution,
@@ -42,6 +43,8 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
   }));
   const rankingData = buildDomainRanking(analytics);
   const hourlyData = buildHourlyHeatmap(analytics);
+  const deliveryFunnel = buildDeliveryFunnel(analytics);
+  const failureReasons = analytics?.failureReasons || [];
   const lastSentLabel = summary.lastSentAt
     ? new Date(summary.lastSentAt).toLocaleString()
     : t('common.notFound');
@@ -100,6 +103,52 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
         </Col>
       </Row>
 
+      <Row gutter={[16, 16]}>
+        <Col xs={24} xl={15}>
+          <SectionCard title={t('dashboard.deliveryFunnel')}>
+            {deliveryFunnel.length ? (
+              <div className="delivery-funnel">
+                {deliveryFunnel.map((item) => (
+                  <div className="delivery-funnel-row" key={item.stage}>
+                    <div className="delivery-funnel-row__meta">
+                      <StatusPill tone={item.tone}>{deliveryStageLabel(item.stage, t)}</StatusPill>
+                      <Typography.Text strong>{item.total}</Typography.Text>
+                    </div>
+                    <Progress percent={item.rate} size="small" showInfo={false} status={item.tone === 'error' ? 'exception' : 'normal'} />
+                    <Typography.Text type="secondary">{item.rate}%</Typography.Text>
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <EmptyState description={t('dashboard.noTrend')} />
+            )}
+          </SectionCard>
+        </Col>
+        <Col xs={24} xl={9}>
+          <SectionCard title={t('dashboard.failureReasons')}>
+            {failureReasons.length ? (
+              <List
+                dataSource={failureReasons}
+                renderItem={(item) => (
+                  <List.Item>
+                    <List.Item.Meta
+                      title={<Typography.Text ellipsis>{item.reason}</Typography.Text>}
+                      description={
+                        <Typography.Text type="secondary">
+                          {t('metrics.total')}: {item.total} · {item.lastSeenAt ? new Date(item.lastSeenAt).toLocaleString() : '-'}
+                        </Typography.Text>
+                      }
+                    />
+                  </List.Item>
+                )}
+              />
+            ) : (
+              <EmptyState description={t('dashboard.noFailures')} />
+            )}
+          </SectionCard>
+        </Col>
+      </Row>
+
       <Row gutter={[16, 16]}>
         <Col xs={24} xl={15}>
           <SectionCard title={t('dashboard.trend')} className="chart-card">
@@ -304,3 +353,12 @@ function domainHealthTone(status: string): 'success' | 'warning' | 'error' {
   if (status === 'warning') return 'warning';
   return 'error';
 }
+
+function deliveryStageLabel(stage: string, t: (key: string) => string) {
+  if (stage === 'submitted') return t('dashboard.stageSubmitted');
+  if (stage === 'accepted') return t('dashboard.stageAccepted');
+  if (stage === 'delivered') return t('dashboard.stageDelivered');
+  if (stage === 'pending') return t('dashboard.stagePending');
+  if (stage === 'failed') return t('dashboard.stageFailed');
+  return stage;
+}

+ 186 - 52
src/pages/SendingLogs.tsx

@@ -1,5 +1,5 @@
 import { CopyOutlined, SearchOutlined } from '@ant-design/icons';
-import { Button, DatePicker, Descriptions, Drawer, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
+import { Alert, Button, DatePicker, Descriptions, Drawer, Input, Select, Space, Spin, Table, Tag, Timeline, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useMemo, useState } from 'react';
 
@@ -7,8 +7,9 @@ import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
+import { buildEventTimeline } from '../frontend/analytics-model.js';
 import { useI18n } from '../frontend/i18n/react';
-import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
+import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent, SendEventTimelineEntry, WebhookDelivery } from '../frontend/types';
 
 const { RangePicker } = DatePicker;
 
@@ -16,15 +17,18 @@ interface SendingLogsProps {
   events: SendEvent[];
   domains: Domain[];
   onCopy: (value: string) => void;
+  onLoadEvent?: (id: number) => Promise<SendEvent | null>;
 }
 
-export default function SendingLogs({ events, domains, onCopy }: SendingLogsProps) {
+export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: SendingLogsProps) {
   const { t } = useI18n();
   const [domain, setDomain] = useState<string>();
   const [status, setStatus] = useState<string>();
   const [recipient, setRecipient] = useState('');
   const [range, setRange] = useState<[number, number] | null>(null);
   const [selected, setSelected] = useState<SendEvent | null>(null);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [detailError, setDetailError] = useState('');
 
   const filtered = useMemo(() => {
     return events.filter((event) => {
@@ -50,7 +54,7 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     },
     { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
-    { title: t('domains.actions'), render: (_, event) => <Button onClick={() => setSelected(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
+    { title: t('domains.actions'), render: (_, event) => <Button onClick={() => void openDetail(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
   ];
 
   return (
@@ -116,22 +120,45 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
 
       <DeliveryLogDrawer
         event={selected}
+        loading={detailLoading}
+        error={detailError}
         onClose={() => setSelected(null)}
         onCopy={onCopy}
       />
     </>
   );
 
+  async function openDetail(event: SendEvent) {
+    setSelected(event);
+    setDetailError('');
+    if (!onLoadEvent) return;
+    setDetailLoading(true);
+    try {
+      const detail = await onLoadEvent(event.id);
+      if (detail) setSelected(detail);
+      if (!detail) setDetailError(t('logs.detailNotFound'));
+    } catch (error) {
+      setDetailError(error instanceof Error ? error.message : t('logs.detailLoadFailed'));
+    } finally {
+      setDetailLoading(false);
+    }
+  }
+
   function DeliveryLogDrawer({
     event,
+    loading,
+    error,
     onClose,
     onCopy
   }: {
     event: SendEvent | null;
+    loading: boolean;
+    error: string;
     onClose: () => void;
     onCopy: (value: string) => void;
   }) {
     const deliveryLog = event?.deliveryLog || [];
+    const trackingTimeline = buildEventTimeline(event);
     return (
       <Drawer
         title={event ? `${t('logs.detailTitle')} · mh-${event.id}` : t('logs.detailTitle')}
@@ -145,59 +172,121 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
         ) : null}
       >
         {event ? (
-          <Space direction="vertical" size={16} className="full-width">
-            <Descriptions bordered size="small" column={1}>
-              <Descriptions.Item label={t('logs.time')}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item>
-              <Descriptions.Item label={t('logs.sender')}>{event.sender}</Descriptions.Item>
-              <Descriptions.Item label={t('logs.recipient')}>{event.recipients.join(', ')}</Descriptions.Item>
-              <Descriptions.Item label={t('logs.domain')}>{event.domain || '-'}</Descriptions.Item>
-              <Descriptions.Item label={t('logs.subject')}>{event.subject || '-'}</Descriptions.Item>
-              <Descriptions.Item label={t('common.status')}>
-                <StatusTag status={event.status} />
-              </Descriptions.Item>
-              <Descriptions.Item label={t('logs.messageId')}>mh-{event.id}</Descriptions.Item>
-              <Descriptions.Item label={t('logs.queueId')}>
-                <Typography.Text code>{event.queueId || '-'}</Typography.Text>
-              </Descriptions.Item>
-              <Descriptions.Item label={t('logs.deliveredAt')}>
-                {event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}
-              </Descriptions.Item>
-              <Descriptions.Item label={t('logs.finalResponse')}>
-                <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
-              </Descriptions.Item>
-            </Descriptions>
-            <SectionCard title={t('logs.deliveryAttempts')} className="delivery-log-card">
-              {event.deliveryAttempts?.length ? (
-                <Timeline
-                  items={event.deliveryAttempts.map((attempt, index) => ({
-                    key: `${attempt.raw || attempt.at}-${index}`,
-                    color: deliveryAttemptColor(attempt.status),
-                    children: <DeliveryAttemptTimelineItem attempt={attempt} />
-                  }))}
-                />
-              ) : (
-                <EmptyState description={t('logs.noDeliveryAttempts')} />
-              )}
-            </SectionCard>
-            <SectionCard title={t('logs.deliveryLog')} className="delivery-log-card">
-              {deliveryLog.length ? (
-                <Timeline
-                  items={deliveryLog.map((entry, index) => ({
-                    key: `${entry.at}-${index}`,
-                    color: timelineColor(entry),
-                    children: <DeliveryLogTimelineItem entry={entry} />
-                  }))}
-                />
-              ) : (
-                <EmptyState description={t('logs.noDeliveryLog')} />
-              )}
-            </SectionCard>
-          </Space>
+          <Spin spinning={loading}>
+            <Space direction="vertical" size={16} className="full-width">
+              {error ? <Alert type="error" showIcon message={error} /> : null}
+              <Descriptions bordered size="small" column={1}>
+                <Descriptions.Item label={t('logs.time')}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.sender')}>{event.sender}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.recipient')}>{event.recipients.join(', ')}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.domain')}>{event.domain || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.subject')}>{event.subject || '-'}</Descriptions.Item>
+                <Descriptions.Item label={t('common.status')}>
+                  <StatusTag status={event.status} />
+                </Descriptions.Item>
+                <Descriptions.Item label={t('logs.messageId')}>mh-{event.id}</Descriptions.Item>
+                <Descriptions.Item label={t('logs.queueId')}>
+                  <Typography.Text code>{event.queueId || '-'}</Typography.Text>
+                </Descriptions.Item>
+                <Descriptions.Item label={t('logs.deliveredAt')}>
+                  {event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}
+                </Descriptions.Item>
+                <Descriptions.Item label={t('logs.finalResponse')}>
+                  <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
+                </Descriptions.Item>
+              </Descriptions>
+              <SectionCard title={t('logs.trackingTimeline')} className="delivery-log-card">
+                {trackingTimeline.length ? (
+                  <Timeline
+                    items={trackingTimeline.map((item, index) => ({
+                      key: `${item.stage}-${item.at}-${index}`,
+                      color: timelineToneColor(item.tone),
+                      children: <TrackingTimelineItem item={item} />
+                    }))}
+                  />
+                ) : (
+                  <EmptyState description={t('logs.noTrackingTimeline')} />
+                )}
+              </SectionCard>
+              <SectionCard title={t('logs.deliveryAttempts')} className="delivery-log-card">
+                {event.deliveryAttempts?.length ? (
+                  <Timeline
+                    items={event.deliveryAttempts.map((attempt, index) => ({
+                      key: `${attempt.raw || attempt.at}-${index}`,
+                      color: deliveryAttemptColor(attempt.status),
+                      children: <DeliveryAttemptTimelineItem attempt={attempt} />
+                    }))}
+                  />
+                ) : (
+                  <EmptyState description={t('logs.noDeliveryAttempts')} />
+                )}
+              </SectionCard>
+              <SectionCard title={t('logs.webhookDeliveries')} className="delivery-log-card">
+                {event.webhookDeliveries?.length ? (
+                  <WebhookDeliveriesTable deliveries={event.webhookDeliveries} />
+                ) : (
+                  <EmptyState description={t('logs.noWebhookDeliveries')} />
+                )}
+              </SectionCard>
+              <SectionCard title={t('logs.deliveryLog')} className="delivery-log-card">
+                {deliveryLog.length ? (
+                  <Timeline
+                    items={deliveryLog.map((entry, index) => ({
+                      key: `${entry.at}-${index}`,
+                      color: timelineColor(entry),
+                      children: <DeliveryLogTimelineItem entry={entry} />
+                    }))}
+                  />
+                ) : (
+                  <EmptyState description={t('logs.noDeliveryLog')} />
+                )}
+              </SectionCard>
+            </Space>
+          </Spin>
         ) : null}
       </Drawer>
     );
   }
 
+  function TrackingTimelineItem({ item }: { item: SendEventTimelineEntry }) {
+    return (
+      <div className="delivery-log-entry">
+        <Space wrap size={8}>
+          <Typography.Text strong>{timelineStageLabel(item.stage)}</Typography.Text>
+          {item.status ? <StatusPill tone={item.tone}>{webhookStatusLabel(item.status)}</StatusPill> : null}
+          <Typography.Text type="secondary">{item.at ? new Date(item.at).toLocaleString() : '-'}</Typography.Text>
+        </Space>
+        {item.queueId ? <LogLine label="Q" value={item.queueId} /> : null}
+        {item.recipient ? <LogLine label="To" value={item.recipient} /> : null}
+        {item.relay ? <LogLine label="MX" value={item.relay} /> : null}
+        {item.response ? <LogLine label="S" value={item.response} /> : null}
+        {item.webhookId ? <LogLine label="WH" value={`#${item.webhookId}${item.responseStatus ? ` · HTTP ${item.responseStatus}` : ''}`} /> : null}
+      </div>
+    );
+  }
+
+  function WebhookDeliveriesTable({ deliveries }: { deliveries: WebhookDelivery[] }) {
+    const columns: ColumnsType<WebhookDelivery> = [
+      { title: t('webhooks.events'), dataIndex: 'eventType', width: 120 },
+      {
+        title: t('common.status'),
+        dataIndex: 'status',
+        width: 130,
+        render: (value: string) => <StatusPill tone={webhookTone(value)}>{webhookStatusLabel(value)}</StatusPill>
+      },
+      { title: t('webhooks.attemptCount'), dataIndex: 'attemptCount', width: 90 },
+      { title: 'HTTP', dataIndex: 'responseStatus', width: 90, render: (value) => value ?? '-' },
+      {
+        title: t('logs.time'),
+        dataIndex: 'lastAttemptAt',
+        width: 180,
+        render: (value, record) => new Date(value || record.createdAt).toLocaleString()
+      },
+      { title: t('logs.errorReason'), dataIndex: 'error', ellipsis: true, render: (value) => value || '-' }
+    ];
+    return <Table rowKey="id" size="small" columns={columns} dataSource={deliveries} pagination={false} scroll={{ x: 760 }} />;
+  }
+
   function DeliveryLogTimelineItem({ entry }: { entry: DeliveryLogEntry }) {
     return (
       <div className="delivery-log-entry">
@@ -266,6 +355,17 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
         lines.push('');
       }
     }
+    if (event.webhookDeliveries?.length) {
+      lines.push(t('logs.webhookDeliveries'));
+      for (const delivery of event.webhookDeliveries) {
+        lines.push(`[${delivery.lastAttemptAt || delivery.createdAt || '-'}] ${delivery.eventType} ${webhookStatusLabel(delivery.status)}`);
+        lines.push(`Webhook: #${delivery.webhookId}`);
+        lines.push(`Attempts: ${delivery.attemptCount}`);
+        if (delivery.responseStatus) lines.push(`HTTP: ${delivery.responseStatus}`);
+        if (delivery.error) lines.push(`${t('logs.errorReason')}: ${delivery.error}`);
+        lines.push('');
+      }
+    }
     const entries = event.deliveryLog?.length ? event.deliveryLog : [{
       at: event.createdAt,
       phase: 'legacy',
@@ -307,6 +407,40 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     }[status] as 'success' | 'warning' | 'error' | 'info' | 'neutral' || 'neutral';
   }
 
+  function timelineStageLabel(stage: string) {
+    return {
+      submitted: t('logs.stageSubmitted'),
+      accepted: t('logs.stageAccepted'),
+      delivered: t('logs.stageDelivered'),
+      pending: t('logs.stagePending'),
+      failed: t('logs.stageFailed'),
+      webhook: t('logs.stageWebhook')
+    }[stage] || stage;
+  }
+
+  function webhookStatusLabel(status: string) {
+    return {
+      pending: t('webhooks.statusPending'),
+      processing: t('webhooks.statusProcessing'),
+      success: t('webhooks.statusSuccess'),
+      dead: t('webhooks.statusDead')
+    }[status] || statusLabel(status);
+  }
+
+  function webhookTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
+    if (status === 'success') return 'success';
+    if (status === 'dead') return 'error';
+    if (status === 'pending' || status === 'processing') return 'warning';
+    return statusTone(status);
+  }
+
+  function timelineToneColor(tone: string) {
+    if (tone === 'success') return 'green';
+    if (tone === 'warning') return 'gold';
+    if (tone === 'error') return 'red';
+    return 'blue';
+  }
+
   function timelineColor(entry: DeliveryLogEntry) {
     if (entry.ok === false || entry.phase === 'error') return 'red';
     if (entry.phase === 'queue') return 'green';

+ 6 - 0
src/server.js

@@ -25,6 +25,7 @@ import {
   getDnsCredential,
   getDomain,
   getDomainByName,
+  getSendEvent,
   getSendAnalytics,
   getSettings,
   getDefaultSmtpRelay,
@@ -295,6 +296,11 @@ async function handleApi(req, res, url, user) {
   if (method === 'GET' && pathname === '/api/events') {
     return sendJson(res, 200, { events: listSendEvents(user.id) });
   }
+  const sendEventMatch = pathname.match(/^\/api\/events\/(\d+)$/);
+  if (sendEventMatch && method === 'GET') {
+    const event = getSendEvent(user.id, Number(sendEventMatch[1]));
+    return sendJson(res, event ? 200 : 404, { event });
+  }
   if (method === 'GET' && pathname === '/api/analytics') {
     return sendJson(res, 200, { analytics: getSendAnalytics(user.id, { days: Number(url.searchParams.get('days') || 7) }) });
   }

+ 112 - 0
test/db.test.js

@@ -14,10 +14,12 @@ import {
   createDomain,
   createUser,
   createUserWithAccountToken,
+  createWebhook,
   deleteSmtpCredential,
   deleteSmtpRelay,
   getDnsCredential,
   getDomain,
+  getSendEvent,
   getSendAnalytics,
   getSmtpRelay,
   getSmtpCredential,
@@ -262,6 +264,116 @@ test('stores multiple outbound smtp relays with encrypted recoverable passwords'
   assert.equal(getSmtpRelay(backup.id, alice.id), null);
 });
 
+test('returns scoped send event detail with webhook deliveries', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('detail.example'));
+  createWebhook(alice.id, {
+    name: 'Delivery audit',
+    url: 'https://hooks.example.com/mailhub',
+    events: ['sent']
+  });
+
+  const eventId = logSendEvent({
+    userId: alice.id,
+    domainId: domain.id,
+    sender: 'noreply@detail.example',
+    recipients: ['user@example.com'],
+    subject: 'Tracked message',
+    status: 'sent',
+    detail: '250 2.0.0 queued as QDETAIL',
+    queueId: 'QDETAIL',
+    deliveryAttempts: [{
+      at: '2026-07-09T12:00:00.000Z',
+      queueId: 'QDETAIL',
+      recipient: 'user@example.com',
+      relay: 'mx.example.net',
+      dsn: '2.0.0',
+      status: 'sent',
+      response: '250 2.0.0 ok'
+    }],
+    deliveryLog: [{
+      at: '2026-07-09T11:59:58.000Z',
+      phase: 'queue',
+      direction: 'server',
+      response: '250 2.0.0 queued as QDETAIL',
+      ok: true
+    }],
+    deliveredAt: '2026-07-09T12:00:00.000Z'
+  });
+
+  const detail = getSendEvent(alice.id, eventId);
+
+  assert.equal(detail.id, eventId);
+  assert.equal(detail.domain, 'detail.example');
+  assert.equal(detail.queueId, 'QDETAIL');
+  assert.equal(detail.deliveryAttempts[0].relay, 'mx.example.net');
+  assert.equal(detail.deliveryLog[0].phase, 'queue');
+  assert.equal(detail.webhookDeliveries.length, 1);
+  assert.equal(detail.webhookDeliveries[0].sendEventId, eventId);
+  assert.equal(detail.webhookDeliveries[0].eventType, 'sent');
+  assert.equal(getSendEvent(bob.id, eventId), null);
+});
+
+test('send analytics includes delivery funnel and top failure reasons', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('analytics.example'));
+
+  for (const [index, status] of ['sent', 'sent', 'queued'].entries()) {
+    logSendEvent({
+      userId: alice.id,
+      domainId: domain.id,
+      sender: 'noreply@analytics.example',
+      recipients: ['user@example.com'],
+      subject: `Message ${status}`,
+      status,
+      detail: status === 'queued' ? 'queued as QPENDING' : '250 2.0.0 ok',
+      queueId: status === 'queued' ? 'QPENDING' : `QSENT${index + 1}`
+    });
+  }
+  logSendEvent({
+    userId: alice.id,
+    domainId: domain.id,
+    sender: 'noreply@analytics.example',
+    recipients: ['bad@example.com'],
+    subject: 'Bounce',
+    status: 'bounced',
+    detail: 'Mailbox unavailable',
+    queueId: 'QBOUNCE'
+  });
+  logSendEvent({
+    userId: alice.id,
+    domainId: domain.id,
+    sender: 'noreply@analytics.example',
+    recipients: ['bad2@example.com'],
+    subject: 'Failure',
+    status: 'failed',
+    detail: 'Mailbox unavailable'
+  });
+
+  const analytics = getSendAnalytics(alice.id, { days: 7 });
+
+  assert.equal(analytics.summary.submitted, 5);
+  assert.equal(analytics.summary.accepted, 4);
+  assert.equal(analytics.summary.delivered, 2);
+  assert.equal(analytics.summary.pending, 1);
+  assert.equal(analytics.summary.failed, 2);
+  assert.equal(analytics.summary.acceptanceRate, 80);
+  assert.equal(analytics.summary.deliveryRate, 40);
+  assert.equal(analytics.summary.failureRate, 40);
+  assert.deepEqual(analytics.deliveryFunnel.map((item) => [item.stage, item.total, item.rate]), [
+    ['submitted', 5, 100],
+    ['accepted', 4, 80],
+    ['delivered', 2, 40],
+    ['pending', 1, 20],
+    ['failed', 2, 40]
+  ]);
+  assert.equal(analytics.failureReasons[0].reason, 'Mailbox unavailable');
+  assert.equal(analytics.failureReasons[0].total, 2);
+});
+
 test('stores account tokens as hashes and enforces token lifecycle', () => {
   const database = initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });

+ 55 - 0
test/frontend-analytics-model.test.js

@@ -3,6 +3,8 @@ import { test } from 'node:test';
 
 import {
   buildDashboardSummary,
+  buildDeliveryFunnel,
+  buildEventTimeline,
   buildDomainRanking,
   buildHourlyHeatmap,
   buildStatusDistribution,
@@ -88,6 +90,59 @@ test('normalizes chart datasets for trend, status, ranking, and hourly views', (
   });
 });
 
+test('builds delivery funnel and event timeline models', () => {
+  const analytics = {
+    deliveryFunnel: [
+      { stage: 'submitted', total: 5, rate: 100 },
+      { stage: 'accepted', total: 4, rate: 80 },
+      { stage: 'delivered', total: 2, rate: 40 },
+      { stage: 'pending', total: 1, rate: 20 },
+      { stage: 'failed', total: 2, rate: 40 }
+    ]
+  };
+
+  assert.deepEqual(buildDeliveryFunnel(analytics), [
+    { stage: 'submitted', total: 5, rate: 100, tone: 'info' },
+    { stage: 'accepted', total: 4, rate: 80, tone: 'info' },
+    { stage: 'delivered', total: 2, rate: 40, tone: 'success' },
+    { stage: 'pending', total: 1, rate: 20, tone: 'warning' },
+    { stage: 'failed', total: 2, rate: 40, tone: 'error' }
+  ]);
+
+  const timeline = buildEventTimeline({
+    id: 7,
+    status: 'sent',
+    queueId: 'Q7',
+    createdAt: '2026-07-09T11:59:00.000Z',
+    deliveredAt: '2026-07-09T12:00:00.000Z',
+    deliveryAttempts: [{
+      at: '2026-07-09T12:00:00.000Z',
+      queueId: 'Q7',
+      status: 'sent',
+      recipient: 'user@example.com',
+      relay: 'mx.example.net',
+      response: '250 ok'
+    }],
+    webhookDeliveries: [{
+      id: 11,
+      webhookId: 3,
+      userId: 1,
+      sendEventId: 7,
+      eventType: 'sent',
+      status: 'success',
+      attemptCount: 1,
+      createdAt: '2026-07-09T12:00:01.000Z',
+      lastAttemptAt: '2026-07-09T12:00:02.000Z',
+      responseStatus: 200
+    }]
+  });
+
+  assert.deepEqual(timeline.map((item) => item.stage), ['submitted', 'accepted', 'delivered', 'webhook']);
+  assert.equal(timeline[1].queueId, 'Q7');
+  assert.equal(timeline[2].tone, 'success');
+  assert.equal(timeline[3].status, 'success');
+});
+
 function domain(verified, records) {
   return {
     status: {

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

@@ -276,7 +276,17 @@ test('users can manage outbound smtp relays with recoverable passwords and send
 
     const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
     assert.equal(events.status, 200);
-    assert.equal((await events.json()).events[0].smtpRelayId, created.relay.id);
+    const eventsBody = await events.json();
+    assert.equal(eventsBody.events[0].smtpRelayId, created.relay.id);
+
+    const eventDetail = await fetch(`${baseUrl}/api/events/${eventsBody.events[0].id}`, {
+      headers: { Cookie: cookie }
+    });
+    assert.equal(eventDetail.status, 200);
+    const eventDetailBody = await eventDetail.json();
+    assert.equal(eventDetailBody.event.id, eventsBody.events[0].id);
+    assert.equal(eventDetailBody.event.smtpRelayId, created.relay.id);
+    assert.equal(Array.isArray(eventDetailBody.event.webhookDeliveries), true);
   } finally {
     child.kill('SIGTERM');
     await waitForExit(child, 1000);

部分文件因文件數量過多而無法顯示