webhook-model.test.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import assert from 'node:assert/strict';
  2. import crypto from 'node:crypto';
  3. import { test } from 'node:test';
  4. import {
  5. WEBHOOK_EVENTS,
  6. TERMINAL_WEBHOOK_EVENTS,
  7. MAX_WEBHOOK_ATTEMPTS,
  8. WEBHOOK_LEASE_MS,
  9. eventTypeForStatus,
  10. resolveWebhooksForEvent,
  11. buildWebhookPayload,
  12. signWebhookBody,
  13. nextBackoffMs,
  14. isTerminalWebhookStatus,
  15. normalizeWebhookEvents,
  16. parseWebhookEventsJson
  17. } from '../src/webhook-model.js';
  18. test('maps terminal statuses to email.* types', () => {
  19. assert.equal(eventTypeForStatus('sent'), 'email.sent');
  20. assert.equal(eventTypeForStatus('bounced'), 'email.bounced');
  21. assert.equal(eventTypeForStatus('failed'), 'email.failed');
  22. assert.equal(eventTypeForStatus('opened'), 'email.opened');
  23. assert.equal(eventTypeForStatus('clicked'), 'email.clicked');
  24. assert.equal(eventTypeForStatus('received'), 'email.received');
  25. assert.equal(eventTypeForStatus('queued'), null);
  26. assert.equal(eventTypeForStatus('deferred'), null);
  27. });
  28. test('supports engagement and receipt subscriptions without making them delivery terminal statuses', () => {
  29. assert.deepEqual(WEBHOOK_EVENTS, ['sent', 'bounced', 'failed', 'opened', 'clicked', 'received']);
  30. assert.equal(isTerminalWebhookStatus('opened'), false);
  31. assert.equal(isTerminalWebhookStatus('clicked'), false);
  32. assert.equal(isTerminalWebhookStatus('received'), false);
  33. assert.deepEqual(normalizeWebhookEvents(['clicked', 'opened', 'sent']), ['sent', 'opened', 'clicked']);
  34. });
  35. test('builds an inbound receipt payload without raw MIME content', () => {
  36. const payload = buildWebhookPayload({
  37. deliveryId: 9,
  38. eventType: 'email.received',
  39. createdAt: '2026-07-14T12:00:00.000Z',
  40. inboundMessage: {
  41. id: 55,
  42. mailboxId: 4,
  43. mailboxAddress: 'support@example.com',
  44. domain: 'example.com',
  45. sender: 'sender@example.net',
  46. recipients: ['support@example.com'],
  47. subject: 'Inbound test',
  48. messageId: '<rfc-123@example.net>',
  49. textBody: 'Plain body',
  50. htmlBody: '<p>HTML body</p>',
  51. rawMessage: 'secret raw MIME',
  52. receivedAt: '2026-07-14T11:59:58.000Z'
  53. }
  54. });
  55. assert.equal(payload.type, 'email.received');
  56. assert.equal(payload.data.inbound_message_id, 55);
  57. assert.equal(payload.data.mailbox, 'support@example.com');
  58. assert.equal(payload.data.message_id, '<rfc-123@example.net>');
  59. assert.equal(payload.data.rfc_message_id, '<rfc-123@example.net>');
  60. assert.equal(payload.data.text, 'Plain body');
  61. assert.equal(payload.data.html, '<p>HTML body</p>');
  62. assert.equal('raw_message' in payload.data, false);
  63. assert.equal(JSON.stringify(payload).includes('secret raw MIME'), false);
  64. });
  65. test('isTerminalWebhookStatus matches terminal set', () => {
  66. assert.equal(isTerminalWebhookStatus('sent'), true);
  67. assert.equal(isTerminalWebhookStatus('bounced'), true);
  68. assert.equal(isTerminalWebhookStatus('failed'), true);
  69. assert.equal(isTerminalWebhookStatus('queued'), false);
  70. assert.equal(isTerminalWebhookStatus('processing'), false);
  71. assert.deepEqual(TERMINAL_WEBHOOK_EVENTS, ['sent', 'bounced', 'failed']);
  72. assert.equal(MAX_WEBHOOK_ATTEMPTS, 8);
  73. assert.equal(WEBHOOK_LEASE_MS, 2 * 60 * 1000);
  74. });
  75. test('domain webhooks override account for the same event', () => {
  76. const account = [
  77. { id: 1, domainId: null, enabled: true, events: ['sent', 'failed'] },
  78. { id: 2, domainId: null, enabled: true, events: ['bounced'] }
  79. ];
  80. const domain = [
  81. { id: 3, domainId: 9, enabled: true, events: ['sent'] }
  82. ];
  83. const resolved = resolveWebhooksForEvent({
  84. accountWebhooks: account,
  85. domainWebhooks: domain,
  86. eventType: 'sent'
  87. });
  88. assert.deepEqual(resolved.map((w) => w.id), [3]);
  89. });
  90. test('falls back to account when domain has no matching enabled subscription', () => {
  91. const resolved = resolveWebhooksForEvent({
  92. accountWebhooks: [{ id: 1, domainId: null, enabled: true, events: ['failed'] }],
  93. domainWebhooks: [{ id: 3, domainId: 9, enabled: true, events: ['sent'] }],
  94. eventType: 'failed'
  95. });
  96. assert.deepEqual(resolved.map((w) => w.id), [1]);
  97. });
  98. test('skips disabled webhooks and unsubscribed events', () => {
  99. const resolved = resolveWebhooksForEvent({
  100. accountWebhooks: [
  101. { id: 1, domainId: null, enabled: false, events: ['sent'] },
  102. { id: 2, domainId: null, enabled: 'false', events: ['sent'] },
  103. { id: 3, domainId: null, enabled: true, events: ['bounced'] },
  104. { id: 4, domainId: null, enabled: true, events: ['sent'] }
  105. ],
  106. domainWebhooks: [],
  107. eventType: 'sent'
  108. });
  109. assert.deepEqual(resolved.map((w) => w.id), [4]);
  110. });
  111. test('builds webhook payload for real and test deliveries', () => {
  112. const real = buildWebhookPayload({
  113. deliveryId: 42,
  114. eventType: 'email.sent',
  115. createdAt: '2026-07-09T12:00:00.000Z',
  116. sendEvent: {
  117. id: 7,
  118. status: 'sent',
  119. queueId: 'A1B2C3',
  120. domain: 'example.com',
  121. sender: 'noreply@example.com',
  122. recipients: ['user@example.com'],
  123. subject: 'Hello',
  124. detail: 'ok',
  125. deliveredAt: '2026-07-09T12:00:01.000Z'
  126. }
  127. });
  128. assert.equal(real.id, 'whd_42');
  129. assert.equal(real.type, 'email.sent');
  130. assert.equal(real.created_at, '2026-07-09T12:00:00.000Z');
  131. assert.equal(real.data.message_id, 'mh-7');
  132. assert.equal(real.data.send_event_id, 7);
  133. assert.equal(real.data.queue_id, 'A1B2C3');
  134. assert.equal(real.data.test, undefined);
  135. const synthetic = buildWebhookPayload({
  136. deliveryId: 1,
  137. eventType: 'email.failed',
  138. createdAt: '2026-07-09T12:00:00.000Z',
  139. sendEvent: {
  140. id: 0,
  141. status: 'failed',
  142. domain: 'example.com',
  143. sender: 'noreply@example.com',
  144. recipients: ['user@example.com'],
  145. subject: 'Test'
  146. },
  147. test: true
  148. });
  149. assert.equal(synthetic.data.test, true);
  150. assert.equal(synthetic.data.message_id, 'mh-test');
  151. assert.equal(synthetic.data.send_event_id, 0);
  152. assert.equal(synthetic.type, 'email.failed');
  153. });
  154. test('builds private engagement webhook payloads without full click destinations', () => {
  155. const payload = buildWebhookPayload({
  156. deliveryId: 51,
  157. eventType: 'email.clicked',
  158. createdAt: '2026-07-09T12:00:00.000Z',
  159. sendEvent: {
  160. id: 8,
  161. status: 'sent',
  162. domain: 'example.com',
  163. sender: 'noreply@example.com',
  164. recipients: ['reader@example.net'],
  165. subject: 'Tracked'
  166. },
  167. engagement: {
  168. type: 'click',
  169. occurredAt: '2026-07-09T12:00:00.000Z',
  170. source: 'direct',
  171. linkId: 4,
  172. targetOrigin: 'https://example.net'
  173. }
  174. });
  175. assert.equal(payload.type, 'email.clicked');
  176. assert.deepEqual(payload.data.engagement, {
  177. type: 'click',
  178. occurred_at: '2026-07-09T12:00:00.000Z',
  179. source: 'direct',
  180. link_id: 4,
  181. target_origin: 'https://example.net'
  182. });
  183. assert.equal(JSON.stringify(payload).includes('token='), false);
  184. });
  185. test('signs body with Stripe-style t and v1', () => {
  186. const body = '{"id":"whd_1"}';
  187. const secret = 'secret';
  188. const t = 1_700_000_000;
  189. const header = signWebhookBody(body, secret, t);
  190. assert.equal(header.startsWith('t=1700000000,v1='), true);
  191. assert.match(header, /^t=\d+,v1=[0-9a-f]{64}$/);
  192. const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
  193. assert.equal(header, `t=${t},v1=${expected}`);
  194. });
  195. test('backoff grows then caps', () => {
  196. assert.equal(nextBackoffMs(1), 60_000);
  197. assert.equal(nextBackoffMs(2), 300_000);
  198. assert.equal(nextBackoffMs(3), 1_800_000);
  199. assert.equal(nextBackoffMs(4), 7_200_000);
  200. assert.equal(nextBackoffMs(5), 21_600_000);
  201. assert.equal(nextBackoffMs(6), 43_200_000);
  202. assert.ok(nextBackoffMs(1) < nextBackoffMs(2));
  203. assert.equal(nextBackoffMs(6), nextBackoffMs(7));
  204. assert.equal(nextBackoffMs(10), nextBackoffMs(20));
  205. });
  206. test('normalizeWebhookEvents accepts non-empty subset of terminal events', () => {
  207. assert.deepEqual(normalizeWebhookEvents(['failed', 'sent', 'sent']), ['sent', 'failed']);
  208. assert.deepEqual(normalizeWebhookEvents(['bounced']), ['bounced']);
  209. assert.throws(() => normalizeWebhookEvents([]), /events/i);
  210. assert.throws(() => normalizeWebhookEvents(['queued']), /events/i);
  211. assert.throws(() => normalizeWebhookEvents(null), /events/i);
  212. });
  213. test('parseWebhookEventsJson parses JSON array of events', () => {
  214. assert.deepEqual(parseWebhookEventsJson('["sent","bounced"]'), ['sent', 'bounced']);
  215. assert.throws(() => parseWebhookEventsJson('not-json'), /events/i);
  216. assert.throws(() => parseWebhookEventsJson('[]'), /events/i);
  217. });