Ver código fonte

feat(webhooks): add pure model for events, resolve, sign, backoff

AI-Co-Authored-By: Grok
chendeben 1 mês atrás
pai
commit
ac484d96e4
2 arquivos alterados com 272 adições e 0 exclusões
  1. 114 0
      src/webhook-model.js
  2. 158 0
      test/webhook-model.test.js

+ 114 - 0
src/webhook-model.js

@@ -0,0 +1,114 @@
+import crypto from 'node:crypto';
+
+export const TERMINAL_WEBHOOK_EVENTS = ['sent', 'bounced', 'failed'];
+export const MAX_WEBHOOK_ATTEMPTS = 8;
+export const WEBHOOK_LEASE_MS = 2 * 60 * 1000;
+
+const BACKOFF_MS_TABLE = [
+  60_000, // 1m
+  300_000, // 5m
+  1_800_000, // 30m
+  7_200_000, // 2h
+  21_600_000, // 6h
+  43_200_000 // 12h (cap)
+];
+
+export function isTerminalWebhookStatus(status) {
+  return TERMINAL_WEBHOOK_EVENTS.includes(status);
+}
+
+export function eventTypeForStatus(status) {
+  if (status === 'sent') return 'email.sent';
+  if (status === 'bounced') return 'email.bounced';
+  if (status === 'failed') return 'email.failed';
+  return null;
+}
+
+/**
+ * Domain-scoped enabled webhooks for an event win; otherwise account-level.
+ * @param {{ accountWebhooks: any[]; domainWebhooks: any[]; eventType: string }} input
+ */
+export function resolveWebhooksForEvent({ accountWebhooks, domainWebhooks, eventType }) {
+  const matches = (list) =>
+    (list || []).filter(
+      (w) =>
+        w.enabled !== false &&
+        w.enabled !== 'false' &&
+        Array.isArray(w.events) &&
+        w.events.includes(eventType)
+    );
+  const domainHits = matches(domainWebhooks);
+  if (domainHits.length) return domainHits;
+  return matches(accountWebhooks);
+}
+
+export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEvent, test = false }) {
+  const status = sendEvent.status;
+  return {
+    id: `whd_${deliveryId}`,
+    type: eventTypeForStatus(status) || eventType,
+    created_at: createdAt,
+    data: {
+      ...(test ? { test: true } : {}),
+      message_id: test ? 'mh-test' : `mh-${sendEvent.id}`,
+      send_event_id: sendEvent.id,
+      queue_id: sendEvent.queueId || '',
+      status,
+      domain: sendEvent.domain || '',
+      from: sendEvent.sender || '',
+      to: sendEvent.recipients || [],
+      subject: sendEvent.subject || '',
+      detail: sendEvent.detail || '',
+      delivered_at: sendEvent.deliveredAt || null
+    }
+  };
+}
+
+/**
+ * Stripe-style signature header: t=<unix>,v1=<hex hmac of `${t}.${rawBody}`>
+ */
+export function signWebhookBody(rawBody, secret, unixSeconds = Math.floor(Date.now() / 1000)) {
+  const signed = `${unixSeconds}.${rawBody}`;
+  const v1 = crypto.createHmac('sha256', secret).update(signed).digest('hex');
+  return `t=${unixSeconds},v1=${v1}`;
+}
+
+/**
+ * attemptCount after increment for failed path; attempt 1 → 60s, … cap 12h
+ */
+export function nextBackoffMs(attemptCount) {
+  const index = Math.max(0, Math.min(BACKOFF_MS_TABLE.length - 1, attemptCount - 1));
+  return BACKOFF_MS_TABLE[index];
+}
+
+/**
+ * Validate and normalize a non-empty subset of terminal webhook events.
+ * Preserves TERMINAL_WEBHOOK_EVENTS order and de-duplicates.
+ */
+export function normalizeWebhookEvents(input) {
+  if (!Array.isArray(input)) {
+    throw new Error('events must be a non-empty array of sent|bounced|failed');
+  }
+  const allowed = new Set(TERMINAL_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');
+    }
+    seen.add(item);
+  }
+  if (seen.size === 0) {
+    throw new Error('events must be a non-empty array of sent|bounced|failed');
+  }
+  return TERMINAL_WEBHOOK_EVENTS.filter((e) => seen.has(e));
+}
+
+export function parseWebhookEventsJson(json) {
+  let parsed;
+  try {
+    parsed = typeof json === 'string' ? JSON.parse(json) : json;
+  } catch {
+    throw new Error('events must be a non-empty array of sent|bounced|failed');
+  }
+  return normalizeWebhookEvents(parsed);
+}

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

@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { test } from 'node:test';
+
+import {
+  TERMINAL_WEBHOOK_EVENTS,
+  MAX_WEBHOOK_ATTEMPTS,
+  WEBHOOK_LEASE_MS,
+  eventTypeForStatus,
+  resolveWebhooksForEvent,
+  buildWebhookPayload,
+  signWebhookBody,
+  nextBackoffMs,
+  isTerminalWebhookStatus,
+  normalizeWebhookEvents,
+  parseWebhookEventsJson
+} from '../src/webhook-model.js';
+
+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('queued'), null);
+  assert.equal(eventTypeForStatus('deferred'), null);
+});
+
+test('isTerminalWebhookStatus matches terminal set', () => {
+  assert.equal(isTerminalWebhookStatus('sent'), true);
+  assert.equal(isTerminalWebhookStatus('bounced'), true);
+  assert.equal(isTerminalWebhookStatus('failed'), true);
+  assert.equal(isTerminalWebhookStatus('queued'), false);
+  assert.equal(isTerminalWebhookStatus('processing'), false);
+  assert.deepEqual(TERMINAL_WEBHOOK_EVENTS, ['sent', 'bounced', 'failed']);
+  assert.equal(MAX_WEBHOOK_ATTEMPTS, 8);
+  assert.equal(WEBHOOK_LEASE_MS, 2 * 60 * 1000);
+});
+
+test('domain webhooks override account for the same event', () => {
+  const account = [
+    { id: 1, domainId: null, enabled: true, events: ['sent', 'failed'] },
+    { id: 2, domainId: null, enabled: true, events: ['bounced'] }
+  ];
+  const domain = [
+    { id: 3, domainId: 9, enabled: true, events: ['sent'] }
+  ];
+  const resolved = resolveWebhooksForEvent({
+    accountWebhooks: account,
+    domainWebhooks: domain,
+    eventType: 'sent'
+  });
+  assert.deepEqual(resolved.map((w) => w.id), [3]);
+});
+
+test('falls back to account when domain has no matching enabled subscription', () => {
+  const resolved = resolveWebhooksForEvent({
+    accountWebhooks: [{ id: 1, domainId: null, enabled: true, events: ['failed'] }],
+    domainWebhooks: [{ id: 3, domainId: 9, enabled: true, events: ['sent'] }],
+    eventType: 'failed'
+  });
+  assert.deepEqual(resolved.map((w) => w.id), [1]);
+});
+
+test('skips disabled webhooks and unsubscribed events', () => {
+  const resolved = resolveWebhooksForEvent({
+    accountWebhooks: [
+      { id: 1, domainId: null, enabled: false, events: ['sent'] },
+      { id: 2, domainId: null, enabled: 'false', events: ['sent'] },
+      { id: 3, domainId: null, enabled: true, events: ['bounced'] },
+      { id: 4, domainId: null, enabled: true, events: ['sent'] }
+    ],
+    domainWebhooks: [],
+    eventType: 'sent'
+  });
+  assert.deepEqual(resolved.map((w) => w.id), [4]);
+});
+
+test('builds webhook payload for real and test deliveries', () => {
+  const real = buildWebhookPayload({
+    deliveryId: 42,
+    eventType: 'email.sent',
+    createdAt: '2026-07-09T12:00:00.000Z',
+    sendEvent: {
+      id: 7,
+      status: 'sent',
+      queueId: 'A1B2C3',
+      domain: 'example.com',
+      sender: 'noreply@example.com',
+      recipients: ['user@example.com'],
+      subject: 'Hello',
+      detail: 'ok',
+      deliveredAt: '2026-07-09T12:00:01.000Z'
+    }
+  });
+  assert.equal(real.id, 'whd_42');
+  assert.equal(real.type, 'email.sent');
+  assert.equal(real.created_at, '2026-07-09T12:00:00.000Z');
+  assert.equal(real.data.message_id, 'mh-7');
+  assert.equal(real.data.send_event_id, 7);
+  assert.equal(real.data.queue_id, 'A1B2C3');
+  assert.equal(real.data.test, undefined);
+
+  const synthetic = buildWebhookPayload({
+    deliveryId: 1,
+    eventType: 'email.failed',
+    createdAt: '2026-07-09T12:00:00.000Z',
+    sendEvent: {
+      id: 0,
+      status: 'failed',
+      domain: 'example.com',
+      sender: 'noreply@example.com',
+      recipients: ['user@example.com'],
+      subject: 'Test'
+    },
+    test: true
+  });
+  assert.equal(synthetic.data.test, true);
+  assert.equal(synthetic.data.message_id, 'mh-test');
+  assert.equal(synthetic.data.send_event_id, 0);
+  assert.equal(synthetic.type, 'email.failed');
+});
+
+test('signs body with Stripe-style t and v1', () => {
+  const body = '{"id":"whd_1"}';
+  const secret = 'secret';
+  const t = 1_700_000_000;
+  const header = signWebhookBody(body, secret, t);
+  assert.equal(header.startsWith('t=1700000000,v1='), true);
+  assert.match(header, /^t=\d+,v1=[0-9a-f]{64}$/);
+
+  const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
+  assert.equal(header, `t=${t},v1=${expected}`);
+});
+
+test('backoff grows then caps', () => {
+  assert.equal(nextBackoffMs(1), 60_000);
+  assert.equal(nextBackoffMs(2), 300_000);
+  assert.equal(nextBackoffMs(3), 1_800_000);
+  assert.equal(nextBackoffMs(4), 7_200_000);
+  assert.equal(nextBackoffMs(5), 21_600_000);
+  assert.equal(nextBackoffMs(6), 43_200_000);
+  assert.ok(nextBackoffMs(1) < nextBackoffMs(2));
+  assert.equal(nextBackoffMs(6), nextBackoffMs(7));
+  assert.equal(nextBackoffMs(10), nextBackoffMs(20));
+});
+
+test('normalizeWebhookEvents accepts non-empty subset of terminal events', () => {
+  assert.deepEqual(normalizeWebhookEvents(['failed', 'sent', 'sent']), ['sent', 'failed']);
+  assert.deepEqual(normalizeWebhookEvents(['bounced']), ['bounced']);
+  assert.throws(() => normalizeWebhookEvents([]), /events/i);
+  assert.throws(() => normalizeWebhookEvents(['queued']), /events/i);
+  assert.throws(() => normalizeWebhookEvents(null), /events/i);
+});
+
+test('parseWebhookEventsJson parses JSON array of events', () => {
+  assert.deepEqual(parseWebhookEventsJson('["sent","bounced"]'), ['sent', 'bounced']);
+  assert.throws(() => parseWebhookEventsJson('not-json'), /events/i);
+  assert.throws(() => parseWebhookEventsJson('[]'), /events/i);
+});