浏览代码

feat(tracking): add open and click analytics

AI-Co-Authored-By: Codex
chendeben 1 月之前
父节点
当前提交
e98c69cd49

+ 6 - 0
.env.example

@@ -38,6 +38,12 @@ SEND_REQUIRES_VERIFIED=false
 DELIVERY_TRACKING_ENABLED=true
 POSTFIX_LOG_POLL_INTERVAL_MS=5000
 
+# Engagement tracking uses opaque public links and a stable encryption key.
+TRACKING_SECRET=change-this-separate-long-random-tracking-secret
+ENGAGEMENT_TRACKING_ENABLED=false
+TRACKING_RETENTION_DAYS=180
+TRUST_PROXY=true
+
 # DMARC defaults.
 DMARC_POLICY=none
 DMARC_RUA=

+ 93 - 26
package-lock.json

@@ -11,7 +11,9 @@
       "dependencies": {
         "@ant-design/icons": "^6.3.2",
         "@ant-design/plots": "^2.6.8",
+        "@zone-eu/mailsplit": "^5.4.14",
         "antd": "^5.29.3",
+        "parse5": "^8.0.1",
         "react": "^19.2.7",
         "react-dom": "^19.2.7"
       },
@@ -404,31 +406,6 @@
         "node": ">=6.9.0"
       }
     },
-    "node_modules/@emnapi/core": {
-      "version": "1.11.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
-      "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.2.2",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.11.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
-      "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@emnapi/wasi-threads": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1093,6 +1070,7 @@
       "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -1133,6 +1111,17 @@
         }
       }
     },
+    "node_modules/@zone-eu/mailsplit": {
+      "version": "5.4.14",
+      "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
+      "integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
+      "license": "(MIT OR EUPL-1.1+)",
+      "dependencies": {
+        "libbase64": "1.3.0",
+        "libmime": "5.4.1",
+        "libqp": "2.1.1"
+      }
+    },
     "node_modules/antd": {
       "version": "5.29.3",
       "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1610,7 +1599,8 @@
       "version": "1.11.21",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
       "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/detect-libc": {
       "version": "2.1.2",
@@ -1622,6 +1612,27 @@
         "node": ">=8"
       }
     },
+    "node_modules/encoding-japanese": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
+      "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/eventemitter3": {
       "version": "5.0.4",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -1743,6 +1754,46 @@
         "string-convert": "^0.2.0"
       }
     },
+    "node_modules/libbase64": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
+      "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
+      "license": "MIT"
+    },
+    "node_modules/libmime": {
+      "version": "5.4.1",
+      "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
+      "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
+      "license": "MIT",
+      "dependencies": {
+        "encoding-japanese": "2.2.0",
+        "iconv-lite": "0.7.3",
+        "libbase64": "1.3.0",
+        "libqp": "2.1.1"
+      }
+    },
+    "node_modules/libmime/node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/libqp": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
+      "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
+      "license": "MIT"
+    },
     "node_modules/lightningcss": {
       "version": "1.32.0",
       "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2029,6 +2080,18 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+      "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^8.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
     "node_modules/pdfast": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz",
@@ -2048,6 +2111,7 @@
       "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -2695,6 +2759,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
       "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -2704,6 +2769,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
       "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -2897,6 +2963,7 @@
       "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",

+ 2 - 0
package.json

@@ -38,7 +38,9 @@
   "dependencies": {
     "@ant-design/icons": "^6.3.2",
     "@ant-design/plots": "^2.6.8",
+    "@zone-eu/mailsplit": "^5.4.14",
     "antd": "^5.29.3",
+    "parse5": "^8.0.1",
     "react": "^19.2.7",
     "react-dom": "^19.2.7"
   },

文件差异内容过多而无法显示
+ 0 - 1
public/assets/index-DVZJ2w6n.js


文件差异内容过多而无法显示
+ 0 - 0
public/assets/login-DhHJ-nET.js


文件差异内容过多而无法显示
+ 0 - 0
public/assets/styles-5CnVXnu6.js


文件差异内容过多而无法显示
+ 0 - 0
public/assets/styles-HcGFnvy-.css


文件差异内容过多而无法显示
+ 0 - 0
public/assets/styles-yTEP6Wre.css


+ 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-Bw7rCACo.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-hME2W18a.js">
+    <script type="module" crossorigin src="/assets/index-DVZJ2w6n.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-5CnVXnu6.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-HcGFnvy-.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-yTEP6Wre.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-CUR5060y.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-hME2W18a.js">
+    <script type="module" crossorigin src="/assets/login-DhHJ-nET.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-5CnVXnu6.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-HcGFnvy-.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-yTEP6Wre.css">
   </head>
   <body>
     <div id="auth-root"></div>

文件差异内容过多而无法显示
+ 667 - 144
src/db.js


+ 84 - 1
src/frontend/analytics-model.js

@@ -105,6 +105,55 @@ export function buildHourlyHeatmap(analytics = null) {
   }));
 }
 
+/**
+ * @param {any} [analytics]
+ */
+export function buildEngagementSummary(analytics = null) {
+  const engagement = analytics?.engagement || {};
+  return {
+    trackedDelivered: Number(engagement.trackedDelivered || 0),
+    totalOpens: Number(engagement.totalOpens || 0),
+    uniqueOpens: Number(engagement.uniqueOpens || 0),
+    proxyOpens: Number(engagement.proxyOpens || 0),
+    totalClicks: Number(engagement.totalClicks || 0),
+    uniqueClicks: Number(engagement.uniqueClicks || 0),
+    scannerEvents: Number(engagement.scannerEvents || 0),
+    openRate: Number(engagement.openRate || 0),
+    clickRate: Number(engagement.clickRate || 0),
+    clickToOpenRate: Number(engagement.clickToOpenRate || 0)
+  };
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{date: string; opens: number; uniqueOpens: number; clicks: number; uniqueClicks: number; scannerEvents: number}>}
+ */
+export function buildEngagementTrend(analytics = null) {
+  return (analytics?.engagementByDay || []).map((item) => ({
+    date: item.day || item.date,
+    opens: Number(item.opens || 0),
+    uniqueOpens: Number(item.uniqueOpens || 0),
+    clicks: Number(item.clicks || 0),
+    uniqueClicks: Number(item.uniqueClicks || 0),
+    scannerEvents: Number(item.scannerEvents || 0)
+  }));
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{fingerprint: string; target: string; targetOrigin: string; clicks: number; uniqueClicks: number; lastClickedAt: string}>}
+ */
+export function buildTopLinks(analytics = null) {
+  return (analytics?.topLinks || []).map((item) => ({
+    fingerprint: item.fingerprint || '',
+    target: item.target || item.targetOrigin || '',
+    targetOrigin: item.targetOrigin || '',
+    clicks: Number(item.clicks || 0),
+    uniqueClicks: Number(item.uniqueClicks || 0),
+    lastClickedAt: item.lastClickedAt || ''
+  }));
+}
+
 /**
  * @param {any} [event]
  * @returns {Array<{
@@ -163,6 +212,21 @@ export function buildEventTimeline(event = null) {
       queueId: event.queueId
     });
   }
+  const trackingEvents = Array.isArray(event.tracking?.events) ? event.tracking.events : [];
+  for (const trackingEvent of trackingEvents) {
+    const stage = trackingEvent.eventType === 'click' ? 'clicked' : 'opened';
+    timeline.push({
+      stage,
+      at: trackingEvent.occurredAt || '',
+      tone: trackingEvent.source === 'scanner'
+        ? 'neutral'
+        : stage === 'clicked' ? 'success' : 'info',
+      status: trackingEvent.source,
+      source: trackingEvent.source,
+      targetOrigin: trackingEvent.targetOrigin || '',
+      trackingLinkId: trackingEvent.trackingLinkId ?? null
+    });
+  }
   const webhookDeliveries = Array.isArray(event.webhookDeliveries) ? event.webhookDeliveries : [];
   for (const delivery of webhookDeliveries) {
     timeline.push({
@@ -174,7 +238,26 @@ export function buildEventTimeline(event = null) {
       responseStatus: delivery.responseStatus
     });
   }
-  return timeline;
+  const stageOrder = {
+    submitted: 0,
+    accepted: 1,
+    delivered: 2,
+    pending: 2,
+    failed: 2,
+    opened: 3,
+    clicked: 4,
+    webhook: 5
+  };
+  return timeline
+    .map((item, index) => ({ item, index, time: Date.parse(item.at) }))
+    .sort((left, right) => {
+      const leftTime = Number.isNaN(left.time) ? Number.POSITIVE_INFINITY : left.time;
+      const rightTime = Number.isNaN(right.time) ? Number.POSITIVE_INFINITY : right.time;
+      if (leftTime !== rightTime) return leftTime - rightTime;
+      const stageDifference = (stageOrder[left.item.stage] ?? 99) - (stageOrder[right.item.stage] ?? 99);
+      return stageDifference || left.index - right.index;
+    })
+    .map(({ item }) => item);
 }
 
 function deliveryAttemptStage(status) {

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

@@ -88,6 +88,24 @@ const messages = {
     'dashboard.stageDelivered': '已送达',
     'dashboard.stagePending': '等待投递',
     'dashboard.stageFailed': '投递失败',
+    'dashboard.opens': '打开',
+    'dashboard.clicks': '点击',
+    'dashboard.openRate': '打开率',
+    'dashboard.clickRate': '点击率',
+    'dashboard.clickToOpenRate': '点击打开率',
+    'dashboard.unique': '独立',
+    'dashboard.total': '总计',
+    'dashboard.trackedDelivered': '已追踪送达',
+    'dashboard.measuredActivity': '互动次数',
+    'dashboard.proxyOpens': '代理打开',
+    'dashboard.scannerEvents': '扫描器事件',
+    'dashboard.engagementTrend': '打开与点击趋势',
+    'dashboard.topLinks': '链接点击排行',
+    'dashboard.link': '链接',
+    'dashboard.uniqueClicks': '独立点击',
+    'dashboard.totalClicks': '总点击',
+    'dashboard.lastClicked': '最近点击',
+    'dashboard.noEngagement': '暂无互动数据',
     'domains.title': '发信域名',
     'domains.domain': '域名',
     'domains.senderHost': '发信主机',
@@ -194,6 +212,8 @@ const messages = {
     'logs.deliveredAt': '最终投递时间',
     'logs.trackingTimeline': '投递时间线',
     'logs.noTrackingTimeline': '暂无可用的投递时间线。',
+    'logs.trackingTimelineTruncated': '时间线仅显示最近 500 条互动事件,汇总统计仍包含全部事件。',
+    'logs.trackingLinksTruncated': '链接明细仅显示点击量最高的 200 条,汇总统计仍包含全部链接。',
     'logs.deliveryAttempts': '投递尝试',
     'logs.noDeliveryAttempts': '暂无最终投递回执,可能仍在队列中或属于历史记录。',
     'logs.webhookDeliveries': 'Webhook 回调',
@@ -205,7 +225,21 @@ const messages = {
     'logs.stageDelivered': '已送达',
     'logs.stagePending': '等待投递',
     'logs.stageFailed': '投递失败',
+    'logs.stageOpened': '已打开',
+    'logs.stageClicked': '已点击',
     'logs.stageWebhook': 'Webhook 回调',
+    'logs.engagement': '互动追踪',
+    'logs.opens': '打开',
+    'logs.clicks': '点击',
+    'logs.trackingScope': '统计范围',
+    'logs.messageLevel': '消息级(多收件人)',
+    'logs.recipientLevel': '单收件人',
+    'logs.firstOpened': '首次打开',
+    'logs.lastOpened': '最近打开',
+    'logs.firstClicked': '首次点击',
+    'logs.lastClicked': '最近点击',
+    'logs.proxyOpens': '代理打开',
+    'logs.scannerEvents': '扫描器事件',
     'logs.statusQueued': '队列中',
     'logs.statusSent': '已送达',
     'logs.statusDeferred': '暂时失败',
@@ -263,6 +297,7 @@ const messages = {
     'settings.deliveryChecks': '发信环境检查',
     'settings.noPermission': '当前账号没有系统设置权限。',
     'settings.save': '保存设置',
+    'settings.engagementTracking': '默认启用打开与点击追踪',
     'admin.title': '管理员面板',
     'admin.users': '用户',
     'admin.resources': '资源',
@@ -336,6 +371,8 @@ const messages = {
     'webhooks.eventSent': '已送达',
     'webhooks.eventBounced': '已退信',
     'webhooks.eventFailed': '失败',
+    'webhooks.eventOpened': '已打开',
+    'webhooks.eventClicked': '已点击',
     'webhooks.enabled': '启用',
     'webhooks.disabled': '已停用',
     'webhooks.scope': '作用域',
@@ -509,6 +546,24 @@ const messages = {
     'dashboard.stageDelivered': 'Delivered',
     'dashboard.stagePending': 'Pending',
     'dashboard.stageFailed': 'Failed',
+    'dashboard.opens': 'Opens',
+    'dashboard.clicks': 'Clicks',
+    'dashboard.openRate': 'Open rate',
+    'dashboard.clickRate': 'Click rate',
+    'dashboard.clickToOpenRate': 'Click-to-open rate',
+    'dashboard.unique': 'unique',
+    'dashboard.total': 'total',
+    'dashboard.trackedDelivered': 'Tracked delivered',
+    'dashboard.measuredActivity': 'Measured activity',
+    'dashboard.proxyOpens': 'Proxy opens',
+    'dashboard.scannerEvents': 'Scanner events',
+    'dashboard.engagementTrend': 'Open and click trend',
+    'dashboard.topLinks': 'Top links',
+    'dashboard.link': 'Link',
+    'dashboard.uniqueClicks': 'Unique clicks',
+    'dashboard.totalClicks': 'Total clicks',
+    'dashboard.lastClicked': 'Last clicked',
+    'dashboard.noEngagement': 'No engagement data yet',
     'domains.title': 'Sending domains',
     'domains.domain': 'Domain',
     'domains.senderHost': 'Sending host',
@@ -615,6 +670,8 @@ const messages = {
     'logs.deliveredAt': 'Delivered at',
     'logs.trackingTimeline': 'Delivery timeline',
     'logs.noTrackingTimeline': 'No delivery timeline is available.',
+    'logs.trackingTimelineTruncated': 'Only the latest 500 engagement events are shown; summary metrics include all events.',
+    'logs.trackingLinksTruncated': 'Only the top 200 links by clicks are shown; summary metrics include all links.',
     'logs.deliveryAttempts': 'Delivery attempts',
     'logs.noDeliveryAttempts': 'No final delivery receipt yet. The message may still be queued or historical.',
     'logs.webhookDeliveries': 'Webhook callbacks',
@@ -626,7 +683,21 @@ const messages = {
     'logs.stageDelivered': 'Delivered',
     'logs.stagePending': 'Pending',
     'logs.stageFailed': 'Failed',
+    'logs.stageOpened': 'Opened',
+    'logs.stageClicked': 'Clicked',
     'logs.stageWebhook': 'Webhook callback',
+    'logs.engagement': 'Engagement tracking',
+    'logs.opens': 'Opens',
+    'logs.clicks': 'Clicks',
+    'logs.trackingScope': 'Tracking scope',
+    'logs.messageLevel': 'Message-level (multiple recipients)',
+    'logs.recipientLevel': 'Single recipient',
+    'logs.firstOpened': 'First opened',
+    'logs.lastOpened': 'Last opened',
+    'logs.firstClicked': 'First clicked',
+    'logs.lastClicked': 'Last clicked',
+    'logs.proxyOpens': 'Proxy opens',
+    'logs.scannerEvents': 'Scanner events',
     'logs.statusQueued': 'Queued',
     'logs.statusSent': 'Sent',
     'logs.statusDeferred': 'Deferred',
@@ -684,6 +755,7 @@ const messages = {
     'settings.deliveryChecks': 'Sending Environment Checks',
     'settings.noPermission': 'This account cannot access system settings.',
     'settings.save': 'Save settings',
+    'settings.engagementTracking': 'Enable open and click tracking by default',
     'admin.title': 'Admin Panel',
     'admin.users': 'Users',
     'admin.resources': 'Resources',
@@ -757,6 +829,8 @@ const messages = {
     'webhooks.eventSent': 'Sent',
     'webhooks.eventBounced': 'Bounced',
     'webhooks.eventFailed': 'Failed',
+    'webhooks.eventOpened': 'Opened',
+    'webhooks.eventClicked': 'Clicked',
     'webhooks.enabled': 'Enabled',
     'webhooks.disabled': 'Disabled',
     'webhooks.scope': 'Scope',

+ 8 - 1
src/frontend/styles.css

@@ -48,6 +48,10 @@ body {
   z-index: 20;
 }
 
+.admin-sider.ant-layout-sider-zero-width {
+  overflow: visible;
+}
+
 .admin-sider-inner {
   display: flex;
   flex-direction: column;
@@ -503,6 +507,10 @@ body {
   margin-top: 6px;
 }
 
+.delivery-log-card .ant-table-wrapper {
+  margin-top: 16px;
+}
+
 .delivery-log-entry {
   display: grid;
   gap: 8px;
@@ -822,7 +830,6 @@ body {
   .admin-sider {
     height: auto;
     max-height: none;
-    position: static !important;
   }
 
   .admin-content {

+ 81 - 1
src/frontend/types.ts

@@ -44,6 +44,7 @@ export interface RuntimeConfig {
   dmarcPolicy: string;
   dmarcRua: string;
   sendRequiresVerified: boolean;
+  engagementTrackingEnabled: boolean;
   systemChecks?: SystemChecks;
   submission?: {
     enabled: boolean;
@@ -290,6 +291,56 @@ export interface SendEventTimelineEntry {
   response?: string;
   webhookId?: number;
   responseStatus?: number | null;
+  source?: string;
+  targetOrigin?: string;
+  trackingLinkId?: number | null;
+}
+
+export interface TrackingEvent {
+  id: number;
+  sendEventId: number;
+  trackingLinkId?: number | null;
+  eventType: 'open' | 'click';
+  source: 'direct' | 'proxy' | 'scanner';
+  occurredAt: string;
+  userAgent?: string;
+  targetOrigin?: string;
+}
+
+export interface TrackingLink {
+  id: number;
+  target: string;
+  targetOrigin: string;
+  clicks: number;
+  firstClickedAt?: string | null;
+  lastClickedAt?: string | null;
+}
+
+export interface TrackingSummary {
+  totalOpens: number;
+  totalClicks: number;
+  uniqueOpen: boolean;
+  uniqueClick: boolean;
+  proxyOpens: number;
+  scannerEvents: number;
+  firstOpenedAt?: string | null;
+  lastOpenedAt?: string | null;
+  firstClickedAt?: string | null;
+  lastClickedAt?: string | null;
+}
+
+export interface SendTracking {
+  enabled: boolean;
+  opens: boolean;
+  clicks: boolean;
+  messageLevel: boolean;
+  summary?: TrackingSummary;
+  events?: TrackingEvent[];
+  eventCount?: number;
+  eventsTruncated?: boolean;
+  links?: TrackingLink[];
+  linkCount?: number;
+  linksTruncated?: boolean;
 }
 
 export interface SendEvent {
@@ -307,6 +358,7 @@ export interface SendEvent {
   deliveryLog?: DeliveryLogEntry[];
   deliveryAttempts?: DeliveryAttempt[];
   webhookDeliveries?: WebhookDelivery[];
+  tracking?: SendTracking;
   deliveredAt?: string;
   createdAt: string;
 }
@@ -339,6 +391,34 @@ export interface Analytics {
     total: number;
     rate: number;
   }>;
+  engagement: {
+    trackedDelivered: number;
+    totalOpens: number;
+    uniqueOpens: number;
+    proxyOpens: number;
+    totalClicks: number;
+    uniqueClicks: number;
+    scannerEvents: number;
+    openRate: number;
+    clickRate: number;
+    clickToOpenRate: number;
+  };
+  engagementByDay: Array<{
+    day: string;
+    opens: number;
+    uniqueOpens: number;
+    clicks: number;
+    uniqueClicks: number;
+    scannerEvents: number;
+  }>;
+  topLinks: Array<{
+    fingerprint: string;
+    target: string;
+    targetOrigin: string;
+    clicks: number;
+    uniqueClicks: number;
+    lastClickedAt: string;
+  }>;
   byDay: Array<{
     day: string;
     date?: string;
@@ -420,7 +500,7 @@ export interface DomainPatchPayload {
   dmarcRua?: string;
 }
 
-export type WebhookEvent = 'sent' | 'bounced' | 'failed';
+export type WebhookEvent = 'sent' | 'bounced' | 'failed' | 'opened' | 'clicked';
 export type WebhookDeliveryStatus = 'pending' | 'processing' | 'success' | 'dead';
 
 export interface Webhook {

+ 96 - 1
src/pages/Dashboard.tsx

@@ -1,4 +1,4 @@
-import { Area, Bar, Column, Pie } from '@ant-design/plots';
+import { Area, Bar, Column, Line, Pie } from '@ant-design/plots';
 import { Alert, Col, List, Progress, Row, Space, Table, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 
@@ -11,8 +11,11 @@ import {
   buildDashboardSummary,
   buildDeliveryFunnel,
   buildDomainRanking,
+  buildEngagementSummary,
+  buildEngagementTrend,
   buildHourlyHeatmap,
   buildStatusDistribution,
+  buildTopLinks,
   buildTrendSeries
 } from '../frontend/analytics-model.js';
 import { buildDomainHealth } from '../frontend/domain-model.js';
@@ -44,6 +47,13 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
   const rankingData = buildDomainRanking(analytics);
   const hourlyData = buildHourlyHeatmap(analytics);
   const deliveryFunnel = buildDeliveryFunnel(analytics);
+  const engagement = buildEngagementSummary(analytics);
+  const engagementTrend = buildEngagementTrend(analytics);
+  const engagementTrendData = engagementTrend.flatMap((item) => [
+    { date: item.date, type: t('dashboard.opens'), value: item.opens },
+    { date: item.date, type: t('dashboard.clicks'), value: item.clicks }
+  ]);
+  const topLinks = buildTopLinks(analytics);
   const failureReasons = analytics?.failureReasons || [];
   const lastSentLabel = summary.lastSentAt
     ? new Date(summary.lastSentAt).toLocaleString()
@@ -62,6 +72,22 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
       )
     }
   ];
+  const linkColumns: ColumnsType<(typeof topLinks)[number]> = [
+    {
+      title: t('dashboard.link'),
+      dataIndex: 'target',
+      ellipsis: { showTitle: false },
+      render: (value) => <Typography.Text ellipsis={{ tooltip: value }}>{value || '-'}</Typography.Text>
+    },
+    { title: t('dashboard.uniqueClicks'), dataIndex: 'uniqueClicks', width: 110 },
+    { title: t('dashboard.totalClicks'), dataIndex: 'clicks', width: 100 },
+    {
+      title: t('dashboard.lastClicked'),
+      dataIndex: 'lastClickedAt',
+      width: 180,
+      render: (value) => value ? new Date(value).toLocaleString() : '-'
+    }
+  ];
 
   return (
     <Space direction="vertical" size={20} className="full-width">
@@ -103,6 +129,75 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
         </Col>
       </Row>
 
+      <Row gutter={[16, 16]}>
+        <Col xs={24} sm={12} xl={6}>
+          <MetricCard
+            label={t('dashboard.openRate')}
+            value={`${engagement.openRate}%`}
+            hint={`${engagement.uniqueOpens} ${t('dashboard.unique')} · ${engagement.totalOpens} ${t('dashboard.total')}`}
+          />
+        </Col>
+        <Col xs={24} sm={12} xl={6}>
+          <MetricCard
+            label={t('dashboard.clickRate')}
+            value={`${engagement.clickRate}%`}
+            hint={`${engagement.uniqueClicks} ${t('dashboard.unique')} · ${engagement.totalClicks} ${t('dashboard.total')}`}
+          />
+        </Col>
+        <Col xs={24} sm={12} xl={6}>
+          <MetricCard
+            label={t('dashboard.clickToOpenRate')}
+            value={`${engagement.clickToOpenRate}%`}
+            hint={`${t('dashboard.trackedDelivered')} ${engagement.trackedDelivered}`}
+          />
+        </Col>
+        <Col xs={24} sm={12} xl={6}>
+          <MetricCard
+            label={t('dashboard.measuredActivity')}
+            value={engagement.totalOpens + engagement.totalClicks}
+            hint={`${t('dashboard.proxyOpens')} ${engagement.proxyOpens} · ${t('dashboard.scannerEvents')} ${engagement.scannerEvents}`}
+          />
+        </Col>
+      </Row>
+
+      <Row gutter={[16, 16]}>
+        <Col xs={24} xl={15}>
+          <SectionCard title={t('dashboard.engagementTrend')} className="chart-card">
+            {engagementTrendData.some((item) => item.value > 0) ? (
+              <Line
+                data={engagementTrendData}
+                xField="date"
+                yField="value"
+                colorField="type"
+                height={300}
+                axis={{ y: { title: false }, x: { title: false } }}
+                scale={{ color: { range: [brandColors.chartPrimary, brandColors.chartSuccess] } }}
+                tooltip={{ title: 'date' }}
+                legend={{ color: { position: 'top' } }}
+              />
+            ) : (
+              <EmptyState description={t('dashboard.noEngagement')} />
+            )}
+          </SectionCard>
+        </Col>
+        <Col xs={24} xl={9}>
+          <SectionCard title={t('dashboard.topLinks')}>
+            {topLinks.length ? (
+              <Table
+                rowKey="fingerprint"
+                size="small"
+                columns={linkColumns}
+                dataSource={topLinks}
+                pagination={false}
+                scroll={{ x: 640 }}
+              />
+            ) : (
+              <EmptyState description={t('dashboard.noEngagement')} />
+            )}
+          </SectionCard>
+        </Col>
+      </Row>
+
       <Row gutter={[16, 16]}>
         <Col xs={24} xl={15}>
           <SectionCard title={t('dashboard.deliveryFunnel')}>

+ 89 - 3
src/pages/SendingLogs.tsx

@@ -9,7 +9,7 @@ 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, SendEventTimelineEntry, WebhookDelivery } from '../frontend/types';
+import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent, SendEventTimelineEntry, TrackingLink, WebhookDelivery } from '../frontend/types';
 
 const { RangePicker } = DatePicker;
 
@@ -52,6 +52,16 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
       render: (value) => <StatusTag status={value} />,
       width: 120
     },
+    {
+      title: t('logs.opens'),
+      width: 90,
+      render: (_, event) => event.tracking?.summary?.totalOpens ?? 0
+    },
+    {
+      title: t('logs.clicks'),
+      width: 90,
+      render: (_, event) => event.tracking?.summary?.totalClicks ?? 0
+    },
     { 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={() => void openDetail(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
@@ -114,7 +124,7 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
             </Typography.Text>
           }
         >
-          <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
+          <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1480 }} />
         </SectionCard>
       </Space>
 
@@ -163,7 +173,7 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
       <Drawer
         title={event ? `${t('logs.detailTitle')} · mh-${event.id}` : t('logs.detailTitle')}
         open={Boolean(event)}
-        width={760}
+        width="min(760px, 100vw)"
         onClose={onClose}
         extra={event ? (
           <Button icon={<CopyOutlined />} onClick={() => onCopy(formatDeliveryLog(event))}>
@@ -195,7 +205,49 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
                   <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
                 </Descriptions.Item>
               </Descriptions>
+              {event.tracking?.enabled ? (
+                <SectionCard title={t('logs.engagement')} className="delivery-log-card">
+                  <Descriptions bordered size="small" column={2}>
+                    <Descriptions.Item label={t('logs.trackingScope')} span={2}>
+                      <StatusPill tone={event.tracking.messageLevel ? 'warning' : 'info'}>
+                        {event.tracking.messageLevel ? t('logs.messageLevel') : t('logs.recipientLevel')}
+                      </StatusPill>
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.opens')}>
+                      {event.tracking.summary?.totalOpens || 0}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.clicks')}>
+                      {event.tracking.summary?.totalClicks || 0}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.firstOpened')}>
+                      {formatOptionalTime(event.tracking.summary?.firstOpenedAt)}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.lastOpened')}>
+                      {formatOptionalTime(event.tracking.summary?.lastOpenedAt)}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.firstClicked')}>
+                      {formatOptionalTime(event.tracking.summary?.firstClickedAt)}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.lastClicked')}>
+                      {formatOptionalTime(event.tracking.summary?.lastClickedAt)}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.proxyOpens')}>
+                      {event.tracking.summary?.proxyOpens || 0}
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('logs.scannerEvents')}>
+                      {event.tracking.summary?.scannerEvents || 0}
+                    </Descriptions.Item>
+                  </Descriptions>
+                  {event.tracking.linksTruncated ? (
+                    <Alert type="warning" showIcon message={t('logs.trackingLinksTruncated')} />
+                  ) : null}
+                  {event.tracking.links?.length ? <TrackingLinksTable links={event.tracking.links} /> : null}
+                </SectionCard>
+              ) : null}
               <SectionCard title={t('logs.trackingTimeline')} className="delivery-log-card">
+                {event.tracking?.eventsTruncated ? (
+                  <Alert type="warning" showIcon message={t('logs.trackingTimelineTruncated')} />
+                ) : null}
                 {trackingTimeline.length ? (
                   <Timeline
                     items={trackingTimeline.map((item, index) => ({
@@ -260,11 +312,31 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
         {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.targetOrigin ? <LogLine label="URL" value={item.targetOrigin} /> : null}
         {item.webhookId ? <LogLine label="WH" value={`#${item.webhookId}${item.responseStatus ? ` · HTTP ${item.responseStatus}` : ''}`} /> : null}
       </div>
     );
   }
 
+  function TrackingLinksTable({ links }: { links: TrackingLink[] }) {
+    const columns: ColumnsType<TrackingLink> = [
+      {
+        title: t('dashboard.link'),
+        dataIndex: 'target',
+        ellipsis: { showTitle: false },
+        render: (value) => <Typography.Text ellipsis={{ tooltip: value }}>{value}</Typography.Text>
+      },
+      { title: t('logs.clicks'), dataIndex: 'clicks', width: 80 },
+      {
+        title: t('logs.lastClicked'),
+        dataIndex: 'lastClickedAt',
+        width: 180,
+        render: formatOptionalTime
+      }
+    ];
+    return <Table rowKey="id" size="small" columns={columns} dataSource={links} pagination={false} scroll={{ x: 620 }} />;
+  }
+
   function WebhookDeliveriesTable({ deliveries }: { deliveries: WebhookDelivery[] }) {
     const columns: ColumnsType<WebhookDelivery> = [
       { title: t('webhooks.events'), dataIndex: 'eventType', width: 120 },
@@ -366,6 +438,14 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
         lines.push('');
       }
     }
+    if (event.tracking?.enabled) {
+      lines.push(t('logs.engagement'));
+      lines.push(`${t('logs.opens')}: ${event.tracking.summary?.totalOpens || 0}`);
+      lines.push(`${t('logs.clicks')}: ${event.tracking.summary?.totalClicks || 0}`);
+      lines.push(`${t('logs.proxyOpens')}: ${event.tracking.summary?.proxyOpens || 0}`);
+      lines.push(`${t('logs.scannerEvents')}: ${event.tracking.summary?.scannerEvents || 0}`);
+      lines.push('');
+    }
     const entries = event.deliveryLog?.length ? event.deliveryLog : [{
       at: event.createdAt,
       phase: 'legacy',
@@ -414,6 +494,8 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
       delivered: t('logs.stageDelivered'),
       pending: t('logs.stagePending'),
       failed: t('logs.stageFailed'),
+      opened: t('logs.stageOpened'),
+      clicked: t('logs.stageClicked'),
       webhook: t('logs.stageWebhook')
     }[stage] || stage;
   }
@@ -454,4 +536,8 @@ export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: Se
     if (status === 'bounced' || status === 'failed') return 'red';
     return 'blue';
   }
+
+  function formatOptionalTime(value?: string | null) {
+    return value ? new Date(value).toLocaleString() : '-';
+  }
 }

+ 3 - 0
src/pages/Settings.tsx

@@ -108,6 +108,9 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
           <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked">
             <Switch />
           </Form.Item>
+          <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked">
+            <Switch />
+          </Form.Item>
           <Button type="primary" htmlType="submit" loading={loading}>
             {t('settings.save')}
           </Button>

+ 3 - 1
src/pages/Webhooks.tsx

@@ -44,7 +44,7 @@ import type {
   WebhookPayload
 } from '../frontend/types';
 
-const ALL_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed'];
+const ALL_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked'];
 const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead'];
 
 interface WebhooksProps {
@@ -292,6 +292,8 @@ export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksPro
     if (event === 'sent') return t('webhooks.eventSent');
     if (event === 'bounced') return t('webhooks.eventBounced');
     if (event === 'failed') return t('webhooks.eventFailed');
+    if (event === 'opened') return t('webhooks.eventOpened');
+    if (event === 'clicked') return t('webhooks.eventClicked');
     return event;
   }
 

+ 298 - 42
src/server.js

@@ -11,6 +11,8 @@ import {
   createApiToken,
   createAccountToken,
   createDomain,
+  createSendEvent,
+  createTrackingLink,
   createUserWithAccountToken,
   createWebhook,
   consumeAccountToken,
@@ -69,6 +71,10 @@ import {
   updateUser,
   updateWebhook,
   executeUserMerge,
+  finalizeSendEvent,
+  findSendEventByTrackingToken,
+  findTrackingLinkByToken,
+  recordTrackingEvent,
   verifyApiToken,
   verifyUserCredentials
 } from './db.js';
@@ -96,9 +102,24 @@ import {
   buildVerificationEmail,
   sendSystemEmail
 } from './system-mail.js';
+import {
+  classifyTrackingSource,
+  createTrackingToken,
+  decryptTrackingTarget,
+  encryptTrackingTarget,
+  hashTrackingClientIp,
+  instrumentHtml,
+  trackingTargetFingerprint,
+  trackingReplayKey
+} from './tracking.js';
+import { startTrackingRetentionWorker } from './tracking-retention.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 loadDotEnv();
+const fallbackSecret = crypto
+  .createHash('sha256')
+  .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
+  .digest('hex');
 
 const envConfig = {
   port: Number(process.env.PORT || 3000),
@@ -132,10 +153,10 @@ const envConfig = {
   submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
   submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
   submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
-  sessionSecret: process.env.SESSION_SECRET || crypto
-    .createHash('sha256')
-    .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
-    .digest('hex')
+  sessionSecret: process.env.SESSION_SECRET || fallbackSecret,
+  trackingSecret: process.env.TRACKING_SECRET || process.env.SESSION_SECRET || fallbackSecret,
+  trustProxy: String(process.env.TRUST_PROXY || '').toLowerCase() === 'true',
+  trackingRetentionDays: Math.max(1, Number(process.env.TRACKING_RETENTION_DAYS || 180))
 };
 
 const defaultSettings = {
@@ -145,7 +166,9 @@ const defaultSettings = {
   defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
   dmarcPolicy: process.env.DMARC_POLICY || 'none',
   dmarcRua: process.env.DMARC_RUA || '',
-  sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false'
+  sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false',
+  engagementTrackingEnabled:
+    String(process.env.ENGAGEMENT_TRACKING_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false'
 };
 
 const emailVerificationPurpose = 'email_verification';
@@ -178,12 +201,17 @@ startWebhookWorker({
   batchSize: envConfig.webhookWorkerBatchSize
 });
 
+startTrackingRetentionWorker({
+  days: envConfig.trackingRetentionDays
+});
+
 const server = http.createServer(async (req, res) => {
   try {
     setSecurityHeaders(res);
     if (req.method === 'OPTIONS') return handleOptions(res);
     const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
     if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
+    if (url.pathname.startsWith('/t/') && await handleTrackingRequest(req, res, url)) return;
     if (req.method === 'POST' && (url.pathname === '/api/register' || url.pathname === '/register')) return await handleRegister(req, res);
     if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/login')) return await handleLogin(req, res);
     if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
@@ -240,7 +268,15 @@ startSubmissionServer({
   relaySecure: envConfig.smtpSecure,
   relayUsername: envConfig.smtpUser,
   relayPassword: envConfig.smtpPassword,
-  relayHelo: envConfig.smtpHelo
+  relayHelo: envConfig.smtpHelo,
+  getTrackingSettings() {
+    const settings = runtimeSettings();
+    return {
+      enabled: settings.engagementTrackingEnabled,
+      appBaseUrl: settings.appBaseUrl,
+      secret: envConfig.trackingSecret
+    };
+  }
 });
 
 async function handleApi(req, res, url, user) {
@@ -298,11 +334,16 @@ async function handleApi(req, res, url, user) {
   }
   const sendEventMatch = pathname.match(/^\/api\/events\/(\d+)$/);
   if (sendEventMatch && method === 'GET') {
-    const event = getSendEvent(user.id, Number(sendEventMatch[1]));
+    const event = getSendEvent(user.id, Number(sendEventMatch[1]), { trackingSecret: envConfig.trackingSecret });
     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) }) });
+    return sendJson(res, 200, {
+      analytics: getSendAnalytics(user.id, {
+        days: Number(url.searchParams.get('days') || 7),
+        trackingSecret: envConfig.trackingSecret
+      })
+    });
   }
   if (method === 'GET' && pathname === '/api/smtp-credential') {
     return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
@@ -765,15 +806,7 @@ async function handleAdminApi(req, res, url, user) {
   }
   if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/settings') {
     const body = await readJson(req);
-    saveSettings({
-      appBaseUrl: body.appBaseUrl,
-      mailHostname: body.mailHostname,
-      sendingIp: body.sendingIp,
-      defaultSpfMechanisms: body.defaultSpfMechanisms,
-      dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy),
-      dmarcRua: body.dmarcRua,
-      sendRequiresVerified: boolString(body.sendRequiresVerified)
-    });
+    saveSettings(settingsPatchFromBody(body));
     return sendJson(res, 200, { settings: await adminRuntimeSettings() });
   }
   if (method === 'GET' && pathname === '/api/admin/users') {
@@ -976,17 +1009,58 @@ async function sendMailFromBody(body, user) {
     throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
   }
 
-  const rawMessage = buildMessage({
-    from,
-    to: recipients,
-    subject: body.subject || '(no subject)',
-    text: body.text || '',
-    html: body.html || '',
-    baseUrl: settings.appBaseUrl
-  });
-  const signed = signMessageForDomain(rawMessage, domain);
   const smtpTransport = smtpTransportForSend(body, domain, user);
+  const subject = body.subject || '(no subject)';
+  const tracking = resolveSendTracking(body, settings, recipients);
+  const openToken = tracking.opens ? createTrackingToken() : '';
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    smtpRelayId: smtpTransport.smtpRelayId,
+    sender: from,
+    recipients,
+    subject,
+    trackingToken: openToken,
+    trackingOpens: tracking.opens,
+    trackingClicks: tracking.clicks
+  });
+  let actualTracking = {
+    ...tracking,
+    enabled: false,
+    opens: false,
+    clicks: false
+  };
   try {
+    let html = body.html || '';
+    if (tracking.enabled) {
+      const result = instrumentHtml(html, {
+        openPixelUrl: tracking.opens ? trackingOpenUrl(settings.appBaseUrl, openToken) : '',
+        createClickUrl: tracking.clicks
+          ? (target) => createTrackedClickUrl({
+              appBaseUrl: settings.appBaseUrl,
+              userId: user.id,
+              eventId,
+              target
+            })
+          : null
+      });
+      html = result.html;
+      actualTracking = {
+        ...tracking,
+        enabled: (tracking.opens && result.pixelAdded) || (tracking.clicks && result.linkCount > 0),
+        opens: tracking.opens && result.pixelAdded,
+        clicks: tracking.clicks && result.linkCount > 0
+      };
+    }
+    const rawMessage = buildMessage({
+      from,
+      to: recipients,
+      subject,
+      text: body.text || '',
+      html,
+      baseUrl: settings.appBaseUrl
+    });
+    const signed = signMessageForDomain(rawMessage, domain);
     const smtpResult = await sendViaSmtp({
       host: smtpTransport.host,
       port: smtpTransport.port,
@@ -998,42 +1072,79 @@ async function sendMailFromBody(body, user) {
       recipients,
       rawMessage: signed
     });
-    logSendEvent({
-      userId: user.id,
-      domainId: domain.id,
+    finalizeSendEvent(eventId, user.id, {
       smtpRelayId: smtpTransport.smtpRelayId,
-      sender: from,
-      recipients,
-      subject: body.subject || '(no subject)',
       status: 'queued',
       detail: smtpResult.message,
       queueId: smtpResult.queueId,
-      deliveryLog: smtpResult.deliveryLog
+      deliveryLog: smtpResult.deliveryLog,
+      trackingOpens: actualTracking.opens,
+      trackingClicks: actualTracking.clicks
     });
     return {
+      eventId,
       queued: true,
       domain: domain.domain,
       recipients,
       smtp: smtpResult.message,
       queueId: smtpResult.queueId,
-      smtpRelayId: smtpTransport.smtpRelayId
+      smtpRelayId: smtpTransport.smtpRelayId,
+      tracking: actualTracking
     };
   } catch (error) {
-    logSendEvent({
-      userId: user.id,
-      domainId: domain.id,
+    finalizeSendEvent(eventId, user.id, {
       smtpRelayId: smtpTransport.smtpRelayId,
-      sender: from,
-      recipients,
-      subject: body.subject || '(no subject)',
       status: 'failed',
       detail: error.message,
-      deliveryLog: deliveryLogFromError(error)
+      deliveryLog: deliveryLogFromError(error),
+      trackingOpens: actualTracking.opens,
+      trackingClicks: actualTracking.clicks
     });
     throw error;
   }
 }
 
+function resolveSendTracking(body, settings, recipients) {
+  if (!body.html) return { enabled: false, opens: false, clicks: false, messageLevel: recipients.length > 1 };
+  const configured = Boolean(settings.engagementTrackingEnabled);
+  const requested = body.tracking;
+  let opens = configured;
+  let clicks = configured;
+  if (typeof requested === 'boolean') {
+    opens = requested;
+    clicks = requested;
+  } else if (requested && typeof requested === 'object') {
+    if (Object.hasOwn(requested, 'opens')) opens = Boolean(requested.opens);
+    if (Object.hasOwn(requested, 'clicks')) clicks = Boolean(requested.clicks);
+  }
+  return {
+    enabled: opens || clicks,
+    opens,
+    clicks,
+    messageLevel: recipients.length > 1
+  };
+}
+
+function createTrackedClickUrl({ appBaseUrl, userId, eventId, target }) {
+  const normalizedTarget = new URL(target).toString();
+  const token = createTrackingToken();
+  createTrackingLink(userId, eventId, {
+    token,
+    targetCiphertext: encryptTrackingTarget(normalizedTarget, envConfig.trackingSecret),
+    targetFingerprint: trackingTargetFingerprint(normalizedTarget, envConfig.trackingSecret),
+    targetOrigin: new URL(normalizedTarget).origin
+  });
+  return `${trackingUrlBase(appBaseUrl)}/t/c/${token}`;
+}
+
+function trackingOpenUrl(appBaseUrl, token) {
+  return `${trackingUrlBase(appBaseUrl)}/t/o/${token}.gif`;
+}
+
+function trackingUrlBase(appBaseUrl) {
+  return String(appBaseUrl || '').replace(/\/+$/, '');
+}
+
 function deliveryLogFromError(error) {
   if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
   return [{
@@ -1352,7 +1463,8 @@ function runtimeSettings() {
     defaultSpfMechanisms: settings.defaultSpfMechanisms,
     dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
     dmarcRua: settings.dmarcRua,
-    sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true'
+    sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true',
+    engagementTrackingEnabled: String(settings.engagementTrackingEnabled).toLowerCase() === 'true'
   };
 }
 
@@ -1404,6 +1516,131 @@ async function readJson(req) {
   return JSON.parse(raw);
 }
 
+const trackingPixel = Buffer.from(
+  'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+  'base64'
+);
+
+async function handleTrackingRequest(req, res, url) {
+  const openMatch = url.pathname.match(/^\/t\/o\/([A-Za-z0-9_-]{20,128})\.gif$/);
+  if (openMatch) {
+    if (!['GET', 'HEAD'].includes(req.method)) return sendTrackingStatus(res, 405, 'Method not allowed.');
+    if (req.method === 'GET') {
+      const sendEvent = findSendEventByTrackingToken(openMatch[1]);
+      if (sendEvent?.tracking?.opens) {
+        recordPublicTrackingEvent(req, {
+          sendEvent,
+          eventType: 'open'
+        });
+      }
+    }
+    return sendTrackingPixel(res, req.method === 'HEAD');
+  }
+  if (url.pathname.startsWith('/t/o/')) {
+    if (!['GET', 'HEAD'].includes(req.method)) return sendTrackingStatus(res, 405, 'Method not allowed.');
+    return sendTrackingPixel(res, req.method === 'HEAD');
+  }
+
+  const clickMatch = url.pathname.match(/^\/t\/c\/([A-Za-z0-9_-]{20,128})$/);
+  if (!clickMatch) {
+    if (url.pathname.startsWith('/t/c/')) return sendTrackingStatus(res, 404, 'Not found.');
+    return false;
+  }
+  if (req.method !== 'GET') return sendTrackingStatus(res, 405, 'Method not allowed.');
+  const link = findTrackingLinkByToken(clickMatch[1]);
+  if (!link?.trackingClicks) return sendTrackingStatus(res, 404, 'Not found.');
+  let target;
+  try {
+    target = decryptTrackingTarget(link.targetCiphertext, envConfig.trackingSecret);
+  } catch {
+    return sendTrackingStatus(res, 410, 'Link is no longer available.');
+  }
+  recordPublicTrackingEvent(req, {
+    sendEvent: {
+      id: link.sendEventId,
+      userId: link.userId
+    },
+    eventType: 'click',
+    trackingLinkId: link.id
+  });
+  res.writeHead(302, {
+    Location: target,
+    'Cache-Control': 'private, no-store, no-cache, max-age=0',
+    Pragma: 'no-cache',
+    'Referrer-Policy': 'no-referrer'
+  });
+  res.end();
+  return true;
+}
+
+function recordPublicTrackingEvent(req, { sendEvent, eventType, trackingLinkId = null }) {
+  const occurredAt = new Date().toISOString();
+  const userAgent = String(req.headers['user-agent'] || '').slice(0, 500);
+  const source = classifyTrackingSource(userAgent);
+  const ipHash = hashTrackingClientIp({
+    ip: requestClientIp(req),
+    secret: envConfig.trackingSecret,
+    userId: sendEvent.userId,
+    sendEventId: sendEvent.id,
+    occurredAt
+  });
+  const replayKey = trackingReplayKey({
+    secret: envConfig.trackingSecret,
+    sendEventId: sendEvent.id,
+    eventType,
+    trackingLinkId,
+    ipHash,
+    userAgent,
+    occurredAt
+  });
+  try {
+    recordTrackingEvent({
+      sendEventId: sendEvent.id,
+      trackingLinkId,
+      eventType,
+      source,
+      occurredAt,
+      userAgent,
+      ipHash,
+      replayKey
+    });
+  } catch (error) {
+    console.warn(`Tracking event could not be recorded: ${error.message}`);
+  }
+}
+
+function requestClientIp(req) {
+  if (envConfig.trustProxy) {
+    const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
+    if (forwarded) return forwarded;
+  }
+  return req.socket?.remoteAddress || '';
+}
+
+function sendTrackingPixel(res, headOnly = false) {
+  res.writeHead(200, {
+    'Content-Type': 'image/gif',
+    'Content-Length': String(trackingPixel.length),
+    'Cache-Control': 'private, no-store, no-cache, max-age=0',
+    Pragma: 'no-cache',
+    Expires: '0',
+    'Referrer-Policy': 'no-referrer'
+  });
+  res.end(headOnly ? undefined : trackingPixel);
+  return true;
+}
+
+function sendTrackingStatus(res, status, message) {
+  res.writeHead(status, {
+    'Content-Type': 'text/plain; charset=utf-8',
+    'Cache-Control': 'private, no-store, no-cache, max-age=0',
+    Pragma: 'no-cache',
+    'Referrer-Policy': 'no-referrer'
+  });
+  res.end(message);
+  return true;
+}
+
 function sendJson(res, status, payload) {
   res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
   res.end(JSON.stringify(payload));
@@ -1482,6 +1719,25 @@ function smtpRelayPatch(body) {
   return patch;
 }
 
+function settingsPatchFromBody(body) {
+  const patch = {};
+  for (const key of [
+    'appBaseUrl',
+    'mailHostname',
+    'sendingIp',
+    'defaultSpfMechanisms',
+    'dmarcRua'
+  ]) {
+    if (Object.hasOwn(body, key)) patch[key] = body[key];
+  }
+  if (Object.hasOwn(body, 'dmarcPolicy')) patch.dmarcPolicy = normalizeDmarcPolicy(body.dmarcPolicy);
+  if (Object.hasOwn(body, 'sendRequiresVerified')) patch.sendRequiresVerified = boolString(body.sendRequiresVerified);
+  if (Object.hasOwn(body, 'engagementTrackingEnabled')) {
+    patch.engagementTrackingEnabled = boolString(body.engagementTrackingEnabled);
+  }
+  return patch;
+}
+
 function boolString(value) {
   return String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
 }

+ 92 - 15
src/submission.js

@@ -1,13 +1,27 @@
 import net from 'node:net';
 import tls from 'node:tls';
 import { readFileSync } from 'node:fs';
-import { getDomainByName, logSendEvent, verifySmtpCredential } from './db.js';
+import {
+  createSendEvent,
+  createTrackingLink,
+  finalizeSendEvent,
+  getDomainByName,
+  logSendEvent,
+  verifySmtpCredential
+} from './db.js';
 import {
   domainFromAddress,
   extractAddress,
   sendViaSmtp,
   signMessageForDomain
 } from './mailer.js';
+import {
+  createTrackingToken,
+  encryptTrackingTarget,
+  instrumentRawMime,
+  stripRawMimeHeaders,
+  trackingTargetFingerprint
+} from './tracking.js';
 
 const implicitTlsDetectTimeoutMs = 300;
 
@@ -35,6 +49,37 @@ export function startSubmissionServer(config) {
   return servers;
 }
 
+export function resolveSubmissionTracking(rawMessage, defaultEnabled = false) {
+  const control = extractHeader(String(rawMessage || ''), 'x-mailhub-track').trim().toLowerCase();
+  if (!control) {
+    const enabled = Boolean(defaultEnabled);
+    return { enabled, opens: enabled, clicks: enabled, explicit: false };
+  }
+  if (['off', 'false', 'none', '0'].includes(control)) {
+    return { enabled: false, opens: false, clicks: false, explicit: true };
+  }
+  const values = new Set(control.split(/[\s,]+/).filter(Boolean));
+  const opens = values.has('open') || values.has('opens');
+  const clicks = values.has('click') || values.has('clicks');
+  return { enabled: opens || clicks, opens, clicks, explicit: true };
+}
+
+function createSubmissionClickUrl({ appBaseUrl, secret, userId, eventId, target }) {
+  const normalizedTarget = new URL(target).toString();
+  const token = createTrackingToken();
+  createTrackingLink(userId, eventId, {
+    token,
+    targetCiphertext: encryptTrackingTarget(normalizedTarget, secret),
+    targetFingerprint: trackingTargetFingerprint(normalizedTarget, secret),
+    targetOrigin: new URL(normalizedTarget).origin
+  });
+  return `${trackingUrlBase(appBaseUrl)}/t/c/${token}`;
+}
+
+function trackingUrlBase(value) {
+  return String(value || '').replace(/\/+$/, '');
+}
+
 function loadTlsMaterial(config) {
   if (!config.tlsKeyPath || !config.tlsCertPath) {
     console.warn('SMTP TLS certificate paths are not configured; STARTTLS/SMTPS will be unavailable.');
@@ -355,8 +400,48 @@ class SubmissionSession {
       return this.write(550, 'Sender domain is not configured in MailHub');
     }
 
+    const trackingSettings = this.config.getTrackingSettings?.() || {};
+    const requestedTracking = resolveSubmissionTracking(rawMessage, trackingSettings.enabled);
+    const trackingAvailable = Boolean(trackingSettings.appBaseUrl && trackingSettings.secret);
+    const requestedOpens = requestedTracking.opens && trackingAvailable;
+    const requestedClicks = requestedTracking.clicks && trackingAvailable;
+    const openToken = requestedOpens ? createTrackingToken() : '';
+    const eventId = createSendEvent({
+      userId: this.user.id,
+      domainId: domain.id,
+      sender: sender || this.mailFrom,
+      recipients: this.recipients,
+      subject,
+      trackingToken: openToken,
+      trackingOpens: requestedOpens,
+      trackingClicks: requestedClicks
+    });
+
     try {
-      const signed = signMessageForDomain(rawMessage, domain);
+      let preparedMessage = rawMessage;
+      let trackingOpens = false;
+      let trackingClicks = false;
+      if (requestedOpens || requestedClicks) {
+        const result = await instrumentRawMime(preparedMessage, {
+          openPixelUrl: requestedOpens
+            ? `${trackingUrlBase(trackingSettings.appBaseUrl)}/t/o/${openToken}.gif`
+            : '',
+          createClickUrl: requestedClicks
+            ? (target) => createSubmissionClickUrl({
+                appBaseUrl: trackingSettings.appBaseUrl,
+                secret: trackingSettings.secret,
+                userId: this.user.id,
+                eventId,
+                target
+              })
+            : null
+        });
+        preparedMessage = result.rawMessage;
+        trackingOpens = requestedOpens && result.pixelAdded;
+        trackingClicks = requestedClicks && result.linkCount > 0;
+      }
+      preparedMessage = await stripRawMimeHeaders(preparedMessage, ['x-mailhub-track']);
+      const signed = signMessageForDomain(preparedMessage, domain);
       const smtpResult = await sendViaSmtp({
         host: this.config.relayHost,
         port: this.config.relayPort,
@@ -368,26 +453,18 @@ class SubmissionSession {
         recipients: this.recipients,
         rawMessage: signed
       });
-      logSendEvent({
-        userId: this.user.id,
-        domainId: domain.id,
-        sender: sender || this.mailFrom,
-        recipients: this.recipients,
-        subject,
+      finalizeSendEvent(eventId, this.user.id, {
         status: 'queued',
         detail: `submission ${this.remoteAddress}; ${smtpResult.message}`,
         queueId: smtpResult.queueId,
-        deliveryLog: smtpResult.deliveryLog
+        deliveryLog: smtpResult.deliveryLog,
+        trackingOpens,
+        trackingClicks
       });
       this.resetEnvelope(false);
       return this.write(250, 'Message queued');
     } catch (error) {
-      logSendEvent({
-        userId: this.user.id,
-        domainId: domain.id,
-        sender: sender || this.mailFrom,
-        recipients: this.recipients,
-        subject,
+      finalizeSendEvent(eventId, this.user.id, {
         status: 'failed',
         detail: `submission ${this.remoteAddress}; ${error.message}`,
         deliveryLog: deliveryLogFromError(error)

+ 31 - 0
src/tracking-retention.js

@@ -0,0 +1,31 @@
+import { pruneTrackingEvents } from './db.js';
+
+const defaultIntervalMs = 24 * 60 * 60 * 1000;
+
+export function startTrackingRetentionWorker({
+  enabled = true,
+  days = 180,
+  intervalMs = defaultIntervalMs,
+  prune = pruneTrackingEvents,
+  logger = console
+} = {}) {
+  if (!enabled) return null;
+  const run = () => {
+    try {
+      Promise.resolve(prune({ days })).catch((error) => {
+        logger.warn?.(`Tracking retention cleanup failed: ${error.message}`);
+      });
+    } catch (error) {
+      logger.warn?.(`Tracking retention cleanup failed: ${error.message}`);
+    }
+  };
+  run();
+  const timer = setInterval(run, Math.max(10, Number(intervalMs) || defaultIntervalMs));
+  timer.unref?.();
+  return {
+    run,
+    stop() {
+      clearInterval(timer);
+    }
+  };
+}

+ 342 - 0
src/tracking.js

@@ -0,0 +1,342 @@
+import crypto from 'node:crypto';
+import { Readable, Transform } from 'node:stream';
+import { Joiner, Rewriter, Splitter } from '@zone-eu/mailsplit';
+import { parse, parseFragment, serialize } from 'parse5';
+
+const trackingTargetContext = 'mailhub-tracking-target:v1';
+
+export function createTrackingToken() {
+  return crypto.randomBytes(32).toString('base64url');
+}
+
+export function hashTrackingToken(token) {
+  return crypto.createHash('sha256').update(String(token || '')).digest('hex');
+}
+
+export function normalizeTrackingTarget(value) {
+  let url;
+  try {
+    url = new URL(String(value || ''));
+  } catch {
+    throw new Error('Tracking target must be an absolute HTTP URL.');
+  }
+  if (!['http:', 'https:'].includes(url.protocol)) {
+    throw new Error('Tracking target must use HTTP or HTTPS.');
+  }
+  if (!url.hostname) throw new Error('Tracking target must be an absolute HTTP URL.');
+  return url.toString();
+}
+
+export function encryptTrackingTarget(value, secret) {
+  const target = normalizeTrackingTarget(value);
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', trackingEncryptionKey(secret), iv);
+  cipher.setAAD(Buffer.from(trackingTargetContext));
+  const ciphertext = Buffer.concat([cipher.update(target, 'utf8'), cipher.final()]);
+  const tag = cipher.getAuthTag();
+  return ['v1', iv.toString('base64url'), ciphertext.toString('base64url'), tag.toString('base64url')].join('.');
+}
+
+export function decryptTrackingTarget(value, secret) {
+  const [version, ivPart, ciphertextPart, tagPart, extra] = String(value || '').split('.');
+  if (version !== 'v1' || !ivPart || !ciphertextPart || !tagPart || extra) {
+    throw new Error('Invalid encrypted tracking target.');
+  }
+  const decipher = crypto.createDecipheriv(
+    'aes-256-gcm',
+    trackingEncryptionKey(secret),
+    decodeCanonicalBase64Url(ivPart)
+  );
+  decipher.setAAD(Buffer.from(trackingTargetContext));
+  decipher.setAuthTag(decodeCanonicalBase64Url(tagPart));
+  const plaintext = Buffer.concat([
+    decipher.update(decodeCanonicalBase64Url(ciphertextPart)),
+    decipher.final()
+  ]).toString('utf8');
+  return normalizeTrackingTarget(plaintext);
+}
+
+export function trackingTargetFingerprint(value, secret) {
+  return hmac(secret, `target:${normalizeTrackingTarget(value)}`);
+}
+
+export function hashTrackingClientIp({ ip, secret, userId, sendEventId, occurredAt }) {
+  const day = new Date(occurredAt).toISOString().slice(0, 10);
+  return hmac(secret, ['ip', userId, sendEventId, day, normalizeIp(ip)].join(':'));
+}
+
+export function classifyTrackingSource(userAgent) {
+  const value = String(userAgent || '').toLowerCase();
+  if (/googleimageproxy|ggpht\.com|yahoo.*proxy|outlook.*proxy|microsoft office.*image/.test(value)) return 'proxy';
+  if (/scanner|barracuda|proofpoint|mimecast|safelinks|urlscan|spider|crawler|\bbot\b|curl\/|wget\//.test(value)) {
+    return 'scanner';
+  }
+  return 'direct';
+}
+
+export function trackingReplayKey({
+  secret,
+  sendEventId,
+  eventType,
+  trackingLinkId = '',
+  ipHash,
+  userAgent,
+  occurredAt
+}) {
+  const minute = Math.floor(new Date(occurredAt).getTime() / 60_000);
+  const userAgentHash = crypto.createHash('sha256').update(String(userAgent || '')).digest('hex');
+  return hmac(secret, ['replay', sendEventId, eventType, trackingLinkId, ipHash, userAgentHash, minute].join(':'));
+}
+
+export function instrumentHtml(html, { openPixelUrl = '', createClickUrl } = {}) {
+  const source = String(html || '');
+  const documentMode = /<!doctype\s|<html\b|<body\b/i.test(source);
+  const root = documentMode ? parse(source) : parseFragment(source);
+  const trackingOrigin = safeOrigin(openPixelUrl);
+  let linkCount = 0;
+  let hasOpenPixel = false;
+  let body = null;
+
+  walkHtml(root, (node) => {
+    if (node.tagName === 'body') body = node;
+    if (hasAttribute(node, 'data-mailhub-open')) hasOpenPixel = true;
+    if (node.tagName !== 'a' || hasAttribute(node, 'data-mailhub-no-track')) return;
+    const href = getAttribute(node, 'href');
+    if (!href || typeof createClickUrl !== 'function') return;
+    let target;
+    try {
+      target = normalizeTrackingTarget(href);
+    } catch {
+      return;
+    }
+    const targetUrl = new URL(target);
+    if (trackingOrigin && targetUrl.origin === trackingOrigin && targetUrl.pathname.startsWith('/t/c/')) return;
+    const clickUrl = createClickUrl(target);
+    if (!clickUrl) return;
+    setAttribute(node, 'href', String(clickUrl));
+    linkCount += 1;
+  });
+
+  let pixelAdded = false;
+  if (openPixelUrl && !hasOpenPixel) {
+    const pixel = parseFragment(
+      `<img data-mailhub-open="true" src="${escapeHtmlAttribute(openPixelUrl)}" alt="" width="1" height="1" border="0" style="display:none!important;width:1px!important;height:1px!important" referrerpolicy="no-referrer">`
+    ).childNodes[0];
+    const parent = body || root;
+    pixel.parentNode = parent;
+    parent.childNodes = [...(parent.childNodes || []), pixel];
+    pixelAdded = true;
+  }
+
+  return {
+    html: serialize(root),
+    linkCount,
+    pixelAdded
+  };
+}
+
+export async function instrumentRawMime(rawMessage, options = {}) {
+  const source = String(rawMessage || '');
+  const skipReason = rawMimeSkipReason(source);
+  if (skipReason) return unchangedMimeResult(source, skipReason);
+
+  let linkCount = 0;
+  let pixelAdded = false;
+  let htmlParts = 0;
+  let protectedHtmlParts = 0;
+  let unsupportedHtmlParts = 0;
+  const splitter = new Splitter({ ignoreEmbedded: true });
+  const rewriter = new Rewriter((node) => {
+    if (node.contentType !== 'text/html' || node.disposition === 'attachment') return false;
+    if (hasProtectedMimeAncestor(node)) {
+      protectedHtmlParts += 1;
+      return false;
+    }
+    if (!supportsMimeCharset(node.charset)) {
+      unsupportedHtmlParts += 1;
+      return false;
+    }
+    return true;
+  });
+  const joiner = new Joiner();
+
+  rewriter.on('node', (data) => {
+    htmlParts += 1;
+    const chunks = [];
+    data.decoder.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
+    data.decoder.on('error', (error) => data.encoder.destroy(error));
+    data.decoder.on('end', () => {
+      const body = Buffer.concat(chunks);
+      try {
+        const result = instrumentHtml(decodeMimeText(body, data.node.charset), {
+          ...options,
+          openPixelUrl: pixelAdded ? '' : options.openPixelUrl
+        });
+        linkCount += result.linkCount;
+        pixelAdded ||= result.pixelAdded;
+        data.node.setCharset('utf-8');
+        data.encoder.end(Buffer.from(result.html, 'utf8'));
+      } catch (error) {
+        unsupportedHtmlParts += 1;
+        data.encoder.end(body);
+      }
+    });
+  });
+
+  const output = [];
+  await new Promise((resolve, reject) => {
+    joiner.on('data', (chunk) => output.push(Buffer.from(chunk)));
+    joiner.on('end', resolve);
+    joiner.on('error', reject);
+    splitter.on('error', reject);
+    rewriter.on('error', reject);
+    Readable.from([Buffer.from(source)]).pipe(splitter).pipe(rewriter).pipe(joiner);
+  });
+
+  return {
+    rawMessage: Buffer.concat(output).toString('utf8'),
+    tracked: htmlParts > 0 && (linkCount > 0 || pixelAdded),
+    skippedReason: trackingMimeSkipReason({ htmlParts, protectedHtmlParts, unsupportedHtmlParts }),
+    linkCount,
+    pixelAdded
+  };
+}
+
+export async function stripRawMimeHeaders(rawMessage, headerNames = []) {
+  const source = String(rawMessage || '');
+  if (rawMimeSkipReason(source)) return source;
+  const names = new Set((headerNames || []).map((name) => String(name).toLowerCase()));
+  if (!names.size) return source;
+  const splitter = new Splitter({ ignoreEmbedded: true });
+  const headerStripper = new Transform({
+    objectMode: true,
+    transform(data, _encoding, callback) {
+      if (data.type === 'node' && data.root) {
+        for (const name of names) data.headers.remove(name);
+      }
+      callback(null, data);
+    }
+  });
+  const joiner = new Joiner();
+  const output = [];
+  await new Promise((resolve, reject) => {
+    joiner.on('data', (chunk) => output.push(Buffer.from(chunk)));
+    joiner.on('end', resolve);
+    joiner.on('error', reject);
+    splitter.on('error', reject);
+    headerStripper.on('error', reject);
+    Readable.from([Buffer.from(source)]).pipe(splitter).pipe(headerStripper).pipe(joiner);
+  });
+  return Buffer.concat(output).toString('utf8');
+}
+
+function trackingEncryptionKey(secret) {
+  const value = String(secret || '');
+  if (!value) throw new Error('TRACKING_SECRET is required.');
+  return crypto.createHash('sha256').update(`${trackingTargetContext}:${value}`).digest();
+}
+
+function hmac(secret, value) {
+  const key = String(secret || '');
+  if (!key) throw new Error('TRACKING_SECRET is required.');
+  return crypto.createHmac('sha256', key).update(value).digest('hex');
+}
+
+function normalizeIp(value) {
+  return String(value || '').trim().toLowerCase() || 'unknown';
+}
+
+function decodeCanonicalBase64Url(value) {
+  const buffer = Buffer.from(String(value || ''), 'base64url');
+  if (!buffer.length || buffer.toString('base64url') !== value) {
+    throw new Error('Invalid encrypted tracking target.');
+  }
+  return buffer;
+}
+
+function walkHtml(node, visit) {
+  visit(node);
+  for (const child of node.childNodes || []) walkHtml(child, visit);
+}
+
+function hasAttribute(node, name) {
+  return Boolean(node?.attrs?.some((attribute) => attribute.name === name));
+}
+
+function getAttribute(node, name) {
+  return node?.attrs?.find((attribute) => attribute.name === name)?.value || '';
+}
+
+function setAttribute(node, name, value) {
+  const existing = node.attrs?.find((attribute) => attribute.name === name);
+  if (existing) existing.value = value;
+  else node.attrs = [...(node.attrs || []), { name, value }];
+}
+
+function safeOrigin(value) {
+  try {
+    return new URL(String(value || '')).origin;
+  } catch {
+    return '';
+  }
+}
+
+function escapeHtmlAttribute(value) {
+  return String(value || '')
+    .replaceAll('&', '&amp;')
+    .replaceAll('"', '&quot;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;');
+}
+
+function rawMimeSkipReason(rawMessage) {
+  const headerBlock = String(rawMessage || '').split(/\r?\n\r?\n/, 1)[0];
+  if (/^DKIM-Signature:/im.test(headerBlock)) return 'existing-dkim';
+  const unfolded = headerBlock.replace(/\r?\n[\t ]+/g, ' ');
+  if (/^Content-Type:\s*multipart\/signed\b/im.test(unfolded)) return 'signed';
+  if (/^Content-Type:\s*(?:multipart\/encrypted|application\/(?:pkcs7-mime|x-pkcs7-mime|pgp-encrypted))\b/im.test(unfolded)) {
+    return 'encrypted';
+  }
+  return '';
+}
+
+function hasProtectedMimeAncestor(node) {
+  for (let current = node; current; current = current.parentNode) {
+    if (current.contentType === 'multipart/signed') return true;
+    if (current.contentType === 'multipart/encrypted') return true;
+    if (['application/pkcs7-mime', 'application/x-pkcs7-mime', 'application/pgp-encrypted'].includes(current.contentType)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+function supportsMimeCharset(charset) {
+  try {
+    new TextDecoder(String(charset || 'utf-8'), { fatal: true });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function decodeMimeText(value, charset) {
+  return new TextDecoder(String(charset || 'utf-8'), { fatal: true }).decode(value);
+}
+
+function trackingMimeSkipReason({ htmlParts, protectedHtmlParts, unsupportedHtmlParts }) {
+  if (htmlParts > 0) return '';
+  if (protectedHtmlParts > 0) return 'protected-mime';
+  if (unsupportedHtmlParts > 0) return 'unsupported-charset';
+  return 'no-html';
+}
+
+function unchangedMimeResult(rawMessage, skippedReason) {
+  return {
+    rawMessage,
+    tracked: false,
+    skippedReason,
+    linkCount: 0,
+    pixelAdded: false
+  };
+}

+ 30 - 9
src/webhook-model.js

@@ -1,6 +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 MAX_WEBHOOK_ATTEMPTS = 8;
 export const WEBHOOK_LEASE_MS = 2 * 60 * 1000;
 
@@ -21,6 +22,8 @@ export function eventTypeForStatus(status) {
   if (status === 'sent') return 'email.sent';
   if (status === 'bounced') return 'email.bounced';
   if (status === 'failed') return 'email.failed';
+  if (status === 'opened') return 'email.opened';
+  if (status === 'clicked') return 'email.clicked';
   return null;
 }
 
@@ -42,11 +45,14 @@ export function resolveWebhooksForEvent({ accountWebhooks, domainWebhooks, event
   return matches(accountWebhooks);
 }
 
-export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEvent, test = false }) {
+export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEvent, engagement = null, test = false }) {
   const status = sendEvent.status;
+  const externalType = String(eventType || '').startsWith('email.')
+    ? eventType
+    : eventTypeForStatus(status) || eventType;
   return {
     id: `whd_${deliveryId}`,
-    type: eventTypeForStatus(status) || eventType,
+    type: externalType,
     created_at: createdAt,
     data: {
       ...(test ? { test: true } : {}),
@@ -59,7 +65,8 @@ export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEven
       to: sendEvent.recipients || [],
       subject: sendEvent.subject || '',
       detail: sendEvent.detail || '',
-      delivered_at: sendEvent.deliveredAt || null
+      delivered_at: sendEvent.deliveredAt || null,
+      ...(engagement ? { engagement: publicEngagement(engagement) } : {})
     }
   };
 }
@@ -87,20 +94,20 @@ export function nextBackoffMs(attemptCount) {
  */
 export function normalizeWebhookEvents(input) {
   if (!Array.isArray(input)) {
-    throw new Error('events must be a non-empty array of sent|bounced|failed');
+    throw webhookEventsError();
   }
-  const allowed = new Set(TERMINAL_WEBHOOK_EVENTS);
+  const allowed = new Set(WEBHOOK_EVENTS);
   const seen = new Set();
   for (const item of input) {
     if (!allowed.has(item)) {
-      throw new Error('events must be a non-empty array of sent|bounced|failed');
+      throw webhookEventsError();
     }
     seen.add(item);
   }
   if (seen.size === 0) {
-    throw new Error('events must be a non-empty array of sent|bounced|failed');
+    throw webhookEventsError();
   }
-  return TERMINAL_WEBHOOK_EVENTS.filter((e) => seen.has(e));
+  return WEBHOOK_EVENTS.filter((e) => seen.has(e));
 }
 
 export function parseWebhookEventsJson(json) {
@@ -108,7 +115,21 @@ export function parseWebhookEventsJson(json) {
   try {
     parsed = typeof json === 'string' ? JSON.parse(json) : json;
   } catch {
-    throw new Error('events must be a non-empty array of sent|bounced|failed');
+    throw webhookEventsError();
   }
   return normalizeWebhookEvents(parsed);
 }
+
+function publicEngagement(engagement) {
+  return {
+    type: engagement.type,
+    occurred_at: engagement.occurredAt,
+    source: engagement.source,
+    link_id: engagement.linkId ?? null,
+    target_origin: engagement.targetOrigin || ''
+  };
+}
+
+function webhookEventsError() {
+  return new Error('events must be a non-empty array of sent|bounced|failed|opened|clicked');
+}

+ 468 - 1
test/db.test.js

@@ -11,6 +11,8 @@ import {
   consumeAccountToken,
   createApiToken,
   createAccountToken,
+  createSendEvent,
+  createTrackingLink,
   createDomain,
   createUser,
   createUserWithAccountToken,
@@ -39,6 +41,8 @@ import {
   approveUser,
   markUserEmailVerified,
   previewUserMerge,
+  pruneTrackingEvents,
+  recordTrackingEvent,
   saveDnsCredential,
   saveSmtpRelay,
   saveSmtpCredential,
@@ -50,9 +54,18 @@ import {
   updateUser,
   updateUserStatus,
   executeUserMerge,
+  finalizeSendEvent,
+  findSendEventByTrackingToken,
+  findTrackingLinkByToken,
   verifyApiToken,
   verifySmtpCredential
 } from '../src/db.js';
+import {
+  createTrackingToken,
+  encryptTrackingTarget,
+  hashTrackingToken,
+  trackingTargetFingerprint
+} from '../src/tracking.js';
 
 test('migrates legacy data to the seeded admin user', () => {
   const dataDir = tempDataDir();
@@ -125,6 +138,377 @@ test('migrates legacy data to the seeded admin user', () => {
   assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
 });
 
+test('migrates legacy send events with engagement tracking disabled', () => {
+  const dataDir = tempDataDir();
+  const database = initDatabase(dataDir, 'test-secret');
+  const columns = database.prepare('PRAGMA table_info(send_events)').all().map((column) => column.name);
+  assert.equal(columns.includes('tracking_token_hash'), true);
+  assert.equal(columns.includes('tracking_opens'), true);
+  assert.equal(columns.includes('tracking_clicks'), true);
+  const tables = database
+    .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('tracking_links', 'tracking_events') ORDER BY name")
+    .all()
+    .map((row) => row.name);
+  assert.deepEqual(tables, ['tracking_events', 'tracking_links']);
+});
+
+test('creates and finalizes one tracked send event with a unique open token', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'tracked', email: 'tracked@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('tracked.example'));
+  const token = createTrackingToken();
+  const id = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@tracked.example',
+    recipients: ['reader@example.net'],
+    subject: 'Tracked',
+    trackingToken: token,
+    trackingOpens: true,
+    trackingClicks: true
+  });
+
+  const submitting = getSendEvent(user.id, id);
+  assert.equal(submitting.status, 'submitting');
+  assert.equal(submitting.tracking.enabled, true);
+  assert.equal(submitting.tracking.opens, true);
+  assert.equal(submitting.tracking.clicks, true);
+  assert.equal(findSendEventByTrackingToken(token).id, id);
+
+  const finalized = finalizeSendEvent(id, user.id, {
+    status: 'queued',
+    detail: 'queued as TRACK123',
+    queueId: 'TRACK123',
+    deliveryLog: [{ phase: 'queue', ok: true }]
+  });
+  assert.equal(finalized.status, 'queued');
+  assert.equal(finalized.queueId, 'TRACK123');
+
+  assert.throws(() => createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@tracked.example',
+    recipients: ['second@example.net'],
+    subject: 'Duplicate token',
+    trackingToken: token,
+    trackingOpens: true
+  }), /unique/i);
+});
+
+test('stores encrypted opaque tracking links and isolates creation by owner', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'track-alice', email: 'track-alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'track-bob', email: 'track-bob@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('links.example'));
+  const eventId = createSendEvent({
+    userId: alice.id,
+    domainId: domain.id,
+    sender: 'sender@links.example',
+    recipients: ['reader@example.net'],
+    subject: 'Links',
+    trackingClicks: true
+  });
+  const token = createTrackingToken();
+  const target = 'https://example.net/reset?token=private';
+  const linkId = createTrackingLink(alice.id, eventId, {
+    token,
+    targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
+    targetFingerprint: hashTrackingToken(`destination:${target}`),
+    targetOrigin: 'https://example.net'
+  });
+
+  const link = findTrackingLinkByToken(token);
+  assert.equal(link.id, linkId);
+  assert.equal(link.sendEventId, eventId);
+  assert.equal(link.userId, alice.id);
+  assert.equal(link.targetCiphertext.includes('private'), false);
+  assert.equal(link.targetOrigin, 'https://example.net');
+  const detail = getSendEvent(alice.id, eventId, { trackingSecret: 'tracking-secret' });
+  assert.equal(detail.tracking.links.length, 1);
+  assert.equal(detail.tracking.links[0].target, target);
+  assert.throws(() => createTrackingLink(bob.id, eventId, {
+    token: createTrackingToken(),
+    targetCiphertext: encryptTrackingTarget('https://example.net/other', 'tracking-secret'),
+    targetFingerprint: 'other',
+    targetOrigin: 'https://example.net'
+  }), /not found/i);
+});
+
+test('records deduplicated engagement events and summarizes message detail', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'events', email: 'events@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('events.example'));
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@events.example',
+    recipients: ['reader@example.net'],
+    subject: 'Engagement',
+    trackingOpens: true,
+    trackingClicks: true
+  });
+  const trackingLinkId = createTrackingLink(user.id, eventId, {
+    token: createTrackingToken(),
+    targetCiphertext: encryptTrackingTarget('https://example.net/click', 'tracking-secret'),
+    targetFingerprint: trackingTargetFingerprint('https://example.net/click', 'tracking-secret'),
+    targetOrigin: 'https://example.net'
+  });
+
+  const scanner = recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'scanner',
+    occurredAt: '2026-07-09T12:00:00.000Z',
+    userAgent: 'Scanner',
+    ipHash: 'scanner-ip',
+    replayKey: 'scanner-open'
+  });
+  assert.equal(scanner.recorded, true);
+  assert.equal(scanner.firstQualifying, false);
+
+  const direct = recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-09T12:01:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'direct-ip',
+    replayKey: 'direct-open'
+  });
+  assert.equal(direct.recorded, true);
+  assert.equal(direct.firstQualifying, true);
+
+  recordTrackingEvent({
+    sendEventId: eventId,
+    trackingLinkId,
+    eventType: 'click',
+    source: 'direct',
+    occurredAt: '2026-07-09T11:59:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'click-ip',
+    replayKey: 'direct-click'
+  });
+
+  const duplicate = recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-09T12:01:30.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'direct-ip',
+    replayKey: 'direct-open'
+  });
+  assert.equal(duplicate.recorded, false);
+  assert.equal(duplicate.duplicate, true);
+
+  const detail = getSendEvent(user.id, eventId);
+  assert.equal(detail.tracking.summary.totalOpens, 1);
+  assert.equal(detail.tracking.summary.totalClicks, 1);
+  assert.equal(detail.tracking.summary.uniqueOpen, true);
+  assert.equal(detail.tracking.summary.scannerEvents, 1);
+  assert.equal(detail.tracking.summary.firstOpenedAt, '2026-07-09T11:59:00.000Z');
+  assert.equal(detail.tracking.summary.lastOpenedAt, '2026-07-09T12:01:00.000Z');
+  assert.equal(detail.tracking.events.length, 3);
+  assert.equal(detail.tracking.eventCount, 3);
+  assert.equal(detail.tracking.eventsTruncated, false);
+  assert.equal(detail.tracking.events.some((event) => Object.hasOwn(event, 'ipHash')), false);
+  const listed = listSendEvents(user.id)[0];
+  assert.equal(listed.tracking.summary.totalOpens, 1);
+  assert.equal(listed.tracking.summary.uniqueOpen, true);
+});
+
+test('bounds tracking detail events while preserving the complete SQL summary', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'bounded-events', email: 'bounded-events@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('bounded-events.example'));
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@bounded-events.example',
+    recipients: ['reader@example.net'],
+    subject: 'Bounded events',
+    trackingOpens: true
+  });
+  const start = Date.parse('2026-07-09T00:00:00.000Z');
+  for (let index = 0; index < 505; index += 1) {
+    recordTrackingEvent({
+      sendEventId: eventId,
+      eventType: 'open',
+      source: 'direct',
+      occurredAt: new Date(start + index * 1000).toISOString(),
+      userAgent: 'Mozilla/5.0',
+      ipHash: `bounded-${index}`,
+      replayKey: `bounded-${index}`
+    });
+  }
+  for (let index = 0; index < 205; index += 1) {
+    const target = `https://example.net/link/${index}`;
+    createTrackingLink(user.id, eventId, {
+      token: createTrackingToken(),
+      targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
+      targetFingerprint: trackingTargetFingerprint(target, 'tracking-secret'),
+      targetOrigin: 'https://example.net'
+    });
+  }
+
+  const detail = getSendEvent(user.id, eventId, { trackingSecret: 'tracking-secret' });
+  assert.equal(detail.tracking.summary.totalOpens, 505);
+  assert.equal(detail.tracking.eventCount, 505);
+  assert.equal(detail.tracking.events.length, 500);
+  assert.equal(detail.tracking.eventsTruncated, true);
+  assert.equal(detail.tracking.linkCount, 205);
+  assert.equal(detail.tracking.links.length, 200);
+  assert.equal(detail.tracking.linksTruncated, true);
+  assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:05.000Z');
+  assert.equal(detail.tracking.events.at(-1).occurredAt, '2026-07-09T00:08:24.000Z');
+
+  const listed = listSendEvents(user.id)[0];
+  assert.equal(listed.tracking.summary.totalOpens, 505);
+  assert.equal(Object.hasOwn(listed.tracking, 'events'), false);
+  const eventIndexes = database.prepare("PRAGMA index_list('tracking_events')").all().map((row) => row.name);
+  const sendEventIndexes = database.prepare("PRAGMA index_list('send_events')").all().map((row) => row.name);
+  assert.equal(eventIndexes.includes('idx_tracking_events_link_time'), true);
+  assert.equal(sendEventIndexes.includes('idx_events_user_created'), true);
+});
+
+test('enqueues first qualifying engagement webhook and ignores scanner-first events', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'engagement-hook', email: 'engagement-hook@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('engagement-hook.example'));
+  createWebhook(user.id, {
+    name: 'Engagement events',
+    url: 'https://hooks.example.com/engagement',
+    events: ['opened', 'clicked']
+  });
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@engagement-hook.example',
+    recipients: ['reader@example.net'],
+    subject: 'Webhook engagement',
+    status: 'sent',
+    trackingOpens: true,
+    trackingClicks: true
+  });
+
+  recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'scanner',
+    occurredAt: '2026-07-09T12:00:00.000Z',
+    userAgent: 'Scanner',
+    ipHash: 'scanner-ip',
+    replayKey: 'hook-scanner'
+  });
+  assert.equal(getSendEvent(user.id, eventId).webhookDeliveries.length, 0);
+
+  recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-09T12:01:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'direct-ip',
+    replayKey: 'hook-direct-1'
+  });
+  recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-09T12:02:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'direct-ip-2',
+    replayKey: 'hook-direct-2'
+  });
+  const deliveries = getSendEvent(user.id, eventId).webhookDeliveries;
+  assert.equal(deliveries.length, 1);
+  assert.equal(deliveries[0].eventType, 'opened');
+});
+
+test('caps tracking writes per message and UTC day', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'cap-user', email: 'cap-user@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('cap.example'));
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@cap.example',
+    recipients: ['reader@example.net'],
+    subject: 'Cap',
+    trackingOpens: true
+  });
+  for (let index = 0; index < 2; index += 1) {
+    const result = recordTrackingEvent({
+      sendEventId: eventId,
+      eventType: 'open',
+      source: 'direct',
+      occurredAt: `2026-07-09T12:0${index}:00.000Z`,
+      userAgent: 'Mozilla/5.0',
+      ipHash: `ip-${index}`,
+      replayKey: `cap-${index}`,
+      maxPerDay: 2
+    });
+    assert.equal(result.recorded, true);
+  }
+  const capped = recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-09T12:03:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'ip-3',
+    replayKey: 'cap-3',
+    maxPerDay: 2
+  });
+  assert.equal(capped.recorded, false);
+  assert.equal(capped.capped, true);
+
+  const nextDay = recordTrackingEvent({
+    sendEventId: eventId,
+    eventType: 'open',
+    source: 'direct',
+    occurredAt: '2026-07-10T00:00:00.000Z',
+    userAgent: 'Mozilla/5.0',
+    ipHash: 'ip-4',
+    replayKey: 'cap-4',
+    maxPerDay: 2
+  });
+  assert.equal(nextDay.recorded, true);
+});
+
+test('prunes raw tracking events before the retention cutoff', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'retention', email: 'retention@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('retention.example'));
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'sender@retention.example',
+    recipients: ['reader@example.net'],
+    subject: 'Retention',
+    trackingOpens: true
+  });
+  for (const [occurredAt, replayKey] of [
+    ['2026-01-01T00:00:00.000Z', 'old-event'],
+    ['2026-07-09T00:00:00.000Z', 'new-event']
+  ]) {
+    recordTrackingEvent({
+      sendEventId: eventId,
+      eventType: 'open',
+      source: 'direct',
+      occurredAt,
+      userAgent: 'Mozilla/5.0',
+      ipHash: replayKey,
+      replayKey
+    });
+  }
+
+  assert.equal(pruneTrackingEvents({ days: 30, now: '2026-07-10T00:00:00.000Z' }), 1);
+  const detail = getSendEvent(user.id, eventId);
+  assert.equal(detail.tracking.events.length, 1);
+  assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:00.000Z');
+});
+
 test('isolates domains, smtp credentials, and api tokens by user', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
@@ -350,7 +734,7 @@ test('send analytics includes delivery funnel and top failure reasons', () => {
     recipients: ['bad2@example.com'],
     subject: 'Failure',
     status: 'failed',
-    detail: 'Mailbox unavailable'
+    detail: 'Mailbox\t  unavailable'
   });
 
   const analytics = getSendAnalytics(alice.id, { days: 7 });
@@ -374,6 +758,89 @@ test('send analytics includes delivery funnel and top failure reasons', () => {
   assert.equal(analytics.failureReasons[0].total, 2);
 });
 
+test('send analytics reports delivered engagement rates trends and grouped links', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'analytics-track', email: 'analytics-track@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('analytics-track.example'));
+  const now = new Date().toISOString();
+  const target = 'https://example.net/reset?token=private';
+  const fingerprint = trackingTargetFingerprint(target, 'tracking-secret');
+  const events = [];
+  for (let index = 0; index < 3; index += 1) {
+    events.push(createSendEvent({
+      userId: user.id,
+      domainId: domain.id,
+      sender: 'sender@analytics-track.example',
+      recipients: [`reader-${index}@example.net`],
+      subject: `Tracked ${index}`,
+      status: 'sent',
+      trackingOpens: true,
+      trackingClicks: true
+    }));
+  }
+  const links = events.slice(0, 2).map((eventId, index) => {
+    const token = createTrackingToken();
+    const id = createTrackingLink(user.id, eventId, {
+      token,
+      targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
+      targetFingerprint: fingerprint,
+      targetOrigin: 'https://example.net'
+    });
+    return { id, token, index };
+  });
+  const engagement = [
+    { sendEventId: events[0], eventType: 'open', source: 'direct', key: 'open-direct' },
+    { sendEventId: events[0], eventType: 'open', source: 'proxy', key: 'open-proxy' },
+    { sendEventId: events[0], trackingLinkId: links[0].id, eventType: 'click', source: 'direct', key: 'click-one' },
+    { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'scanner', key: 'click-scanner' },
+    { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'direct', key: 'click-two' }
+  ];
+  for (const item of engagement) {
+    recordTrackingEvent({
+      ...item,
+      occurredAt: now,
+      userAgent: item.source === 'scanner' ? 'Scanner' : 'Mozilla/5.0',
+      ipHash: item.key,
+      replayKey: item.key
+    });
+  }
+
+  const analytics = getSendAnalytics(user.id, { days: 7, trackingSecret: 'tracking-secret' });
+  assert.equal(analytics.engagement.trackedDelivered, 3);
+  assert.equal(analytics.engagement.totalOpens, 2);
+  assert.equal(analytics.engagement.uniqueOpens, 2);
+  assert.equal(analytics.engagement.proxyOpens, 1);
+  assert.equal(analytics.engagement.totalClicks, 2);
+  assert.equal(analytics.engagement.uniqueClicks, 2);
+  assert.equal(analytics.engagement.scannerEvents, 1);
+  assert.equal(analytics.engagement.openRate, 66.7);
+  assert.equal(analytics.engagement.clickRate, 66.7);
+  assert.equal(analytics.engagement.clickToOpenRate, 100);
+  assert.equal(analytics.engagementByDay.reduce((sum, day) => sum + day.clicks, 0), 2);
+  assert.equal(analytics.topLinks.length, 1);
+  assert.equal(analytics.topLinks[0].clicks, 2);
+  assert.equal(analytics.topLinks[0].uniqueClicks, 2);
+  assert.equal(analytics.topLinks[0].target, target);
+});
+
+test('send analytics does not truncate windows above 5000 messages', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'large-analytics', email: 'large-analytics@example.com', password: 'password123' });
+  const createdAt = new Date().toISOString();
+  const insert = database.prepare(`
+    INSERT INTO send_events (
+      user_id, domain_id, sender, recipients, subject, status, detail, created_at
+    ) VALUES (?, NULL, ?, '[]', ?, 'queued', '', ?)
+  `);
+  database.exec('BEGIN');
+  for (let index = 0; index < 5001; index += 1) {
+    insert.run(user.id, 'sender@example.com', `Message ${index}`, createdAt);
+  }
+  database.exec('COMMIT');
+
+  assert.equal(getSendAnalytics(user.id, { days: 1 }).summary.total, 5001);
+});
+
 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' });

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

@@ -4,10 +4,13 @@ import { test } from 'node:test';
 import {
   buildDashboardSummary,
   buildDeliveryFunnel,
+  buildEngagementSummary,
+  buildEngagementTrend,
   buildEventTimeline,
   buildDomainRanking,
   buildHourlyHeatmap,
   buildStatusDistribution,
+  buildTopLinks,
   buildTrendSeries
 } from '../src/frontend/analytics-model.js';
 
@@ -143,6 +146,73 @@ test('builds delivery funnel and event timeline models', () => {
   assert.equal(timeline[3].status, 'success');
 });
 
+test('normalizes engagement KPIs trends links and timeline events', () => {
+  const analytics = {
+    engagement: {
+      trackedDelivered: 10,
+      totalOpens: 8,
+      uniqueOpens: 6,
+      proxyOpens: 2,
+      totalClicks: 5,
+      uniqueClicks: 4,
+      scannerEvents: 3,
+      openRate: 60,
+      clickRate: 40,
+      clickToOpenRate: 66.7
+    },
+    engagementByDay: [
+      { day: '2026-07-08', opens: 3, uniqueOpens: 2, clicks: 1, uniqueClicks: 1, scannerEvents: 1 },
+      { day: '2026-07-09', opens: 5, uniqueOpens: 4, clicks: 4, uniqueClicks: 3, scannerEvents: 2 }
+    ],
+    topLinks: [{
+      fingerprint: 'abc',
+      target: 'https://example.com/path',
+      targetOrigin: 'https://example.com',
+      clicks: 5,
+      uniqueClicks: 4,
+      lastClickedAt: '2026-07-09T12:00:00.000Z'
+    }]
+  };
+
+  assert.deepEqual(buildEngagementSummary(analytics), analytics.engagement);
+  assert.deepEqual(buildEngagementTrend(analytics)[0], {
+    date: '2026-07-08',
+    opens: 3,
+    uniqueOpens: 2,
+    clicks: 1,
+    uniqueClicks: 1,
+    scannerEvents: 1
+  });
+  assert.equal(buildTopLinks(analytics)[0].target, 'https://example.com/path');
+
+  const timeline = buildEventTimeline({
+    id: 9,
+    status: 'sent',
+    createdAt: '2026-07-09T11:00:00.000Z',
+    deliveryAttempts: [{
+      at: '2026-07-09T12:02:00.000Z',
+      status: 'sent',
+      recipient: 'user@example.com',
+      response: '250 ok'
+    }],
+    tracking: {
+      events: [
+        { id: 1, eventType: 'open', source: 'proxy', occurredAt: '2026-07-09T12:00:00.000Z' },
+        {
+          id: 2,
+          eventType: 'click',
+          source: 'direct',
+          targetOrigin: 'https://example.com',
+          occurredAt: '2026-07-09T12:01:00.000Z'
+        }
+      ]
+    }
+  });
+  assert.deepEqual(timeline.map((item) => item.stage), ['submitted', 'opened', 'clicked', 'delivered']);
+  assert.equal(timeline[1].source, 'proxy');
+  assert.equal(timeline[2].targetOrigin, 'https://example.com');
+});
+
 function domain(verified, records) {
   return {
     status: {

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

@@ -0,0 +1,229 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+test('instruments tracked API HTML before DKIM and supports per-send opt-out', async () => {
+  const relay = await startFakeSmtpServer();
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-tracking-'));
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      APP_BASE_URL: `http://127.0.0.1:${port}`,
+      ADMIN_PASSWORD: 'password123',
+      SESSION_SECRET: 'session-secret',
+      TRACKING_SECRET: 'tracking-secret',
+      SMTP_HOST: '127.0.0.1',
+      SMTP_PORT: String(relay.port),
+      SMTP_HELO: 'mail.track-send.example',
+      SEND_REQUIRES_VERIFIED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: '0',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const baseUrl = `http://127.0.0.1:${port}`;
+    const cookie = await login(baseUrl);
+    const domainResponse = await fetch(`${baseUrl}/api/domains`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ domain: 'track-send.example' })
+    });
+    assert.equal(domainResponse.status, 201);
+
+    const settings = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ engagementTrackingEnabled: true })
+    });
+    assert.equal(settings.status, 200);
+    assert.equal((await settings.json()).settings.engagementTrackingEnabled, true);
+
+    const tracked = await sendHtml(baseUrl, cookie, {
+      subject: 'Tracked HTML',
+      html: '<html><body><a href="https://example.net/reset?token=secret">Reset</a></body></html>'
+    });
+    assert.equal(tracked.eventId > 0, true);
+    assert.deepEqual(tracked.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
+    await waitFor(() => relay.messages.length === 1);
+    const trackedMessage = relay.messages[0];
+    assert.match(trackedMessage, /^DKIM-Signature:/m);
+    const trackedHtml = decodeHtmlPart(trackedMessage);
+    assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
+    assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
+    assert.equal(trackedHtml.includes('token=secret'), false);
+
+    const optedOut = await sendHtml(baseUrl, cookie, {
+      subject: 'Untracked HTML',
+      html: '<a href="https://example.net/private">Private</a>',
+      tracking: { opens: false, clicks: false }
+    });
+    assert.deepEqual(optedOut.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
+    await waitFor(() => relay.messages.length === 2);
+    const untrackedHtml = decodeHtmlPart(relay.messages[1]);
+    assert.match(untrackedHtml, /https:\/\/example\.net\/private/);
+    assert.equal(untrackedHtml.includes('/t/o/'), false);
+    assert.equal(untrackedHtml.includes('/t/c/'), false);
+
+    const noTrackableLinks = await sendHtml(baseUrl, cookie, {
+      subject: 'No trackable links',
+      html: '<p>No links here.</p>',
+      tracking: { opens: false, clicks: true }
+    });
+    assert.deepEqual(noTrackableLinks.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
+    await waitFor(() => relay.messages.length === 3);
+    const noTrackableLinksHtml = decodeHtmlPart(relay.messages[2]);
+    assert.equal(noTrackableLinksHtml.includes('/t/c/'), false);
+
+    const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } }).then((response) => response.json());
+    assert.equal(events.events.length, 3);
+    assert.equal(events.events.every((event) => event.status === 'queued'), true);
+    assert.equal(events.events.find((event) => event.subject === 'Tracked HTML').tracking.enabled, true);
+    assert.equal(events.events.find((event) => event.subject === 'No trackable links').tracking.enabled, false);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await relay.close();
+  }
+});
+
+async function sendHtml(baseUrl, cookie, data) {
+  const response = await fetch(`${baseUrl}/api/send`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Cookie: cookie },
+    body: JSON.stringify({
+      from: 'noreply@track-send.example',
+      to: 'reader@example.net',
+      text: 'Fallback',
+      ...data
+    })
+  });
+  assert.equal(response.status, 202);
+  return response.json();
+}
+
+async function login(baseUrl) {
+  const response = await fetch(`${baseUrl}/api/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username: 'admin', password: 'password123' })
+  });
+  assert.equal(response.status, 200);
+  return response.headers.get('set-cookie')?.split(';')[0] || '';
+}
+
+function decodeHtmlPart(rawMessage) {
+  const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
+  assert.ok(match, 'expected an HTML MIME part');
+  return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
+}
+
+function startFakeSmtpServer() {
+  const messages = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+    let messageLines = [];
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            messages.push(messageLines.join('\n'));
+            messageLines = [];
+            socket.write('250 2.0.0 queued as TRACK123\r\n');
+          } else {
+            messageLines.push(line);
+          }
+          continue;
+        }
+        if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
+        else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
+        else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end with dot\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => resolve({
+      port: server.address().port,
+      messages,
+      close: () => new Promise((closeResolve) => server.close(closeResolve))
+    }));
+  });
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const { port } = server.address();
+      server.close((error) => (error ? reject(error) : resolve(port)));
+    });
+    server.on('error', reject);
+  });
+}
+
+async function waitFor(predicate, timeoutMs = 5000) {
+  const startedAt = Date.now();
+  while (!predicate()) {
+    if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for SMTP message.');
+    await new Promise((resolve) => setTimeout(resolve, 20));
+  }
+}
+
+function waitForOutput(child, value, timeoutMs = 8000) {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
+    const onData = (chunk) => {
+      buffer += String(chunk);
+      if (!buffer.includes(value)) return;
+      clearTimeout(timer);
+      child.stdout?.off('data', onData);
+      child.stderr?.off('data', onData);
+      resolve();
+    };
+    child.stdout?.on('data', onData);
+    child.stderr?.on('data', onData);
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  return new Promise((resolve) => {
+    if (child.exitCode != null) return resolve(true);
+    const timer = setTimeout(() => resolve(false), timeoutMs);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve(true);
+    });
+  });
+}
+
+function escapeRegExp(value) {
+  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}

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

@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createSendEvent,
+  createTrackingLink,
+  createUser,
+  getSendEvent,
+  initDatabase
+} from '../src/db.js';
+import {
+  createTrackingToken,
+  encryptTrackingTarget,
+  hashTrackingToken
+} from '../src/tracking.js';
+
+test('serves neutral open pixels and only records valid GET requests', async () => {
+  const fixture = await trackingServerFixture();
+  try {
+    const head = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
+      method: 'HEAD',
+      headers: { 'User-Agent': 'Mozilla/5.0' }
+    });
+    assert.equal(head.status, 200);
+    assert.match(head.headers.get('content-type') || '', /image\/gif/i);
+    assert.match(head.headers.get('cache-control') || '', /no-store/i);
+    assert.equal(getSendEvent(fixture.userId, fixture.eventId).tracking.events.length, 0);
+
+    const open = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
+      headers: {
+        'User-Agent': 'Mozilla/5.0',
+        'X-Forwarded-For': '203.0.113.7'
+      }
+    });
+    assert.equal(open.status, 200);
+    assert.match(open.headers.get('content-type') || '', /image\/gif/i);
+    assert.ok((await open.arrayBuffer()).byteLength > 0);
+    const detail = getSendEvent(fixture.userId, fixture.eventId);
+    assert.equal(detail.tracking.summary.totalOpens, 1);
+    assert.equal(detail.tracking.events[0].source, 'direct');
+
+    const unknown = await fetch(`${fixture.baseUrl}/t/o/${createTrackingToken()}.gif`);
+    assert.equal(unknown.status, 200);
+    assert.equal(await unknown.arrayBuffer().then((value) => value.byteLength), 42);
+    const malformed = await fetch(`${fixture.baseUrl}/t/o/bad.gif`, { redirect: 'manual' });
+    assert.equal(malformed.status, 200);
+    assert.match(malformed.headers.get('content-type') || '', /image\/gif/i);
+    assert.equal(malformed.headers.get('location'), null);
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+  }
+});
+
+test('redirects opaque click tokens and rejects unknown or unreadable targets', async () => {
+  const fixture = await trackingServerFixture();
+  try {
+    const click = await fetch(`${fixture.baseUrl}/t/c/${fixture.clickToken}`, {
+      redirect: 'manual',
+      headers: { 'User-Agent': 'Mozilla/5.0' }
+    });
+    assert.equal(click.status, 302);
+    assert.equal(click.headers.get('location'), fixture.target);
+    assert.match(click.headers.get('cache-control') || '', /no-store/i);
+    assert.equal(click.headers.get('referrer-policy'), 'no-referrer');
+    const detail = getSendEvent(fixture.userId, fixture.eventId);
+    assert.equal(detail.tracking.summary.totalClicks, 1);
+    assert.equal(detail.tracking.events.find((event) => event.eventType === 'click').targetOrigin, 'https://example.net');
+
+    const unknown = await fetch(`${fixture.baseUrl}/t/c/${createTrackingToken()}`, { redirect: 'manual' });
+    assert.equal(unknown.status, 404);
+    assert.equal(unknown.headers.get('location'), null);
+    const malformed = await fetch(`${fixture.baseUrl}/t/c/bad?u=https://attacker.example`, { redirect: 'manual' });
+    assert.equal(malformed.status, 404);
+    assert.equal(malformed.headers.get('location'), null);
+
+    const unreadable = await fetch(`${fixture.baseUrl}/t/c/${fixture.unreadableClickToken}`, { redirect: 'manual' });
+    assert.equal(unreadable.status, 410);
+    assert.equal(unreadable.headers.get('location'), null);
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+  }
+});
+
+async function trackingServerFixture() {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-tracking-server-'));
+  initDatabase(dataDir, 'session-secret');
+  const user = createUser({ username: `tracking-${Date.now()}`, email: `tracking-${Date.now()}@example.com`, password: 'password123' });
+  const domain = createDomain(user.id, domainFixture(`tracking-${Date.now()}.example`));
+  const openToken = createTrackingToken();
+  const eventId = createSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: `sender@${domain.domain}`,
+    recipients: ['reader@example.net'],
+    subject: 'Tracking endpoint',
+    status: 'sent',
+    trackingToken: openToken,
+    trackingOpens: true,
+    trackingClicks: true
+  });
+  const target = 'https://example.net/reset?token=private#account';
+  const clickToken = createTrackingToken();
+  createTrackingLink(user.id, eventId, {
+    token: clickToken,
+    targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
+    targetFingerprint: hashTrackingToken(target),
+    targetOrigin: 'https://example.net'
+  });
+  const unreadableClickToken = createTrackingToken();
+  createTrackingLink(user.id, eventId, {
+    token: unreadableClickToken,
+    targetCiphertext: encryptTrackingTarget('https://example.net/old', 'old-secret'),
+    targetFingerprint: hashTrackingToken('https://example.net/old'),
+    targetOrigin: 'https://example.net'
+  });
+
+  const port = await freePort();
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      ADMIN_PASSWORD: 'password123',
+      SESSION_SECRET: 'session-secret',
+      TRACKING_SECRET: 'tracking-secret',
+      TRUST_PROXY: 'true',
+      SUBMISSION_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: '0',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+  await waitForOutput(child, 'MailHub listening');
+  return {
+    child,
+    baseUrl: `http://127.0.0.1:${port}`,
+    userId: user.id,
+    eventId,
+    openToken,
+    clickToken,
+    unreadableClickToken,
+    target
+  };
+}
+
+function domainFixture(domain) {
+  return {
+    domain,
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domain}`,
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  };
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const { port } = server.address();
+      server.close((error) => (error ? reject(error) : resolve(port)));
+    });
+    server.on('error', reject);
+  });
+}
+
+function waitForOutput(child, value, timeoutMs = 8000) {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
+    const onData = (chunk) => {
+      buffer += String(chunk);
+      if (!buffer.includes(value)) return;
+      clearTimeout(timer);
+      child.stdout?.off('data', onData);
+      child.stderr?.off('data', onData);
+      resolve();
+    };
+    child.stdout?.on('data', onData);
+    child.stderr?.on('data', onData);
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  return new Promise((resolve) => {
+    if (child.exitCode != null) return resolve(true);
+    const timer = setTimeout(() => resolve(false), timeoutMs);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve(true);
+    });
+  });
+}

+ 28 - 1
test/submission-tls-sniff.test.js

@@ -6,7 +6,7 @@ import path from 'node:path';
 import tls from 'node:tls';
 import { test } from 'node:test';
 
-import { startSubmissionServer } from '../src/submission.js';
+import { resolveSubmissionTracking, startSubmissionServer } from '../src/submission.js';
 
 const testCert = `-----BEGIN CERTIFICATE-----
 MIIDCTCCAfGgAwIBAgIUKFK3kjPk5eJZ2nsmLbEjfQZp5lQwDQYJKoZIhvcNAQEL
@@ -85,6 +85,33 @@ test('smtp listeners accept both plain SMTP and implicit TLS clients', async ()
   }
 });
 
+test('submission tracking honors defaults and explicit control headers', () => {
+  assert.deepEqual(resolveSubmissionTracking('Subject: A\r\n\r\nBody', false), {
+    enabled: false,
+    opens: false,
+    clicks: false,
+    explicit: false
+  });
+  assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: opens\r\n\r\nBody', false), {
+    enabled: true,
+    opens: true,
+    clicks: false,
+    explicit: true
+  });
+  assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: clicks, opens\r\n\r\nBody', false), {
+    enabled: true,
+    opens: true,
+    clicks: true,
+    explicit: true
+  });
+  assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: off\r\n\r\nBody', true), {
+    enabled: false,
+    opens: false,
+    clicks: false,
+    explicit: true
+  });
+});
+
 function waitForListening(server) {
   if (server.listening) return Promise.resolve();
   return new Promise((resolve) => server.once('listening', resolve));

+ 165 - 0
test/submission-tracking.test.js

@@ -0,0 +1,165 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createUser,
+  initDatabase,
+  listSendEvents,
+  saveSmtpCredential
+} from '../src/db.js';
+import { createDkimKeyPair } from '../src/dkim.js';
+import { sendViaSmtp } from '../src/mailer.js';
+import { startSubmissionServer } from '../src/submission.js';
+
+test('SMTP submission instruments HTML before DKIM and relays one tracked message', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-tracking-')), 'session-secret');
+  const user = createUser({ username: 'submission-user', email: 'submission-user@example.com', password: 'password123' });
+  saveSmtpCredential(user.id, { username: 'smtp-submission-user', password: 'smtp-password' });
+  const keys = createDkimKeyPair();
+  createDomain(user.id, {
+    domain: 'submission-track.example',
+    selector: 'mh202607',
+    verificationToken: 'verify',
+    dkimPublic: keys.publicKey,
+    dkimPrivate: keys.privateKey,
+    senderHost: 'mail.submission-track.example',
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const relay = await startFakeSmtpServer();
+  const [submission] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'submission-track.example',
+    allowInsecureAuth: true,
+    relayHost: '127.0.0.1',
+    relayPort: relay.port,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mail.submission-track.example',
+    getTrackingSettings: () => ({
+      enabled: false,
+      appBaseUrl: 'https://mail.example.com',
+      secret: 'tracking-secret'
+    })
+  });
+  await waitForListening(submission);
+
+  try {
+    const rawMessage = [
+      'From: noreply@submission-track.example',
+      'To: reader@example.net',
+      'Subject: Submission tracked',
+      'MIME-Version: 1.0',
+      'X-MailHub-Track: opens,clicks',
+      'Content-Type: text/html; charset=UTF-8',
+      '',
+      '<html><body><a href="https://example.net/private?token=secret">Open</a></body></html>',
+      ''
+    ].join('\r\n');
+    const response = await sendViaSmtp({
+      host: '127.0.0.1',
+      port: submission.address().port,
+      secure: false,
+      username: 'smtp-submission-user',
+      password: 'smtp-password',
+      helo: 'client.example',
+      mailFrom: 'noreply@submission-track.example',
+      recipients: ['reader@example.net'],
+      rawMessage
+    });
+    assert.match(response.message, /Message queued/i);
+    await waitFor(() => relay.messages.length === 1);
+
+    const relayed = relay.messages[0];
+    assert.match(relayed, /^DKIM-Signature:/m);
+    assert.doesNotMatch(relayed, /^X-MailHub-Track:/im);
+    const decoded = relayed.replace(/=\n/g, '').replace(/=3D/gi, '=');
+    assert.match(decoded, /https:\/\/mail\.example\.com\/t\/o\/[A-Za-z0-9_-]+\.gif/);
+    assert.match(decoded, /https:\/\/mail\.example\.com\/t\/c\/[A-Za-z0-9_-]+/);
+    assert.equal(decoded.includes('token=secret'), false);
+
+    const [event] = listSendEvents(user.id);
+    assert.equal(event.status, 'queued');
+    assert.equal(event.tracking.opens, true);
+    assert.equal(event.tracking.clicks, true);
+    assert.equal(event.tracking.messageLevel, false);
+  } finally {
+    await closeServer(submission);
+    await relay.close();
+  }
+});
+
+function startFakeSmtpServer() {
+  const messages = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+    let messageLines = [];
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            messages.push(messageLines.join('\n'));
+            messageLines = [];
+            socket.write('250 2.0.0 queued as SUBTRACK123\r\n');
+          } else {
+            messageLines.push(line);
+          }
+          continue;
+        }
+        if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
+        else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
+        else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end with dot\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => resolve({
+      port: server.address().port,
+      messages,
+      close: () => closeServer(server)
+    }));
+  });
+}
+
+function waitForListening(server) {
+  if (server.listening) return Promise.resolve();
+  return new Promise((resolve) => server.once('listening', resolve));
+}
+
+function closeServer(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}
+
+async function waitFor(predicate, timeoutMs = 5000) {
+  const startedAt = Date.now();
+  while (!predicate()) {
+    if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for relayed message.');
+    await new Promise((resolve) => setTimeout(resolve, 20));
+  }
+}

+ 45 - 0
test/tracking-retention.test.js

@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { startTrackingRetentionWorker } from '../src/tracking-retention.js';
+
+test('retention worker prunes on startup and interval and stops cleanly', async () => {
+  const calls = [];
+  const worker = startTrackingRetentionWorker({
+    enabled: true,
+    days: 45,
+    intervalMs: 10,
+    prune(options) {
+      calls.push(options.days);
+      return 1;
+    },
+    logger: { warn() {} }
+  });
+
+  await waitFor(() => calls.length >= 2);
+  assert.deepEqual(calls.slice(0, 2), [45, 45]);
+  worker.stop();
+  const stoppedAt = calls.length;
+  await new Promise((resolve) => setTimeout(resolve, 30));
+  assert.equal(calls.length, stoppedAt);
+});
+
+test('retention worker can be disabled', () => {
+  let calls = 0;
+  const worker = startTrackingRetentionWorker({
+    enabled: false,
+    prune() {
+      calls += 1;
+    }
+  });
+  assert.equal(worker, null);
+  assert.equal(calls, 0);
+});
+
+async function waitFor(predicate, timeoutMs = 500) {
+  const startedAt = Date.now();
+  while (!predicate()) {
+    if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for retention worker.');
+    await new Promise((resolve) => setTimeout(resolve, 5));
+  }
+}

+ 267 - 0
test/tracking.test.js

@@ -0,0 +1,267 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  classifyTrackingSource,
+  createTrackingToken,
+  decryptTrackingTarget,
+  encryptTrackingTarget,
+  hashTrackingClientIp,
+  hashTrackingToken,
+  instrumentHtml,
+  instrumentRawMime,
+  normalizeTrackingTarget,
+  stripRawMimeHeaders,
+  trackingTargetFingerprint,
+  trackingReplayKey
+} from '../src/tracking.js';
+
+test('generates opaque tokens and stable hashes', () => {
+  const token = createTrackingToken();
+  assert.match(token, /^[A-Za-z0-9_-]{43}$/);
+  assert.match(hashTrackingToken(token), /^[a-f0-9]{64}$/);
+  assert.equal(hashTrackingToken(token), hashTrackingToken(token));
+  assert.notEqual(hashTrackingToken(token), hashTrackingToken(createTrackingToken()));
+});
+
+test('encrypts destinations with authenticated random nonces', () => {
+  const target = 'https://example.com/reset?token=secret#account';
+  const first = encryptTrackingTarget(target, 'tracking-secret');
+  const second = encryptTrackingTarget(target, 'tracking-secret');
+
+  assert.notEqual(first, second);
+  assert.equal(first.includes('secret'), false);
+  assert.equal(decryptTrackingTarget(first, 'tracking-secret'), target);
+  assert.throws(() => decryptTrackingTarget(`${first.slice(0, -1)}x`, 'tracking-secret'));
+  assert.throws(() => decryptTrackingTarget(first, 'different-secret'));
+  assert.equal(trackingTargetFingerprint(target, 'tracking-secret'), trackingTargetFingerprint(target, 'tracking-secret'));
+  assert.notEqual(trackingTargetFingerprint(target, 'tracking-secret'), trackingTargetFingerprint(target, 'different-secret'));
+});
+
+test('accepts absolute http destinations and rejects unsafe protocols', () => {
+  assert.equal(normalizeTrackingTarget('https://Example.com:443/path?q=1#x'), 'https://example.com/path?q=1#x');
+  assert.equal(normalizeTrackingTarget('http://example.com/a'), 'http://example.com/a');
+  assert.throws(() => normalizeTrackingTarget('javascript:alert(1)'), /http/i);
+  assert.throws(() => normalizeTrackingTarget('mailto:user@example.com'), /http/i);
+  assert.throws(() => normalizeTrackingTarget('/relative'), /absolute/i);
+});
+
+test('scopes IP hashes to account message and UTC day', () => {
+  const input = {
+    ip: '203.0.113.7',
+    secret: 'tracking-secret',
+    userId: 1,
+    sendEventId: 10,
+    occurredAt: '2026-07-09T23:59:00.000Z'
+  };
+  const hash = hashTrackingClientIp(input);
+  assert.equal(hash, hashTrackingClientIp(input));
+  assert.notEqual(hash, hashTrackingClientIp({ ...input, userId: 2 }));
+  assert.notEqual(hash, hashTrackingClientIp({ ...input, sendEventId: 11 }));
+  assert.notEqual(hash, hashTrackingClientIp({ ...input, occurredAt: '2026-07-10T00:00:00.000Z' }));
+});
+
+test('classifies direct proxy and scanner clients', () => {
+  assert.equal(classifyTrackingSource('Mozilla/5.0 AppleWebKit/537.36 Chrome/126 Safari/537.36'), 'direct');
+  assert.equal(classifyTrackingSource('Mozilla/5.0 (via ggpht.com GoogleImageProxy)'), 'proxy');
+  assert.equal(classifyTrackingSource('Barracuda Sentinel Link Scanner'), 'scanner');
+  assert.equal(classifyTrackingSource('curl/8.7.1'), 'scanner');
+});
+
+test('builds minute-bucket replay keys from event and client context', () => {
+  const input = {
+    secret: 'tracking-secret',
+    sendEventId: 10,
+    eventType: 'click',
+    trackingLinkId: 4,
+    ipHash: 'ip-hash',
+    userAgent: 'Mozilla/5.0',
+    occurredAt: '2026-07-09T12:34:05.000Z'
+  };
+  const key = trackingReplayKey(input);
+  assert.equal(key, trackingReplayKey({ ...input, occurredAt: '2026-07-09T12:34:59.999Z' }));
+  assert.notEqual(key, trackingReplayKey({ ...input, occurredAt: '2026-07-09T12:35:00.000Z' }));
+  assert.notEqual(key, trackingReplayKey({ ...input, trackingLinkId: 5 }));
+});
+
+test('rewrites absolute links and appends exactly one open pixel', () => {
+  const createdTargets = [];
+  const result = instrumentHtml(
+    '<html><body><a href="https://example.com/reset?token=secret">Reset</a><p>Hello</p></body></html>',
+    {
+      openPixelUrl: 'https://mail.example/t/o/open-token.gif',
+      createClickUrl(target) {
+        createdTargets.push(target);
+        return 'https://mail.example/t/c/click-token';
+      }
+    }
+  );
+
+  assert.deepEqual(createdTargets, ['https://example.com/reset?token=secret']);
+  assert.match(result.html, /href="https:\/\/mail\.example\/t\/c\/click-token"/);
+  assert.equal((result.html.match(/data-mailhub-open/g) || []).length, 1);
+  assert.match(result.html, /src="https:\/\/mail\.example\/t\/o\/open-token\.gif"/);
+  assert.equal(result.linkCount, 1);
+  assert.equal(result.pixelAdded, true);
+});
+
+test('skips opt-out and non-http links without duplicating an existing pixel', () => {
+  let calls = 0;
+  const result = instrumentHtml(
+    '<a data-mailhub-no-track href="https://example.com/private">Private</a>' +
+      '<a href="mailto:user@example.com">Mail</a>' +
+      '<a href="/relative">Relative</a>' +
+      '<img data-mailhub-open="true" src="https://mail.example/t/o/token.gif">',
+    {
+      openPixelUrl: 'https://mail.example/t/o/token.gif',
+      createClickUrl() {
+        calls += 1;
+        return 'https://mail.example/t/c/token';
+      }
+    }
+  );
+
+  assert.equal(calls, 0);
+  assert.equal(result.linkCount, 0);
+  assert.equal(result.pixelAdded, false);
+  assert.equal((result.html.match(/data-mailhub-open/g) || []).length, 1);
+});
+
+test('rewrites encoded HTML MIME parts and preserves attachments', async () => {
+  const html = '<html><body><a href="https://example.com/a">A</a></body></html>';
+  const attachment = Buffer.from('attachment-bytes').toString('base64');
+  const raw = [
+    'From: sender@example.com',
+    'To: user@example.net',
+    'Subject: Tracked',
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/mixed; boundary="outer"',
+    '',
+    '--outer',
+    'Content-Type: text/html; charset=UTF-8',
+    'Content-Transfer-Encoding: base64',
+    '',
+    Buffer.from(html).toString('base64'),
+    '--outer',
+    'Content-Type: application/octet-stream',
+    'Content-Disposition: attachment; filename="file.bin"',
+    'Content-Transfer-Encoding: base64',
+    '',
+    attachment,
+    '--outer--',
+    ''
+  ].join('\r\n');
+
+  const result = await instrumentRawMime(raw, {
+    openPixelUrl: 'https://mail.example/t/o/open.gif',
+    createClickUrl: () => 'https://mail.example/t/c/click'
+  });
+
+  assert.equal(result.tracked, true);
+  assert.equal(result.linkCount, 1);
+  assert.match(result.rawMessage, /filename="file\.bin"/);
+  assert.match(result.rawMessage, new RegExp(attachment));
+  const encodedHtml = result.rawMessage.match(/Content-Type: text\/html[^]*?\r\n\r\n([A-Za-z0-9+/=\r\n]+?)\r\n--outer/i)?.[1] || '';
+  const decodedHtml = Buffer.from(encodedHtml.replace(/\s+/g, ''), 'base64').toString('utf8');
+  assert.match(decodedHtml, /mail\.example\/t\/c\/click/);
+  assert.match(decodedHtml, /data-mailhub-open/);
+});
+
+test('skips signed encrypted and existing-DKIM raw messages', async () => {
+  const messages = [
+    'DKIM-Signature: v=1; d=example.com; b=x\r\nContent-Type: text/html\r\n\r\n<a href="https://example.com">A</a>\r\n',
+    'Content-Type: multipart/signed; boundary="s"\r\n\r\n--s--\r\n',
+    'Content-Type: application/pkcs7-mime\r\n\r\nencrypted\r\n'
+  ];
+  for (const raw of messages) {
+    const result = await instrumentRawMime(raw, {
+      openPixelUrl: 'https://mail.example/t/o/open.gif',
+      createClickUrl: () => 'https://mail.example/t/c/click'
+    });
+    assert.equal(result.tracked, false);
+    assert.equal(result.rawMessage, raw);
+    assert.ok(result.skippedReason);
+  }
+});
+
+test('does not rewrite HTML nested inside signed MIME content', async () => {
+  const raw = [
+    'From: sender@example.com',
+    'To: user@example.net',
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/mixed; boundary="outer"',
+    '',
+    '--outer',
+    'Content-Type: multipart/signed; boundary="signed"; protocol="application/pgp-signature"',
+    '',
+    '--signed',
+    'Content-Type: text/html; charset=UTF-8',
+    '',
+    '<a href="https://example.com/signed">Signed</a>',
+    '--signed',
+    'Content-Type: application/pgp-signature',
+    '',
+    'signature-bytes',
+    '--signed--',
+    '--outer--',
+    ''
+  ].join('\r\n');
+
+  const result = await instrumentRawMime(raw, {
+    openPixelUrl: 'https://mail.example/t/o/open.gif',
+    createClickUrl: () => 'https://mail.example/t/c/click'
+  });
+
+  assert.equal(result.tracked, false);
+  assert.equal(result.linkCount, 0);
+  assert.equal(result.pixelAdded, false);
+  assert.match(result.rawMessage, /https:\/\/example\.com\/signed/);
+  assert.doesNotMatch(result.rawMessage, /mail\.example\/t\//);
+});
+
+test('rewrites non-UTF-8 HTML without corrupting its charset', async () => {
+  const html = Buffer.from(
+    '<html><body><p>Ol\xE1</p><a href="https://example.com/a">A</a></body></html>',
+    'latin1'
+  );
+  const raw = [
+    'From: sender@example.com',
+    'To: user@example.net',
+    'MIME-Version: 1.0',
+    'Content-Type: text/html; charset=iso-8859-1',
+    'Content-Transfer-Encoding: base64',
+    '',
+    html.toString('base64'),
+    ''
+  ].join('\r\n');
+
+  const result = await instrumentRawMime(raw, {
+    openPixelUrl: 'https://mail.example/t/o/open.gif',
+    createClickUrl: () => 'https://mail.example/t/c/click'
+  });
+
+  assert.equal(result.tracked, true);
+  assert.match(result.rawMessage, /Content-Type: text\/html; charset=utf-8/i);
+  const encodedHtml = result.rawMessage.split(/\r?\n\r?\n/, 2)[1] || '';
+  const decodedHtml = Buffer.from(encodedHtml.replace(/\s+/g, ''), 'base64').toString('utf8');
+  assert.match(decodedHtml, /Ol\u00e1/);
+  assert.match(decodedHtml, /mail\.example\/t\/c\/click/);
+});
+
+test('removes submission control headers without changing the MIME body', async () => {
+  const raw = [
+    'From: sender@example.com',
+    'X-MailHub-Track: opens,clicks',
+    'Subject: Hello',
+    'Content-Type: text/plain; charset=UTF-8',
+    '',
+    'Body stays intact.',
+    ''
+  ].join('\r\n');
+  const stripped = await stripRawMimeHeaders(raw, ['x-mailhub-track']);
+  assert.doesNotMatch(stripped, /^X-MailHub-Track:/im);
+  assert.match(stripped, /Body stays intact\.\r\n$/);
+
+  const signed = `DKIM-Signature: v=1; d=example.com; b=x\r\nX-MailHub-Track: off\r\n\r\nBody\r\n`;
+  assert.equal(await stripRawMimeHeaders(signed, ['x-mailhub-track']), signed);
+});

+ 43 - 0
test/webhook-model.test.js

@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
 import { test } from 'node:test';
 
 import {
+  WEBHOOK_EVENTS,
   TERMINAL_WEBHOOK_EVENTS,
   MAX_WEBHOOK_ATTEMPTS,
   WEBHOOK_LEASE_MS,
@@ -20,10 +21,19 @@ test('maps terminal statuses to email.* types', () => {
   assert.equal(eventTypeForStatus('sent'), 'email.sent');
   assert.equal(eventTypeForStatus('bounced'), 'email.bounced');
   assert.equal(eventTypeForStatus('failed'), 'email.failed');
+  assert.equal(eventTypeForStatus('opened'), 'email.opened');
+  assert.equal(eventTypeForStatus('clicked'), 'email.clicked');
   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']);
+  assert.equal(isTerminalWebhookStatus('opened'), false);
+  assert.equal(isTerminalWebhookStatus('clicked'), false);
+  assert.deepEqual(normalizeWebhookEvents(['clicked', 'opened', 'sent']), ['sent', 'opened', 'clicked']);
+});
+
 test('isTerminalWebhookStatus matches terminal set', () => {
   assert.equal(isTerminalWebhookStatus('sent'), true);
   assert.equal(isTerminalWebhookStatus('bounced'), true);
@@ -119,6 +129,39 @@ test('builds webhook payload for real and test deliveries', () => {
   assert.equal(synthetic.type, 'email.failed');
 });
 
+test('builds private engagement webhook payloads without full click destinations', () => {
+  const payload = buildWebhookPayload({
+    deliveryId: 51,
+    eventType: 'email.clicked',
+    createdAt: '2026-07-09T12:00:00.000Z',
+    sendEvent: {
+      id: 8,
+      status: 'sent',
+      domain: 'example.com',
+      sender: 'noreply@example.com',
+      recipients: ['reader@example.net'],
+      subject: 'Tracked'
+    },
+    engagement: {
+      type: 'click',
+      occurredAt: '2026-07-09T12:00:00.000Z',
+      source: 'direct',
+      linkId: 4,
+      targetOrigin: 'https://example.net'
+    }
+  });
+
+  assert.equal(payload.type, 'email.clicked');
+  assert.deepEqual(payload.data.engagement, {
+    type: 'click',
+    occurred_at: '2026-07-09T12:00:00.000Z',
+    source: 'direct',
+    link_id: 4,
+    target_origin: 'https://example.net'
+  });
+  assert.equal(JSON.stringify(payload).includes('token='), false);
+});
+
 test('signs body with Stripe-style t and v1', () => {
   const body = '{"id":"whd_1"}';
   const secret = 'secret';

部分文件因为文件数量过多而无法显示