typedmailer 1.4.0 → 2.1.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 +12 -6
- package/dist/config.d.ts +4 -0
- package/dist/config.js +18 -8
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +42 -26
- package/dist/index.d.ts +3 -3
- package/dist/index.js +38 -2
- package/dist/providers/registry.d.ts +31 -16
- package/dist/providers/registry.js +9 -7
- package/dist/providers/resend.js +1 -1
- package/dist/providers/sendgrid.js +1 -1
- package/dist/providers/smtp.js +8 -1
- package/dist/testing.js +5 -3
- package/dist/types.d.ts +24 -0
- package/dist/webhooks/normalize.d.ts +1 -1
- package/dist/webhooks/normalize.js +20 -3
- package/dist/webhooks/signatures.d.ts +5 -2
- package/dist/webhooks/signatures.js +26 -5
- package/dist/webhooks/types.d.ts +7 -0
- package/dist/webhooks.js +11 -5
- package/docs/framework-webhooks.md +60 -0
- package/docs/migration-v2.md +35 -0
- package/docs/provider-contracts.md +11 -3
- package/docs/reliability.md +59 -0
- package/docs/webhooks.md +6 -2
- package/examples/reliability/durable-mail.ts +134 -0
- package/examples/reliability/schema.sql +20 -0
- package/package.json +12 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EmailWebhookEvent } from './types.js';
|
|
2
|
-
export declare function normalizeResend(payload: unknown): readonly EmailWebhookEvent[];
|
|
2
|
+
export declare function normalizeResend(payload: unknown, maxRecipients?: number): readonly EmailWebhookEvent[];
|
|
3
3
|
export declare function normalizeMailgun(payload: unknown): readonly EmailWebhookEvent[];
|
|
4
4
|
export declare function normalizeSendGrid(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
|
|
5
5
|
export declare function normalizeSes(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { WebhookVerificationError } from './types.js';
|
|
2
2
|
import { asId, asOptionalRecord, asRecord, asString, asStringArray, firstString, isString, parseDate, } from './shared.js';
|
|
3
|
-
export function normalizeResend(payload) {
|
|
3
|
+
export function normalizeResend(payload, maxRecipients = 1_000) {
|
|
4
4
|
const root = asRecord(payload);
|
|
5
5
|
const data = asRecord(root.data);
|
|
6
|
+
const singleRecipient = asString(data.to);
|
|
7
|
+
const recipients = singleRecipient ? [singleRecipient] : asStringArray(data.to);
|
|
8
|
+
if ((Array.isArray(data.to) && data.to.length > maxRecipients) || recipients.length > maxRecipients)
|
|
9
|
+
throw new WebhookVerificationError('invalid_payload');
|
|
6
10
|
return [
|
|
7
11
|
makeEvent('resend', root, {
|
|
8
12
|
id: asString(root.id),
|
|
@@ -10,6 +14,7 @@ export function normalizeResend(payload) {
|
|
|
10
14
|
type: mapEventType(asString(root.type)),
|
|
11
15
|
messageId: asString(data.email_id),
|
|
12
16
|
recipient: firstString(data.to),
|
|
17
|
+
...(recipients.length ? { recipients } : {}),
|
|
13
18
|
occurredAt: parseDate(root.created_at),
|
|
14
19
|
}),
|
|
15
20
|
];
|
|
@@ -66,6 +71,14 @@ export function normalizeSes(payload, maxEvents) {
|
|
|
66
71
|
}
|
|
67
72
|
const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
|
|
68
73
|
const emails = to.length > 0 ? to : asStringArray(mail.destination);
|
|
74
|
+
if (emails.length > maxEvents)
|
|
75
|
+
throw new WebhookVerificationError('invalid_payload');
|
|
76
|
+
const occurredAt = parseDate(delivery?.timestamp) ??
|
|
77
|
+
parseDate(bounce?.timestamp) ??
|
|
78
|
+
parseDate(complaint?.timestamp) ??
|
|
79
|
+
parseDate(asOptionalRecord(message.deliveryDelay)?.timestamp) ??
|
|
80
|
+
parseDate(asOptionalRecord(message.reject)?.timestamp) ??
|
|
81
|
+
parseDate(mail.timestamp);
|
|
69
82
|
return emails.length > 0
|
|
70
83
|
? emails.map((recipient) => makeEvent('ses', message, {
|
|
71
84
|
id: asString(message.messageId),
|
|
@@ -73,7 +86,7 @@ export function normalizeSes(payload, maxEvents) {
|
|
|
73
86
|
type: mapEventType(eventName),
|
|
74
87
|
messageId: asString(mail.messageId),
|
|
75
88
|
recipient,
|
|
76
|
-
occurredAt
|
|
89
|
+
occurredAt,
|
|
77
90
|
}))
|
|
78
91
|
: [
|
|
79
92
|
makeEvent('ses', message, {
|
|
@@ -81,7 +94,7 @@ export function normalizeSes(payload, maxEvents) {
|
|
|
81
94
|
eventType: eventName,
|
|
82
95
|
type: mapEventType(eventName),
|
|
83
96
|
messageId: asString(mail.messageId),
|
|
84
|
-
occurredAt
|
|
97
|
+
occurredAt,
|
|
85
98
|
}),
|
|
86
99
|
];
|
|
87
100
|
}
|
|
@@ -96,6 +109,7 @@ export function normalizeProviderEvents(provider, payload, maxEvents) {
|
|
|
96
109
|
id: provider === 'brevo'
|
|
97
110
|
? (asId(event.id) ?? asString(event['message-id']))
|
|
98
111
|
: (asId(event.ID) ?? asString(event.MessageID)),
|
|
112
|
+
eventId: provider === 'brevo' ? asId(event.id) : asId(event.ID),
|
|
99
113
|
eventType: eventName,
|
|
100
114
|
type: mapEventType(eventName),
|
|
101
115
|
messageId: provider === 'brevo' ? asString(event['message-id']) : asString(event.MessageID),
|
|
@@ -107,12 +121,15 @@ export function normalizeProviderEvents(provider, payload, maxEvents) {
|
|
|
107
121
|
function makeEvent(provider, raw, fields) {
|
|
108
122
|
if (!fields.eventType)
|
|
109
123
|
throw new WebhookVerificationError('invalid_payload');
|
|
124
|
+
const eventId = fields.eventId ?? (provider !== 'brevo' && provider !== 'postmark' && provider !== 'ses' ? fields.id : undefined);
|
|
110
125
|
return {
|
|
111
126
|
provider,
|
|
112
127
|
raw,
|
|
113
128
|
eventType: fields.eventType,
|
|
114
129
|
type: fields.type,
|
|
115
130
|
...(fields.id ? { id: fields.id } : {}),
|
|
131
|
+
...(eventId ? { eventId } : {}),
|
|
132
|
+
...(fields.recipients ? { recipients: fields.recipients } : {}),
|
|
116
133
|
...(fields.messageId ? { messageId: fields.messageId } : {}),
|
|
117
134
|
...(fields.recipient ? { recipient: fields.recipient } : {}),
|
|
118
135
|
...(fields.occurredAt ? { occurredAt: fields.occurredAt } : {}),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { VerifyWebhookInput } from './types.js';
|
|
2
2
|
export declare function verifyResend(input: Extract<VerifyWebhookInput, {
|
|
3
3
|
provider: 'resend';
|
|
4
|
-
}>, rawBody: Uint8Array):
|
|
4
|
+
}>, rawBody: Uint8Array): string;
|
|
5
5
|
export declare function verifyMailgun(input: Extract<VerifyWebhookInput, {
|
|
6
6
|
provider: 'mailgun';
|
|
7
7
|
}>, payload: unknown): void;
|
|
@@ -13,4 +13,7 @@ export declare function verifyAuthorization(input: Extract<VerifyWebhookInput, {
|
|
|
13
13
|
}>, headerName: string): void;
|
|
14
14
|
export declare function verifySnsNotification(input: Extract<VerifyWebhookInput, {
|
|
15
15
|
provider: 'ses';
|
|
16
|
-
}>, payload: unknown): Promise<
|
|
16
|
+
}>, payload: unknown): Promise<{
|
|
17
|
+
readonly payload: unknown;
|
|
18
|
+
readonly deliveryId: string;
|
|
19
|
+
}>;
|
|
@@ -24,6 +24,7 @@ export function verifyResend(input, rawBody) {
|
|
|
24
24
|
.some((item) => item.startsWith('v1,') && safeEqual(expected, decodeBase64(item.slice(3))));
|
|
25
25
|
if (!matches)
|
|
26
26
|
throw new WebhookVerificationError('invalid_signature');
|
|
27
|
+
return messageId;
|
|
27
28
|
}
|
|
28
29
|
export function verifyMailgun(input, payload) {
|
|
29
30
|
const root = asRecord(payload);
|
|
@@ -73,7 +74,8 @@ export async function verifySnsNotification(input, payload) {
|
|
|
73
74
|
const signingCertUrl = asString(envelope.SigningCertURL);
|
|
74
75
|
const signature = asString(envelope.Signature);
|
|
75
76
|
const signatureVersion = asString(envelope.SignatureVersion);
|
|
76
|
-
if (envelope.
|
|
77
|
+
if (!asString(envelope.MessageId) ||
|
|
78
|
+
envelope.Type !== 'Notification' ||
|
|
77
79
|
topicArn !== input.topicArn ||
|
|
78
80
|
!signingCertUrl ||
|
|
79
81
|
!signature ||
|
|
@@ -114,13 +116,32 @@ export async function verifySnsNotification(input, payload) {
|
|
|
114
116
|
throw new WebhookVerificationError('invalid_signature');
|
|
115
117
|
let certificateBody;
|
|
116
118
|
try {
|
|
117
|
-
|
|
119
|
+
if (!response.body)
|
|
120
|
+
throw new WebhookVerificationError('invalid_signature');
|
|
121
|
+
const reader = response.body.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let length = 0;
|
|
124
|
+
try {
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done)
|
|
128
|
+
break;
|
|
129
|
+
length += value.byteLength;
|
|
130
|
+
if (length > 32_768) {
|
|
131
|
+
await reader.cancel();
|
|
132
|
+
throw new WebhookVerificationError('invalid_signature');
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
}
|
|
136
|
+
certificateBody = Buffer.concat(chunks, length).toString('utf8');
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
reader.releaseLock();
|
|
140
|
+
}
|
|
118
141
|
}
|
|
119
142
|
catch {
|
|
120
143
|
throw new WebhookVerificationError('invalid_signature');
|
|
121
144
|
}
|
|
122
|
-
if (certificateBody.length > 32_768)
|
|
123
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
124
145
|
try {
|
|
125
146
|
const certificate = new X509Certificate(certificateBody);
|
|
126
147
|
const now = input.now ?? new Date();
|
|
@@ -144,7 +165,7 @@ export async function verifySnsNotification(input, payload) {
|
|
|
144
165
|
}
|
|
145
166
|
try {
|
|
146
167
|
const message = JSON.parse(asString(envelope.Message) ?? '');
|
|
147
|
-
return message;
|
|
168
|
+
return { payload: message, deliveryId: asString(envelope.MessageId) };
|
|
148
169
|
}
|
|
149
170
|
catch {
|
|
150
171
|
throw new WebhookVerificationError('invalid_payload');
|
package/dist/webhooks/types.d.ts
CHANGED
|
@@ -2,11 +2,18 @@ export type ProviderName = import('../types.js').ProviderName;
|
|
|
2
2
|
export type EmailWebhookEventType = 'accepted' | 'delivered' | 'bounced' | 'complained' | 'delayed' | 'opened' | 'clicked' | 'unsubscribed' | 'rejected' | 'failed' | 'other';
|
|
3
3
|
export interface EmailWebhookEvent {
|
|
4
4
|
readonly provider: Exclude<ProviderName, 'smtp'>;
|
|
5
|
+
/** Legacy provider identifier; may fall back to the email message ID. */
|
|
5
6
|
readonly id?: string;
|
|
7
|
+
/** Provider event ID, without a message-ID fallback. */
|
|
8
|
+
readonly eventId?: string;
|
|
9
|
+
/** Authenticated transport notification ID (Resend/SNS). Shared by events in one notification. */
|
|
10
|
+
readonly deliveryId?: string;
|
|
6
11
|
readonly type: EmailWebhookEventType;
|
|
7
12
|
readonly eventType: string;
|
|
8
13
|
readonly messageId?: string;
|
|
9
14
|
readonly recipient?: string;
|
|
15
|
+
/** All Resend recipients; recipient retains the first address for compatibility. */
|
|
16
|
+
readonly recipients?: readonly string[];
|
|
10
17
|
readonly occurredAt?: Date;
|
|
11
18
|
/** Original provider data. It can contain message metadata and personal information. */
|
|
12
19
|
readonly raw: unknown;
|
package/dist/webhooks.js
CHANGED
|
@@ -24,9 +24,10 @@ export async function verifyWebhook(input) {
|
|
|
24
24
|
throw new WebhookVerificationError('invalid_payload');
|
|
25
25
|
}
|
|
26
26
|
switch (input.provider) {
|
|
27
|
-
case 'resend':
|
|
28
|
-
verifyResend(input, rawBody);
|
|
29
|
-
return normalizeResend(payload);
|
|
27
|
+
case 'resend': {
|
|
28
|
+
const deliveryId = verifyResend(input, rawBody);
|
|
29
|
+
return normalizeResend(payload, maxEvents).map((event) => ({ ...event, deliveryId }));
|
|
30
|
+
}
|
|
30
31
|
case 'mailgun':
|
|
31
32
|
verifyMailgun(input, payload);
|
|
32
33
|
return normalizeMailgun(payload);
|
|
@@ -37,7 +38,12 @@ export async function verifyWebhook(input) {
|
|
|
37
38
|
case 'postmark':
|
|
38
39
|
verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
|
|
39
40
|
return normalizeProviderEvents(input.provider, payload, maxEvents);
|
|
40
|
-
case 'ses':
|
|
41
|
-
|
|
41
|
+
case 'ses': {
|
|
42
|
+
const notification = await verifySnsNotification(input, payload);
|
|
43
|
+
return normalizeSes(notification.payload, maxEvents).map((event) => ({
|
|
44
|
+
...event,
|
|
45
|
+
deliveryId: notification.deliveryId,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Next.js and Express webhook routes
|
|
2
|
+
|
|
3
|
+
The repository includes TypeScript Resend webhook examples in `examples/webhooks/`. Copy the appropriate adapter and `shared.ts` into your application. These files are examples, not new package exports; they depend on `typedmailer/webhooks`, and the Express adapter additionally needs `express` and its TypeScript types.
|
|
4
|
+
|
|
5
|
+
Both examples preserve the exact signed request bytes, limit the raw body to 1 MiB, authenticate with the endpoint signing secret, and await `acceptEvents()` before returning HTTP 204. Implement that callback with a durable inbox or queue: store a stable provider event key under a unique constraint and commit atomically. Duplicate deliveries should resolve successfully after confirming the prior durable acceptance. The callback's second argument supplies `deliveryId` from Resend's authenticated `svix-id` header; use it with the provider name as an inbox uniqueness key. Normalized event IDs are optional. Keep business processing in your worker.
|
|
6
|
+
|
|
7
|
+
The examples return 400 for malformed event payloads, 401 for failed signatures, 413 for oversized bodies, and 503 for acceptance failures. The Express adapter also returns 415 for unsupported content types or compressed requests. Upstream proxies and hosting platforms should enforce matching request-size and timeout limits.
|
|
8
|
+
|
|
9
|
+
## Next.js App Router
|
|
10
|
+
|
|
11
|
+
Copy `nextjs.ts` and `shared.ts` into `lib/webhooks/`, then create `app/api/webhooks/resend/route.ts`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createResendWebhookHandler } from '@/lib/webhooks/nextjs';
|
|
15
|
+
import { acceptEmailEvents } from '@/lib/email-event-inbox';
|
|
16
|
+
|
|
17
|
+
export const runtime = 'nodejs';
|
|
18
|
+
|
|
19
|
+
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
|
|
20
|
+
if (!webhookSecret) throw new Error('RESEND_WEBHOOK_SECRET is required.');
|
|
21
|
+
|
|
22
|
+
export const POST = createResendWebhookHandler({
|
|
23
|
+
webhookSecret,
|
|
24
|
+
acceptEvents: acceptEmailEvents,
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`acceptEmailEvents` is your application's durable ingestion function. It receives `readonly EmailWebhookEvent[]` and `{ deliveryId: string }`, and resolves after the transaction or durable queue write succeeds. The adapter uses a standard Web `Request` and `Response`, so it does not need to import Next.js. Its streaming reader checks actual received bytes even when `Content-Length` is absent or inaccurate.
|
|
29
|
+
|
|
30
|
+
## Express
|
|
31
|
+
|
|
32
|
+
Copy `express.ts` and `shared.ts` into your application, then mount the router before the global JSON parser:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import express from 'express';
|
|
36
|
+
import { createResendWebhookRouter } from './webhooks/express.js';
|
|
37
|
+
import { acceptEmailEvents } from './email-event-inbox.js';
|
|
38
|
+
|
|
39
|
+
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
|
|
40
|
+
if (!webhookSecret) throw new Error('RESEND_WEBHOOK_SECRET is required.');
|
|
41
|
+
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(
|
|
44
|
+
'/webhooks/resend',
|
|
45
|
+
createResendWebhookRouter({
|
|
46
|
+
webhookSecret,
|
|
47
|
+
acceptEvents: acceptEmailEvents,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
app.use(express.json());
|
|
51
|
+
app.listen(3000);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Do not mount `express.json()` before the webhook router. Parsed and reserialized JSON does not preserve the signed bytes. The route uses `express.raw()` with a finite byte limit and disables decompression to retain the exact raw input.
|
|
55
|
+
|
|
56
|
+
## Verification
|
|
57
|
+
|
|
58
|
+
`tests/webhook-examples.test.ts` exercises signed and tampered requests, body limits, invalid JSON, and failed durable acceptance. Express tests use an ephemeral local HTTP server. SDK and webhook tests never send real emails. CI separately verifies SMTP through Mailpit.
|
|
59
|
+
|
|
60
|
+
References: [Next.js Route Handlers](https://nextjs.org/docs/app/api-reference/file-conventions/route), [Express raw parser](https://expressjs.com/en/5x/api.html#express.raw), and [Resend webhook verification](https://resend.com/docs/webhooks/introduction).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Migrating from v1 to v2
|
|
2
|
+
|
|
3
|
+
TypedMailer 2 retains the shared `createMailer()` / `send()` API, lazy optional SDK loading, Node.js 22+ support, and AWS credential chain. It tightens configuration contracts and corrects provider attachment serialization.
|
|
4
|
+
|
|
5
|
+
## Provider SDKs
|
|
6
|
+
|
|
7
|
+
Amazon SES now requires `@aws-sdk/client-sesv2 >=3.797.0 <4`. Older versions can silently omit `Simple.Headers` and `Simple.Attachments` when serializing requests. Upgrade your installed SDK and lockfile:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install typedmailer@^2 @aws-sdk/client-sesv2@^3.797.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Other provider SDK peer ranges are unchanged. Install only the SDKs your application uses.
|
|
14
|
+
|
|
15
|
+
## Configuration validation
|
|
16
|
+
|
|
17
|
+
Built-in provider options now reject unknown keys, matching custom provider configuration and message validation. Pass only the documented fields rather than spreading an application-wide configuration object. For example, a misspelled `apiToken` is rejected rather than ignored.
|
|
18
|
+
|
|
19
|
+
Named senders such as `App <sender@example.com>` validate the enclosed email address using the same validation as plain and object addresses. `App <a@b>`, blank display names in named strings, and line breaks in display names are rejected. The configured sender is validated immediately in `createTestMailer()` as well as `createMailer()`. Valid email strings and `{ email, name }` objects remain supported. Validation failures remain Zod errors; transport failures remain `MailError` instances.
|
|
20
|
+
|
|
21
|
+
## Attachments
|
|
22
|
+
|
|
23
|
+
Attachment strings represent UTF-8 file content for every adapter; `Uint8Array` represents raw bytes. The Resend adapter now Base64-encodes both forms before calling its SDK. If you worked around the v1 Resend behavior by supplying a Base64 string, pass the original text or `Buffer.from(encodedContent, 'base64')` instead. Pre-encoded strings would otherwise be encoded a second time.
|
|
24
|
+
|
|
25
|
+
`maxAttachmentBytes` still measures original content bytes, before provider Base64 encoding. Attachments remain buffered; streaming, upload storage, and concurrency budgets belong to the application.
|
|
26
|
+
|
|
27
|
+
## Result types and custom adapters
|
|
28
|
+
|
|
29
|
+
`createMailer({ provider: 'resend', ... })` now returns `Mailer<'resend'>`. A union of provider options preserves that union. Built-in results no longer include the impossible `'test'` provider; `createTestMailer()` still returns `Mailer<'test'>`. Broader explicit `Mailer` / `SendMailResult` annotations remain valid.
|
|
30
|
+
|
|
31
|
+
Every successful send must have a non-blank provider message ID. Custom adapters must return `{ messageId: 'provider-id' }`. Blank, missing, or invalid IDs reject with `MailError`, code `provider`, and `deliveryUnknown: true`, matching built-in adapters. Do not blindly retry such sends: the provider may already have accepted the email.
|
|
32
|
+
|
|
33
|
+
## Webhook routes
|
|
34
|
+
|
|
35
|
+
The webhook API is unchanged. New [Next.js and Express examples](framework-webhooks.md) demonstrate raw-body preservation, bounded reads, and acknowledging only after the application durably accepts events. They do not install a database or implement business side effects.
|
|
@@ -6,7 +6,9 @@ The adapters provide one `createMailer(...).send(...)` interface, while provider
|
|
|
6
6
|
|
|
7
7
|
- Provider SDKs are optional peers. Only the selected adapter is loaded, and a missing selected SDK becomes a `MailError` with code `configuration`.
|
|
8
8
|
- `send()` resolves when the provider reports that it accepted the request. It does not prove inbox delivery.
|
|
9
|
-
- A successful result includes the selected provider, provider message ID, and local acceptance timestamp. If
|
|
9
|
+
- A successful result includes the selected provider, a non-blank provider message ID, and local acceptance timestamp. If a built-in or custom provider gives no usable ID, `send()` rejects with a `MailError` with code `provider` and `deliveryUnknown: true`.
|
|
10
|
+
- SMTP results include optional `accepted`/`rejected` envelope recipients. Partial acceptance resolves, while complete rejection rejects; acceptance does not prove inbox delivery.
|
|
11
|
+
- Optional `onSend` observers expose sanitized lifecycle metrics, with synchronous and asynchronous failures isolated. See [reliability](reliability.md).
|
|
10
12
|
- TypedMailer does not retry automatically. `retryable` and `deliveryUnknown` are diagnostic signals, not a safe retry instruction. A timeout or ambiguous provider response can mean the provider accepted the message.
|
|
11
13
|
- Unsupported fields or operations fail with `MailError` code `unsupported`; adapters must not silently discard caller data.
|
|
12
14
|
- `close()` is safe to call repeatedly, stops new work, waits for in-flight operations, and closes the underlying transport at most once.
|
|
@@ -23,7 +25,13 @@ The adapters provide one `createMailer(...).send(...)` interface, while provider
|
|
|
23
25
|
| Amazon SES | No | No | Yes | Yes | Unsupported |
|
|
24
26
|
| SMTP | Yes | No | No | Yes | SMTP connection check |
|
|
25
27
|
|
|
26
|
-
Attachments are buffered for provider SDK requests. Configure `maxAttachmentBytes` on `createMailer()` to enforce an aggregate byte cap over all attachment content; strings are counted as UTF-8 bytes and `Uint8Array` content by `byteLength`. The option has no default cap to preserve existing behavior. Large-file streaming belongs in the application. `contentId` is supported by the providers listed as supporting inline attachments. `verifyConnection()` is only available for SMTP because the API providers do not offer a side-effect-free check through this interface.
|
|
28
|
+
Attachments are buffered for provider SDK requests. Configure `maxAttachmentBytes` on `createMailer()` to enforce an aggregate byte cap over all attachment content; strings represent UTF-8 file content and are counted as UTF-8 bytes and `Uint8Array` content by `byteLength`. The option has no default cap to preserve existing behavior. Large-file streaming belongs in the application. `contentId` is supported by the providers listed as supporting inline attachments. `verifyConnection()` is only available for SMTP because the API providers do not offer a side-effect-free check through this interface.
|
|
29
|
+
|
|
30
|
+
## SDK serialization compatibility
|
|
31
|
+
|
|
32
|
+
Amazon SES requires `@aws-sdk/client-sesv2 >=3.797.0 <4` to serialize both custom headers and attachments. Resend receives Base64 attachment strings derived from the original UTF-8 text or binary bytes. `tests/providers/sdk-serialization.test.ts` runs the actual Resend SDK with an intercepted fetch and the actual SES SDK with a local transport; it verifies the serialized HTTP payload rather than only mocked SDK arguments. These tests run against both the lockfile and minimum supported SDK versions in CI.
|
|
33
|
+
|
|
34
|
+
Built-in mailers preserve the selected provider literal (or provider union) in their result type. Built-in and custom configurations reject unknown keys and share sender validation.
|
|
27
35
|
|
|
28
36
|
## Updating a provider adapter
|
|
29
37
|
|
|
@@ -31,4 +39,4 @@ When changing an adapter, add its configuration schema and lazy loader in `src/p
|
|
|
31
39
|
|
|
32
40
|
## Custom adapters
|
|
33
41
|
|
|
34
|
-
Applications can provide a `ProviderAdapter<TName>` directly to `createMailer()` without adding a built-in provider. Its `send()` method receives `NormalizedMailInput`: `from` is always set, and `to`, `cc`, and `bcc` are arrays when present. Resolve with `{ messageId }` after the SDK accepts the message. `verifyConnection()` and `close()` are optional; verification rejects with `unsupported` when omitted, while close is a no-op. TypedMailer normalizes thrown errors and tags results with the adapter's name. The generic adapter name is preserved in `SendMailResult<TName>` for TypeScript callers. Custom adapters are responsible for documenting and enforcing unsupported message fields.
|
|
42
|
+
Applications can provide a `ProviderAdapter<TName>` directly to `createMailer()` without adding a built-in provider. Its `send()` method receives `NormalizedMailInput`: `from` is always set, and `to`, `cc`, and `bcc` are arrays when present. Resolve with `{ messageId }` and optional `accepted`/`rejected` recipient lists after the SDK accepts the message. `verifyConnection()` and `close()` are optional; verification rejects with `unsupported` when omitted, while close is a no-op. TypedMailer normalizes thrown errors and tags results with the adapter's name. The generic adapter name is preserved in `SendMailResult<TName>` for TypeScript callers. Custom adapters are responsible for documenting and enforcing unsupported message fields.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Reliable application delivery
|
|
2
|
+
|
|
3
|
+
TypedMailer reports provider acceptance, not inbox delivery. It performs no automatic retry, queueing, database migration, or failover. Version 2.1 adds optional SMTP recipient results, structured provider errors, authenticated webhook notification IDs, and isolated send observers.
|
|
4
|
+
|
|
5
|
+
## SMTP partial acceptance
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const result = await mailer.send(message);
|
|
9
|
+
if (result.rejected?.length) {
|
|
10
|
+
await recordPartialAcceptance(result);
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
SMTP `accepted` and `rejected` contain envelope recipient addresses reported by the relay, including CC/BCC. A send with some rejected recipients still resolves when the relay accepts the remaining recipients. Do not retry the entire original message: recipients already accepted could receive duplicates. When every recipient is rejected, the SMTP adapter rejects. These fields are optional for other and custom providers. Treat the recipient lists as personal data.
|
|
15
|
+
|
|
16
|
+
## Error diagnostics and observations
|
|
17
|
+
|
|
18
|
+
`MailError.status` contains the HTTP or SMTP response status when the SDK exposes it. `retryAfterSeconds` contains an optional parsed `Retry-After` delay, rounded up to whole seconds; unsupported/malformed delays remain undefined. These values do not override `deliveryUnknown` or make a retry safe. The original SDK error remains in `cause` and can contain sensitive information.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const mailer = createMailer({
|
|
22
|
+
provider: 'resend',
|
|
23
|
+
apiKey,
|
|
24
|
+
from: 'sender@example.test',
|
|
25
|
+
onSend(event) {
|
|
26
|
+
if (event.type === 'started') return;
|
|
27
|
+
metrics.record(event.provider, event.type, event.durationMs);
|
|
28
|
+
if (event.type === 'failed') metrics.recordFailure(event.code);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`onSend` emits `started` followed by `succeeded` or `failed` for a validated send that reaches provider execution. Payloads contain provider, duration, and safe error classifications, never addresses, subject, content, headers, credentials, or arbitrary SDK errors. Construction/message validation failures and calls after close do not emit send events. Observer exceptions and rejected promises are ignored deliberately. Async observers are not awaited by `send()` or `close()`; applications own flushing and error reporting for their telemetry. Keep synchronous callbacks short. No telemetry dependency is installed by the package.
|
|
34
|
+
|
|
35
|
+
## Durable webhook inbox
|
|
36
|
+
|
|
37
|
+
Copy [`durable-mail.ts`](../examples/reliability/durable-mail.ts) and [`schema.sql`](../examples/reliability/schema.sql) into your application. Supply a `SqlDatabase` adapter for your PostgreSQL client. `acceptWebhookDelivery` inserts one complete notification with a unique `(provider, delivery_id)` constraint. Use it from the framework handler's `acceptEvents` callback and resolve only after database commit. A storage failure must return a retryable HTTP failure, not an acknowledgment.
|
|
38
|
+
|
|
39
|
+
For Resend and SES, `verifyWebhook()` provides an authenticated `deliveryId`. It identifies the whole notification; SES can produce multiple recipient events sharing this ID. Store the complete event array once, then apply business changes in a transaction with an inbox processing marker. Do not deduplicate each recipient separately using only `deliveryId`. For other providers, use genuine `eventId` values where present, or an application-defined provider-specific key. The legacy `id` can equal a message ID and must not be assumed unique across delivery/open/bounce events. Do not invent an unsigned delivery ID from a request header.
|
|
40
|
+
|
|
41
|
+
The supplied inbox schema stores normalized events including `raw`. Define retention, access controls, encryption, and any payload redaction in your application. Processing markers and business transactions depend on your domain and are not implemented by this example.
|
|
42
|
+
|
|
43
|
+
## Transactional outbox
|
|
44
|
+
|
|
45
|
+
`enqueueMail` uses a stable business-operation ID to prevent duplicate queue entries. Validate the message first and call it in the same database transaction as the business change. Apply `schema.sql` using your application's migration process. The adapter passed to enqueue can represent an open transaction; worker and inbox adapters must commit before reporting success. Binary attachments are encoded explicitly for JSON storage and restored as `Uint8Array`.
|
|
46
|
+
|
|
47
|
+
`createSqlOutboxStore` claims one queued row with `FOR UPDATE SKIP LOCKED`, committing `sending` before network I/O. `processNextMail` sends once and records one of these states:
|
|
48
|
+
|
|
49
|
+
| State | Meaning and next action |
|
|
50
|
+
| ---------- | ---------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `accepted` | Provider accepted the request. Follow delivery webhooks separately. |
|
|
52
|
+
| `partial` | SMTP rejected some recipients. Reconcile the rejected subset. |
|
|
53
|
+
| `failed` | Classified rejection with no uncertain acceptance. Application policy decides whether to requeue. |
|
|
54
|
+
| `unknown` | Acceptance cannot be established. Reconcile with the provider before resending. |
|
|
55
|
+
| `sending` | Claimed, but no outcome persisted yet. A worker crash or failed receipt commit can leave this state. |
|
|
56
|
+
|
|
57
|
+
The example deliberately does not reclaim stale `sending` rows automatically. Reconcile them and move to `unknown` when acceptance cannot be proven. A failed receipt commit propagates to the worker; it never reclassifies an accepted send as a failure. Use provider idempotency only where supported and keep the key stable across retries. Never resend the whole partial/unknown job automatically.
|
|
58
|
+
|
|
59
|
+
Tests run the SQL against embedded PostgreSQL (PGlite), verify deduplication and state transitions, and exercise database failure paths. Production database concurrency, migrations, worker deployment, provider delivery, and operational recovery require application-level verification.
|
package/docs/webhooks.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
TypedMailer exposes `verifyWebhook()` from `typedmailer/webhooks`. It authenticates a raw HTTP webhook request before returning normalized event records. It does not start an HTTP server, acknowledge requests, persist event IDs, or perform application actions. Your route owns HTTP responses, durable deduplication, and business logic.
|
|
4
4
|
|
|
5
|
+
For framework integration, use the tested [Next.js App Router and Express route examples](framework-webhooks.md).
|
|
6
|
+
|
|
5
7
|
## Raw request bodies
|
|
6
8
|
|
|
7
9
|
Pass the exact bytes received by your HTTP framework. Parsing JSON and serializing it again changes the signed input and causes verification to fail. `rawBody` accepts a string or `Uint8Array`; `headers` is a case-insensitive record of request header names and values. If the framework parses request bodies automatically, configure a raw-body capture before its JSON parser. Apply an HTTP request-body size limit before buffering the body. `verifyWebhook()` also rejects bodies larger than 1 MiB by default; set `maxBodyBytes` to a suitable positive value for your provider and expected batch size. Normalized event batches are limited to 1,000 events by default; set `maxEvents` to adjust that limit. Keep both limits finite and consistent with the upstream request limit.
|
|
@@ -42,13 +44,15 @@ for (const event of events) {
|
|
|
42
44
|
|
|
43
45
|
## Normalized event shape
|
|
44
46
|
|
|
45
|
-
Each event includes `provider`, provider `eventType`, a normalized `type`, and the original provider payload in `raw`. When available, it also includes a provider event ID
|
|
47
|
+
Each event includes `provider`, provider `eventType`, a normalized `type`, and the original provider payload in `raw`. When available, it also includes `eventId` (a genuine provider event ID), `messageId`, `recipient`, and `occurredAt`. Resend/SNS events also carry an authenticated `deliveryId`. Resend preserves all recipients in `recipients` while retaining the first in `recipient` and still returning one event. SES returns one event per recipient and preserves complaint/delay timestamps.
|
|
48
|
+
|
|
49
|
+
`id` remains a legacy identifier for compatibility: Brevo/Postmark can fall back to the message ID. Do not assume it is unique across different events for the same email. `raw` may contain personal data and provider metadata; avoid logging it without review. Unknown provider event names map to `type: 'other'` and retain their original `eventType`.
|
|
46
50
|
|
|
47
51
|
Normalized types are `accepted`, `delivered`, `bounced`, `complained`, `delayed`, `opened`, `clicked`, `unsubscribed`, `rejected`, `failed`, and `other`.
|
|
48
52
|
|
|
49
53
|
## Delivery, retries, and deduplication
|
|
50
54
|
|
|
51
|
-
Providers retry webhook delivery. `verifyWebhook()` verifies authenticity but does not prevent the same authentic event from being processed twice. Persist
|
|
55
|
+
Providers retry webhook delivery. `verifyWebhook()` verifies authenticity but does not prevent the same authentic event from being processed twice. Persist complete notifications by `(provider, deliveryId)` where available, or use genuine provider `eventId` values and a provider-specific fallback when absent. SES recipient events share one notification ID; do not discard siblings by deduplicating each with that ID alone. Use a unique constraint before applying side effects. See [durable inbox/outbox examples](reliability.md). Acknowledge only after durable acceptance by your application. Do not log secrets or full event payloads by default.
|
|
52
56
|
|
|
53
57
|
## Provider references
|
|
54
58
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { MailError, type Mailer, type SendMailInput, type SendMailResult } from 'typedmailer';
|
|
2
|
+
import type { EmailWebhookEvent } from 'typedmailer/webhooks';
|
|
3
|
+
|
|
4
|
+
/** Implement using a database client. Inbox/worker queries must commit before resolving; enqueue may use a business transaction. */
|
|
5
|
+
export interface SqlDatabase {
|
|
6
|
+
query(sql: string, values: readonly unknown[]): Promise<{ readonly rows: readonly Record<string, unknown>[] }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OutboxJob {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly input: SendMailInput;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OutboxFailure {
|
|
15
|
+
readonly state: 'failed' | 'unknown';
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly retryable: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OutboxStore {
|
|
21
|
+
claim(): Promise<OutboxJob | undefined>;
|
|
22
|
+
accept(id: string, result: SendMailResult<string>): Promise<void>;
|
|
23
|
+
fail(id: string, failure: OutboxFailure): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One committed row per authenticated notification, including every normalized event. */
|
|
27
|
+
export async function acceptWebhookDelivery(
|
|
28
|
+
database: SqlDatabase,
|
|
29
|
+
deliveryId: string,
|
|
30
|
+
events: readonly EmailWebhookEvent[],
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const provider = events[0]?.provider;
|
|
33
|
+
if (!deliveryId.trim() || !provider || events.some((event) => event.provider !== provider)) {
|
|
34
|
+
throw new Error('Expected one provider and an authenticated notification ID.');
|
|
35
|
+
}
|
|
36
|
+
await database.query(
|
|
37
|
+
`INSERT INTO mail_webhook_inbox (provider, delivery_id, events)
|
|
38
|
+
VALUES ($1, $2, $3::jsonb) ON CONFLICT (provider, delivery_id) DO NOTHING`,
|
|
39
|
+
[provider, deliveryId, JSON.stringify(events)],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validate input before calling; enqueue in the SAME transaction as the business change. */
|
|
44
|
+
export async function enqueueMail(database: SqlDatabase, id: string, input: SendMailInput): Promise<void> {
|
|
45
|
+
if (!id.trim()) throw new Error('Expected a stable business operation ID.');
|
|
46
|
+
await database.query(`INSERT INTO mail_outbox (id, input) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO NOTHING`, [
|
|
47
|
+
id,
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
...input,
|
|
50
|
+
...(input.attachments
|
|
51
|
+
? {
|
|
52
|
+
attachments: input.attachments.map((attachment) => ({
|
|
53
|
+
...attachment,
|
|
54
|
+
content:
|
|
55
|
+
typeof attachment.content === 'string'
|
|
56
|
+
? attachment.content
|
|
57
|
+
: { encoding: 'typedmailer-bytes', base64: Buffer.from(attachment.content).toString('base64') },
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Claim commits BEFORE network I/O. A crash leaves sending for reconciliation, never blind resend. */
|
|
66
|
+
export function createSqlOutboxStore(database: SqlDatabase): OutboxStore {
|
|
67
|
+
return {
|
|
68
|
+
async claim() {
|
|
69
|
+
const result = await database.query(
|
|
70
|
+
`WITH candidate AS (
|
|
71
|
+
SELECT id FROM mail_outbox WHERE state = 'queued'
|
|
72
|
+
ORDER BY created_at, id FOR UPDATE SKIP LOCKED LIMIT 1
|
|
73
|
+
) UPDATE mail_outbox SET state = 'sending', updated_at = now()
|
|
74
|
+
FROM candidate WHERE mail_outbox.id = candidate.id
|
|
75
|
+
RETURNING mail_outbox.id, mail_outbox.input`,
|
|
76
|
+
[],
|
|
77
|
+
);
|
|
78
|
+
const row = result.rows[0];
|
|
79
|
+
if (!row) return undefined;
|
|
80
|
+
if (typeof row.id !== 'string') throw new Error('Invalid outbox row ID.');
|
|
81
|
+
// Stored JSON comes from validated application input. The mailer validates again before sending.
|
|
82
|
+
const input = JSON.parse(JSON.stringify(row.input), (_key, value: unknown) => {
|
|
83
|
+
if (
|
|
84
|
+
typeof value === 'object' &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
'encoding' in value &&
|
|
87
|
+
value.encoding === 'typedmailer-bytes' &&
|
|
88
|
+
'base64' in value &&
|
|
89
|
+
typeof value.base64 === 'string'
|
|
90
|
+
) {
|
|
91
|
+
return new Uint8Array(Buffer.from(value.base64, 'base64'));
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}) as SendMailInput;
|
|
95
|
+
return { id: row.id, input };
|
|
96
|
+
},
|
|
97
|
+
async accept(id, result) {
|
|
98
|
+
const updated = await database.query(
|
|
99
|
+
`UPDATE mail_outbox SET state = $2, receipt = $3::jsonb, updated_at = now()
|
|
100
|
+
WHERE id = $1 AND state = 'sending' RETURNING id`,
|
|
101
|
+
[id, result.rejected?.length ? 'partial' : 'accepted', JSON.stringify(result)],
|
|
102
|
+
);
|
|
103
|
+
if (updated.rows[0]?.id !== id) throw new Error('Outbox receipt was not committed.');
|
|
104
|
+
},
|
|
105
|
+
async fail(id, failure) {
|
|
106
|
+
const updated = await database.query(
|
|
107
|
+
`UPDATE mail_outbox SET state = $2, failure = $3::jsonb, updated_at = now()
|
|
108
|
+
WHERE id = $1 AND state = 'sending' RETURNING id`,
|
|
109
|
+
[id, failure.state, JSON.stringify(failure)],
|
|
110
|
+
);
|
|
111
|
+
if (updated.rows[0]?.id !== id) throw new Error('Outbox failure was not committed.');
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** No automatic retry: the application reconciles unknown and partial delivery states. */
|
|
117
|
+
export async function processNextMail(mailer: Mailer<string>, store: OutboxStore): Promise<boolean> {
|
|
118
|
+
const job = await store.claim();
|
|
119
|
+
if (!job) return false;
|
|
120
|
+
let result: SendMailResult<string>;
|
|
121
|
+
try {
|
|
122
|
+
result = await mailer.send(job.input);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const failure: OutboxFailure =
|
|
125
|
+
error instanceof MailError
|
|
126
|
+
? { state: error.deliveryUnknown ? 'unknown' : 'failed', code: error.code, retryable: error.retryable }
|
|
127
|
+
: { state: 'unknown', code: 'unclassified', retryable: false };
|
|
128
|
+
await store.fail(job.id, failure);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
// Persistence failures propagate. Never reinterpret an accepted send as a send failure.
|
|
132
|
+
await store.accept(job.id, result);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
CREATE TABLE mail_webhook_inbox (
|
|
2
|
+
provider text NOT NULL,
|
|
3
|
+
delivery_id text NOT NULL,
|
|
4
|
+
events jsonb NOT NULL,
|
|
5
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
6
|
+
PRIMARY KEY (provider, delivery_id)
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE mail_outbox (
|
|
10
|
+
id text PRIMARY KEY,
|
|
11
|
+
input jsonb NOT NULL,
|
|
12
|
+
state text NOT NULL DEFAULT 'queued'
|
|
13
|
+
CHECK (state IN ('queued', 'sending', 'accepted', 'partial', 'failed', 'unknown')),
|
|
14
|
+
receipt jsonb,
|
|
15
|
+
failure jsonb,
|
|
16
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
17
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX mail_outbox_queued ON mail_outbox (created_at, id) WHERE state = 'queued';
|