@hogsend/plugin-resend 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,27 @@
1
1
  # @hogsend/plugin-resend
2
2
 
3
3
  Resend email delivery for [Hogsend](https://github.com/dougwithseismic/hogsend):
4
- single + batch sends, tracked sends, webhook parsing/verification, and an email
5
- service with bounce tracking.
6
-
7
- `createResendProvider` is the **reference implementation** of the `EmailProvider`
8
- contract — the contract itself lives in `@hogsend/core` (canonical author import
9
- `@hogsend/engine`); this package re-exports `EmailProvider` and its supporting
10
- types for back-compat. To support another provider, implement that interface. See
11
- [docs/adr/0001-provider-boundary.md](https://github.com/dougwithseismic/hogsend/blob/main/docs/adr/0001-provider-boundary.md).
4
+ single + batch sends and webhook parsing/verification (svix), normalized into the
5
+ provider-neutral `EmailEvent` the engine consumes.
6
+
7
+ `createResendProvider` is the **reference implementation** of the provider-neutral
8
+ `EmailProvider` contract — the contract itself lives in `@hogsend/core` (canonical
9
+ author import `@hogsend/engine`); this package re-exports `EmailProvider` and its
10
+ supporting types for back-compat. To support another provider, implement that
11
+ interface (see the sibling `@hogsend/plugin-postmark`, or
12
+ [docs/byo-email-provider.md](https://github.com/dougwithseismic/hogsend/blob/main/docs/byo-email-provider.md)).
13
+
14
+ Resend is the **default** provider; activate another with `EMAIL_PROVIDER` /
15
+ `email.defaultProvider`. Two sovereign invariants the engine enforces through this
16
+ provider:
17
+
18
+ - **First-party open/click tracking is the single source of truth.** Resend's
19
+ native open/click tracking is an account-level toggle the provider can't disable
20
+ per-send, so `capabilities.nativeTracking` is `true` and the engine logs a boot
21
+ WARN — disable it in the Resend dashboard. Provider webhooks are consumed only
22
+ for `delivered`/`bounced`/`complained`.
23
+ - **The engine renders React → HTML itself** before the wire; this provider's
24
+ `send`/`sendBatch` only ever see HTML strings.
12
25
 
13
26
  This package ships raw TypeScript source; consumers bundle it via their own build
14
27
  (tsup `noExternal`). See the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/plugin-resend",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,8 +24,8 @@
24
24
  "dependencies": {
25
25
  "resend": "^6.12.3",
26
26
  "svix": "^1.94.0",
27
- "@hogsend/core": "^0.9.0",
28
- "@hogsend/email": "^0.9.0"
27
+ "@hogsend/core": "^0.11.0",
28
+ "@hogsend/email": "^0.11.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
@@ -1,5 +1,4 @@
1
1
  import { EmailSendError } from "@hogsend/email";
2
- import { createElement } from "react";
3
2
  import type { Resend } from "resend";
4
3
  import { describe, expect, it, vi } from "vitest";
5
4
  import { sendBatchEmails, sendEmail } from "../send.js";
@@ -28,9 +27,7 @@ function mockResendClient(overrides?: {
28
27
  } as unknown as Resend;
29
28
  }
30
29
 
31
- function dummyElement() {
32
- return createElement("div", null, "test");
33
- }
30
+ const HTML = "<p>test</p>";
34
31
 
35
32
  describe("sendEmail", () => {
36
33
  it("sends successfully and returns id", async () => {
@@ -41,12 +38,36 @@ describe("sendEmail", () => {
41
38
  from: "test@hogsend.com",
42
39
  to: "user@example.com",
43
40
  subject: "Test",
44
- react: dummyElement(),
41
+ html: HTML,
45
42
  },
46
43
  });
47
44
  expect(result.id).toBe("resend_123");
48
45
  });
49
46
 
47
+ it("sends HTML on the wire (never React)", async () => {
48
+ const sendFn = vi.fn().mockResolvedValue({
49
+ data: { id: "resend_123" },
50
+ error: null,
51
+ });
52
+ const client = mockResendClient({ sendFn });
53
+
54
+ await sendEmail({
55
+ client,
56
+ options: {
57
+ from: "test@hogsend.com",
58
+ to: "user@example.com",
59
+ subject: "Test",
60
+ html: HTML,
61
+ text: "test",
62
+ },
63
+ });
64
+
65
+ const arg = sendFn.mock.calls[0]?.[0] as Record<string, unknown>;
66
+ expect(arg.html).toBe(HTML);
67
+ expect(arg.text).toBe("test");
68
+ expect(arg).not.toHaveProperty("react");
69
+ });
70
+
50
71
  it("normalizes string recipient to array", async () => {
51
72
  const sendFn = vi.fn().mockResolvedValue({
52
73
  data: { id: "resend_123" },
@@ -60,7 +81,7 @@ describe("sendEmail", () => {
60
81
  from: "test@hogsend.com",
61
82
  to: "user@example.com",
62
83
  subject: "Test",
63
- react: dummyElement(),
84
+ html: HTML,
64
85
  },
65
86
  });
66
87
 
@@ -69,6 +90,55 @@ describe("sendEmail", () => {
69
90
  );
70
91
  });
71
92
 
93
+ it("passes neutral tags straight through to Resend", async () => {
94
+ const sendFn = vi.fn().mockResolvedValue({
95
+ data: { id: "resend_123" },
96
+ error: null,
97
+ });
98
+ const client = mockResendClient({ sendFn });
99
+
100
+ const tags = [
101
+ { name: "campaign", value: "q1" },
102
+ { name: "cohort", value: "beta" },
103
+ ];
104
+ await sendEmail({
105
+ client,
106
+ options: {
107
+ from: "test@hogsend.com",
108
+ to: "user@example.com",
109
+ subject: "Test",
110
+ html: HTML,
111
+ tags,
112
+ },
113
+ });
114
+
115
+ const arg = sendFn.mock.calls[0]?.[0] as {
116
+ tags?: Array<{ name: string; value: string }>;
117
+ };
118
+ expect(arg.tags).toEqual(tags);
119
+ });
120
+
121
+ it("omits Resend tags when none are set", async () => {
122
+ const sendFn = vi.fn().mockResolvedValue({
123
+ data: { id: "resend_123" },
124
+ error: null,
125
+ });
126
+ const client = mockResendClient({ sendFn });
127
+
128
+ await sendEmail({
129
+ client,
130
+ options: {
131
+ from: "test@hogsend.com",
132
+ to: "user@example.com",
133
+ subject: "Test",
134
+ html: HTML,
135
+ },
136
+ });
137
+
138
+ const arg = sendFn.mock.calls[0]?.[0] as { tags?: unknown };
139
+ expect(arg.tags).toBeUndefined();
140
+ });
141
+
72
142
  it("throws EmailSendError on API error", async () => {
73
143
  const client = mockResendClient({
74
144
  sendFn: vi.fn().mockResolvedValue({
@@ -84,7 +154,7 @@ describe("sendEmail", () => {
84
154
  from: "test@hogsend.com",
85
155
  to: "user@example.com",
86
156
  subject: "Test",
87
- react: dummyElement(),
157
+ html: HTML,
88
158
  },
89
159
  retryOptions: { maxRetries: 0 },
90
160
  }),
@@ -111,7 +181,7 @@ describe("sendEmail", () => {
111
181
  from: "test@hogsend.com",
112
182
  to: "user@example.com",
113
183
  subject: "Test",
114
- react: dummyElement(),
184
+ html: HTML,
115
185
  },
116
186
  retryOptions: { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 50 },
117
187
  });
@@ -134,7 +204,7 @@ describe("sendEmail", () => {
134
204
  from: "test@hogsend.com",
135
205
  to: "user@example.com",
136
206
  subject: "Test",
137
- react: dummyElement(),
207
+ html: HTML,
138
208
  },
139
209
  retryOptions: { maxRetries: 3, baseDelayMs: 10 },
140
210
  }),
@@ -160,13 +230,13 @@ describe("sendBatchEmails", () => {
160
230
  from: "a@hogsend.com",
161
231
  to: "b@example.com",
162
232
  subject: "A",
163
- react: dummyElement(),
233
+ html: HTML,
164
234
  },
165
235
  {
166
236
  from: "a@hogsend.com",
167
237
  to: "c@example.com",
168
238
  subject: "B",
169
- react: dummyElement(),
239
+ html: HTML,
170
240
  },
171
241
  ],
172
242
  });
@@ -184,7 +254,7 @@ describe("sendBatchEmails", () => {
184
254
  from: "a@hogsend.com",
185
255
  to: `user${i}@example.com`,
186
256
  subject: `Email ${i}`,
187
- react: dummyElement(),
257
+ html: HTML,
188
258
  }));
189
259
 
190
260
  await sendBatchEmails({ client, emails });
@@ -1,7 +1,12 @@
1
+ import type { LegacyResendWebhookEvent } from "@hogsend/core";
1
2
  import { WebhookVerificationError } from "@hogsend/email";
2
3
  import { describe, expect, it } from "vitest";
3
4
  import type { EmailSentEvent } from "../types.js";
4
- import { createWebhookHandler, parseWebhookEvent } from "../webhooks.js";
5
+ import {
6
+ classifyResendBounce,
7
+ createWebhookHandler,
8
+ parseWebhookEvent,
9
+ } from "../webhooks.js";
5
10
 
6
11
  function makeSentEvent(): EmailSentEvent {
7
12
  return {
@@ -17,15 +22,20 @@ function makeSentEvent(): EmailSentEvent {
17
22
  };
18
23
  }
19
24
 
20
- describe("parseWebhookEvent", () => {
21
- it("parses a valid sent event", () => {
25
+ describe("parseWebhookEvent → EmailEvent", () => {
26
+ it("normalizes a sent event into the neutral shape", () => {
22
27
  const event = makeSentEvent();
23
28
  const parsed = parseWebhookEvent(JSON.stringify(event));
24
29
  expect(parsed.type).toBe("email.sent");
25
- expect(parsed.data.email_id).toBe("email_123");
30
+ expect(parsed.messageId).toBe("email_123");
31
+ expect(parsed.recipients).toEqual(["user@example.com"]);
32
+ expect(parsed.occurredAt).toBe("2024-01-01T00:00:00Z");
33
+ // `raw` is the escape hatch: still readable via the legacy union cast.
34
+ const legacy = parsed.raw as LegacyResendWebhookEvent;
35
+ expect(legacy.data.email_id).toBe("email_123");
26
36
  });
27
37
 
28
- it("parses a valid bounced event", () => {
38
+ it("normalizes a bounced event with the bounce class table", () => {
29
39
  const event = {
30
40
  type: "email.bounced",
31
41
  created_at: "2024-01-01T00:00:00Z",
@@ -35,11 +45,44 @@ describe("parseWebhookEvent", () => {
35
45
  to: ["user@example.com"],
36
46
  subject: "Test",
37
47
  created_at: "2024-01-01T00:00:00Z",
38
- bounce: { message: "Mailbox not found", type: "hard" },
48
+ bounce: { message: "Mailbox not found", type: "HardBounce" },
39
49
  },
40
50
  };
41
51
  const parsed = parseWebhookEvent(JSON.stringify(event));
42
52
  expect(parsed.type).toBe("email.bounced");
53
+ expect(parsed.bounce).toEqual({
54
+ class: "permanent",
55
+ code: "HardBounce",
56
+ reason: "Mailbox not found",
57
+ });
58
+ });
59
+
60
+ it("normalizes a clicked event into the neutral click shape", () => {
61
+ const event = {
62
+ type: "email.clicked",
63
+ created_at: "2024-01-01T00:00:00Z",
64
+ data: {
65
+ email_id: "email_789",
66
+ from: "test@hogsend.com",
67
+ to: ["user@example.com"],
68
+ subject: "Test",
69
+ created_at: "2024-01-01T00:00:00Z",
70
+ click: {
71
+ link: "https://hogsend.com/x",
72
+ timestamp: "2024-01-01T00:01:00Z",
73
+ ipAddress: "1.2.3.4",
74
+ userAgent: "Mozilla",
75
+ },
76
+ },
77
+ };
78
+ const parsed = parseWebhookEvent(JSON.stringify(event));
79
+ expect(parsed.type).toBe("email.clicked");
80
+ expect(parsed.click).toEqual({
81
+ url: "https://hogsend.com/x",
82
+ at: "2024-01-01T00:01:00Z",
83
+ ip: "1.2.3.4",
84
+ ua: "Mozilla",
85
+ });
43
86
  });
44
87
 
45
88
  it("throws on unknown event type", () => {
@@ -54,6 +97,41 @@ describe("parseWebhookEvent", () => {
54
97
  });
55
98
  });
56
99
 
100
+ describe("classifyResendBounce (the free-string → class table)", () => {
101
+ const cases: Array<[string, string]> = [
102
+ ["HardBounce", "permanent"],
103
+ ["Permanent", "permanent"],
104
+ ["SuppressedRecipient", "permanent"],
105
+ ["Suppressed", "permanent"],
106
+ ["SoftBounce", "transient"],
107
+ ["Transient", "transient"],
108
+ ["MailboxFull", "transient"],
109
+ ["Throttled", "transient"],
110
+ ["Undetermined", "transient"],
111
+ ["Complaint", "complaint"],
112
+ ["Spam", "complaint"],
113
+ ["Abuse", "complaint"],
114
+ ["SomethingNew", "unknown"],
115
+ ["", "unknown"],
116
+ ];
117
+
118
+ for (const [input, expected] of cases) {
119
+ it(`maps "${input}" → ${expected}`, () => {
120
+ expect(classifyResendBounce(input)).toBe(expected);
121
+ });
122
+ }
123
+
124
+ it("is case-insensitive and substring-based", () => {
125
+ expect(classifyResendBounce("a HardBounce occurred")).toBe("permanent");
126
+ expect(classifyResendBounce("a spam complaint")).toBe("complaint");
127
+ expect(classifyResendBounce("HARDBOUNCE")).toBe("permanent");
128
+ });
129
+
130
+ it("treats undefined as unknown (no suppression)", () => {
131
+ expect(classifyResendBounce(undefined)).toBe("unknown");
132
+ });
133
+ });
134
+
57
135
  describe("createWebhookHandler", () => {
58
136
  it("requires valid svix headers", async () => {
59
137
  const handler = createWebhookHandler({
package/src/index.ts CHANGED
@@ -7,26 +7,45 @@ export {
7
7
  } from "./provider.js";
8
8
  // Sending (with retry + auto-chunking)
9
9
  export { sendBatchEmails, sendEmail } from "./send.js";
10
- // Types
10
+ // Deprecated Resend-shaped union (frozen one minor; cast `event.raw`).
11
11
  export type {
12
12
  BatchEmailItem,
13
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
13
14
  EmailBouncedEvent,
15
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
14
16
  EmailClickedEvent,
17
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
15
18
  EmailComplainedEvent,
19
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
16
20
  EmailDeliveredEvent,
21
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
17
22
  EmailDeliveryDelayedEvent,
23
+ EmailEvent,
24
+ EmailEventType,
25
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
18
26
  EmailOpenedEvent,
19
27
  EmailProvider,
28
+ EmailProviderCapabilities,
29
+ EmailProviderMeta,
30
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
20
31
  EmailSentEvent,
32
+ /** @deprecated Use {@link EmailEvent}. Frozen `event.raw` cast target. */
33
+ LegacyResendWebhookEvent,
21
34
  SendEmailOptions,
22
35
  SendResult,
36
+ /** @deprecated Use {@link EmailEvent}. Kept for one minor. */
23
37
  WebhookEvent,
38
+ /** @deprecated Use {@link EmailEventType}. Kept for one minor. */
24
39
  WebhookEventType,
25
40
  WebhookHandlerMap,
26
41
  } from "./types.js";
42
+ // Types
43
+ export { defineEmailProvider, WebhookHandshakeSignal } from "./types.js";
27
44
  // Webhooks
28
45
  export {
46
+ classifyResendBounce,
29
47
  createWebhookHandler,
30
48
  parseWebhookEvent,
49
+ toEmailEvent,
31
50
  verifyWebhook,
32
51
  } from "./webhooks.js";
package/src/provider.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { RetryOptions } from "@hogsend/email";
2
2
  import { createResendClient } from "./client.js";
3
3
  import { sendBatchEmails, sendEmail } from "./send.js";
4
- import type {
5
- BatchEmailItem,
6
- EmailProvider,
7
- SendEmailOptions,
8
- SendResult,
9
- WebhookEvent,
4
+ import {
5
+ type BatchEmailItem,
6
+ defineEmailProvider,
7
+ type EmailEvent,
8
+ type EmailProvider,
9
+ type SendEmailOptions,
10
+ type SendResult,
10
11
  } from "./types.js";
11
12
  import { parseWebhookEvent, verifyWebhook } from "./webhooks.js";
12
13
 
@@ -27,7 +28,17 @@ export function createResendProvider(
27
28
  const client = createResendClient({ apiKey: config.apiKey });
28
29
  const retryOptions = config.retryOptions;
29
30
 
30
- return {
31
+ return defineEmailProvider({
32
+ meta: { id: "resend", name: "Resend" },
33
+ capabilities: {
34
+ // Resend's open/click tracking is an account-level toggle the provider
35
+ // can't disable per-send, so the engine logs a boot WARN (first-party
36
+ // tracking stays the source of truth).
37
+ nativeTracking: true,
38
+ scheduledSend: true,
39
+ signedWebhooks: true,
40
+ },
41
+
31
42
  async send(options: SendEmailOptions): Promise<SendResult> {
32
43
  return sendEmail({ client, options, retryOptions });
33
44
  },
@@ -42,7 +53,7 @@ export function createResendProvider(
42
53
  verifyWebhook(opts: {
43
54
  payload: string;
44
55
  headers: Record<string, string>;
45
- }): WebhookEvent {
56
+ }): EmailEvent {
46
57
  if (!config.webhookSecret) {
47
58
  throw new Error(
48
59
  "webhookSecret is required on the provider to verify webhooks",
@@ -55,8 +66,8 @@ export function createResendProvider(
55
66
  });
56
67
  },
57
68
 
58
- parseWebhook(payload: string): WebhookEvent {
69
+ parseWebhook(payload: string): EmailEvent {
59
70
  return parseWebhookEvent(payload);
60
71
  },
61
- };
72
+ });
62
73
  }
package/src/send.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeRecipients } from "@hogsend/core";
1
2
  import {
2
3
  DEFAULT_RETRY_OPTIONS,
3
4
  EmailSendError,
@@ -8,10 +9,6 @@ import type { BatchEmailItem, SendEmailOptions, SendResult } from "./types.js";
8
9
 
9
10
  const BATCH_CHUNK_SIZE = 100;
10
11
 
11
- function normalizeRecipients(to: string | string[]): string[] {
12
- return Array.isArray(to) ? to : [to];
13
- }
14
-
15
12
  function isRetryableStatusCode(statusCode: number): boolean {
16
13
  return statusCode === 429 || statusCode >= 500;
17
14
  }
@@ -104,11 +101,15 @@ export async function sendEmail(args: {
104
101
  from: options.from,
105
102
  to: normalizeRecipients(options.to),
106
103
  subject: options.subject,
107
- ...(options.html ? { html: options.html } : { react: options.react }),
104
+ // HTML-ONLY wire the engine always renders React HTML before the
105
+ // provider, so no React ever reaches Resend here.
106
+ html: options.html,
107
+ text: options.text,
108
108
  replyTo: options.replyTo,
109
109
  cc: options.cc,
110
110
  bcc: options.bcc,
111
111
  scheduledAt: options.scheduledAt,
112
+ // Resend accepts neutral `{name,value}[]` tags natively — pass them through.
112
113
  tags: options.tags,
113
114
  headers: options.headers,
114
115
  });
@@ -175,10 +176,13 @@ async function sendBatchChunk(
175
176
  from: email.from,
176
177
  to: normalizeRecipients(email.to),
177
178
  subject: email.subject,
178
- react: email.react,
179
+ // HTML-ONLY wire — no React reaches Resend.
180
+ html: email.html,
181
+ text: email.text,
179
182
  replyTo: email.replyTo,
180
183
  cc: email.cc,
181
184
  bcc: email.bcc,
185
+ // Resend accepts neutral `{name,value}[]` tags natively.
182
186
  tags: email.tags,
183
187
  headers: email.headers,
184
188
  })),
package/src/types.ts CHANGED
@@ -1,19 +1,39 @@
1
1
  // The email-provider contract now lives in the neutral @hogsend/core package.
2
2
  // These re-exports keep every existing `import ... from "@hogsend/plugin-resend"`
3
3
  // working unchanged.
4
+
5
+ // --- Deprecated Resend-shaped union (frozen one minor) ---------------------
6
+ // These no longer flow through verifyWebhook/parseWebhook (which now return the
7
+ // provider-neutral `EmailEvent`); they remain only as `event.raw` cast targets.
4
8
  export type {
5
9
  BatchEmailItem,
10
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
6
11
  EmailBouncedEvent,
12
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
7
13
  EmailClickedEvent,
14
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
8
15
  EmailComplainedEvent,
16
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
9
17
  EmailDeliveredEvent,
18
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
10
19
  EmailDeliveryDelayedEvent,
20
+ EmailEvent,
21
+ EmailEventType,
22
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
11
23
  EmailOpenedEvent,
12
24
  EmailProvider,
25
+ EmailProviderCapabilities,
26
+ EmailProviderMeta,
27
+ /** @deprecated Use {@link EmailEvent}; cast `event.raw`. */
13
28
  EmailSentEvent,
29
+ /** @deprecated Use {@link EmailEvent}. Frozen `event.raw` cast target. */
30
+ LegacyResendWebhookEvent,
14
31
  SendEmailOptions,
15
32
  SendResult,
33
+ /** @deprecated Use {@link EmailEvent}. Kept for one minor. */
16
34
  WebhookEvent,
35
+ /** @deprecated Use {@link EmailEventType}. Kept for one minor. */
17
36
  WebhookEventType,
18
37
  WebhookHandlerMap,
19
38
  } from "@hogsend/core";
39
+ export { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
package/src/webhooks.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ type BounceClass,
3
+ type EmailEvent,
4
+ type EmailEventType,
5
+ normalizeRecipients,
6
+ } from "@hogsend/core";
1
7
  import { WebhookVerificationError } from "@hogsend/email";
2
8
  import { Webhook } from "svix";
3
9
  import type {
@@ -6,11 +12,123 @@ import type {
6
12
  WebhookHandlerMap,
7
13
  } from "./types.js";
8
14
 
15
+ const VALID_TYPES: readonly EmailEventType[] = [
16
+ "email.sent",
17
+ "email.delivered",
18
+ "email.bounced",
19
+ "email.complained",
20
+ "email.delivery_delayed",
21
+ "email.opened",
22
+ "email.clicked",
23
+ ];
24
+
25
+ /**
26
+ * Resend's `bounce.type` is a FREE STRING (no enum). Map a case-insensitive
27
+ * substring of it to a provider-neutral {@link EmailEvent} bounce class. The raw
28
+ * Resend string is preserved in `bounce.code` so nothing is lost.
29
+ *
30
+ * - `permanent` → auto-suppress (the engine increments `bounceCount`).
31
+ * - `transient` → recorded as `email.bounced` but does NOT suppress.
32
+ * - `complaint` → immediate suppress via the complaint path.
33
+ * - `unknown` → recorded, NEVER suppresses (conservative default).
34
+ */
35
+ export function classifyResendBounce(type: string | undefined): BounceClass {
36
+ const t = (type ?? "").toLowerCase();
37
+ if (!t) return "unknown";
38
+ const has = (needle: string) => t.includes(needle.toLowerCase());
39
+
40
+ // Complaint/spam/abuse first — a "spam complaint" must never be read as a
41
+ // permanent bounce.
42
+ if (has("complaint") || has("spam") || has("abuse")) return "complaint";
43
+ if (
44
+ has("hardbounce") ||
45
+ has("hard_bounce") ||
46
+ has("permanent") ||
47
+ has("suppressedrecipient") ||
48
+ has("suppressed")
49
+ ) {
50
+ return "permanent";
51
+ }
52
+ if (
53
+ has("softbounce") ||
54
+ has("soft_bounce") ||
55
+ has("transient") ||
56
+ has("mailboxfull") ||
57
+ has("mailbox_full") ||
58
+ has("throttled") ||
59
+ has("undetermined")
60
+ ) {
61
+ return "transient";
62
+ }
63
+ return "unknown";
64
+ }
65
+
66
+ /**
67
+ * Adapt Resend's verbatim webhook payload into the provider-neutral
68
+ * {@link EmailEvent}. Maps `data.email_id` → `messageId`, `data.to` →
69
+ * `recipients`, `created_at` → `occurredAt`, `data.click` → `click`, and
70
+ * `data.bounce.{type,message}` → `bounce` (via {@link classifyResendBounce}).
71
+ * The untouched payload is preserved in `raw` as the deprecation escape hatch.
72
+ */
73
+ export function toEmailEvent(raw: WebhookEvent): EmailEvent {
74
+ const occurredAt = raw.created_at ?? raw.data?.created_at ?? "";
75
+ const recipients = normalizeRecipients(
76
+ raw.data?.to as string | string[] | undefined,
77
+ );
78
+
79
+ const base = {
80
+ messageId: raw.data?.email_id ?? "",
81
+ recipients,
82
+ occurredAt,
83
+ raw,
84
+ };
85
+
86
+ switch (raw.type) {
87
+ case "email.bounced": {
88
+ const bounce = raw.data.bounce;
89
+ return {
90
+ ...base,
91
+ type: "email.bounced",
92
+ bounce: {
93
+ class: classifyResendBounce(bounce?.type),
94
+ code: bounce?.type ?? "",
95
+ ...(bounce?.message ? { reason: bounce.message } : {}),
96
+ },
97
+ };
98
+ }
99
+ case "email.complained":
100
+ return {
101
+ ...base,
102
+ type: "email.complained",
103
+ bounce: { class: "complaint", code: "complaint" },
104
+ };
105
+ case "email.clicked": {
106
+ const click = raw.data.click;
107
+ return {
108
+ ...base,
109
+ type: "email.clicked",
110
+ click: {
111
+ url: click?.link ?? "",
112
+ ...(click?.timestamp ? { at: click.timestamp } : {}),
113
+ ...(click?.ipAddress ? { ip: click.ipAddress } : {}),
114
+ ...(click?.userAgent ? { ua: click.userAgent } : {}),
115
+ },
116
+ };
117
+ }
118
+ default:
119
+ return { ...base, type: raw.type as EmailEventType };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Svix-verify Resend's webhook over the EXACT received payload bytes, then adapt
125
+ * it into the provider-neutral {@link EmailEvent}.
126
+ */
9
127
  export function verifyWebhook(opts: {
10
128
  payload: string;
11
129
  headers: Record<string, string>;
12
130
  signingSecret: string;
13
- }): WebhookEvent {
131
+ }): EmailEvent {
14
132
  const svixId = opts.headers["svix-id"];
15
133
  const svixTimestamp = opts.headers["svix-timestamp"];
16
134
  const svixSignature = opts.headers["svix-signature"];
@@ -29,7 +147,7 @@ export function verifyWebhook(opts: {
29
147
  "svix-signature": svixSignature,
30
148
  }) as WebhookEvent;
31
149
 
32
- return event;
150
+ return toEmailEvent(event);
33
151
  } catch (error) {
34
152
  throw new WebhookVerificationError(
35
153
  `Webhook verification failed: ${error instanceof Error ? error.message : "unknown error"}`,
@@ -44,14 +162,14 @@ export function createWebhookHandler(opts: {
44
162
  return async (
45
163
  payload: string,
46
164
  headers: Record<string, string>,
47
- ): Promise<{ type: WebhookEventType; handled: boolean }> => {
165
+ ): Promise<{ type: EmailEventType; handled: boolean }> => {
48
166
  const event = verifyWebhook({
49
167
  payload,
50
168
  headers,
51
169
  signingSecret: opts.signingSecret,
52
170
  });
53
171
  const handler = opts.handlers[event.type] as
54
- | ((event: WebhookEvent) => void | Promise<void>)
172
+ | ((event: EmailEvent) => void | Promise<void>)
55
173
  | undefined;
56
174
 
57
175
  if (handler) {
@@ -63,24 +181,15 @@ export function createWebhookHandler(opts: {
63
181
  };
64
182
  }
65
183
 
66
- export function parseWebhookEvent(payload: string): WebhookEvent {
184
+ /** Parse an unsigned Resend payload into the neutral {@link EmailEvent}. */
185
+ export function parseWebhookEvent(payload: string): EmailEvent {
67
186
  const parsed = JSON.parse(payload) as WebhookEvent;
68
187
 
69
- const validTypes: WebhookEventType[] = [
70
- "email.sent",
71
- "email.delivered",
72
- "email.bounced",
73
- "email.complained",
74
- "email.delivery_delayed",
75
- "email.opened",
76
- "email.clicked",
77
- ];
78
-
79
- if (!validTypes.includes(parsed.type)) {
188
+ if (!VALID_TYPES.includes(parsed.type as EmailEventType)) {
80
189
  throw new WebhookVerificationError(
81
- `Unknown webhook event type: ${parsed.type}`,
190
+ `Unknown webhook event type: ${parsed.type as WebhookEventType}`,
82
191
  );
83
192
  }
84
193
 
85
- return parsed;
194
+ return toEmailEvent(parsed);
86
195
  }