@hogsend/plugin-resend 0.9.0 → 0.10.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 +21 -8
- package/package.json +3 -3
- package/src/__tests__/send.test.ts +82 -12
- package/src/__tests__/webhooks.test.ts +84 -6
- package/src/index.ts +20 -1
- package/src/provider.ts +21 -10
- package/src/send.ts +10 -6
- package/src/types.ts +20 -0
- package/src/webhooks.ts +127 -18
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`createResendProvider` is the **reference implementation** of the
|
|
8
|
-
contract — the contract itself lives in `@hogsend/core` (canonical
|
|
9
|
-
`@hogsend/engine`); this package re-exports `EmailProvider` and its
|
|
10
|
-
types for back-compat. To support another provider, implement that
|
|
11
|
-
|
|
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.
|
|
3
|
+
"version": "0.10.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.
|
|
28
|
-
"@hogsend/email": "^0.
|
|
27
|
+
"@hogsend/core": "^0.10.0",
|
|
28
|
+
"@hogsend/email": "^0.10.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
html: HTML,
|
|
164
234
|
},
|
|
165
235
|
{
|
|
166
236
|
from: "a@hogsend.com",
|
|
167
237
|
to: "c@example.com",
|
|
168
238
|
subject: "B",
|
|
169
|
-
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
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.
|
|
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("
|
|
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: "
|
|
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
|
-
//
|
|
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
|
|
5
|
-
BatchEmailItem,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
}):
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}):
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|