typedmailer 1.4.0 → 2.0.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 +8 -4
- package/dist/config.d.ts +4 -0
- package/dist/config.js +18 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -0
- package/dist/providers/registry.d.ts +15 -15
- package/dist/providers/registry.js +6 -7
- package/dist/providers/resend.js +1 -1
- package/dist/testing.js +3 -2
- package/docs/framework-webhooks.md +60 -0
- package/docs/migration-v2.md +35 -0
- package/docs/provider-contracts.md +8 -2
- package/docs/webhooks.md +2 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ npm install typedmailer mailgun.js form-data
|
|
|
39
39
|
npm install typedmailer @aws-sdk/client-sesv2
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
Upgrading from v1? Read the [v2 migration guide](docs/migration-v2.md), including the SES SDK minimum and attachment encoding changes.
|
|
43
|
+
|
|
42
44
|
Requires Node.js 22 or newer. Provider SDKs are optional peers and are loaded only when their adapter is selected.
|
|
43
45
|
|
|
44
46
|
For SMTP, use `secure: true` with implicit TLS (commonly port 465), or use STARTTLS with `secure: false` and `requireTLS: true`. Port 587 requires STARTTLS by default; other ports use opportunistic TLS unless `requireTLS` is set.
|
|
@@ -134,7 +136,7 @@ const mailer = createMailer({
|
|
|
134
136
|
|
|
135
137
|
### Amazon SES
|
|
136
138
|
|
|
137
|
-
SES requires a region and uses the AWS SDK credential provider chain, such as environment credentials, a shared profile, or an IAM role.
|
|
139
|
+
SES requires `@aws-sdk/client-sesv2 >=3.797.0 <4`, a region, and uses the AWS SDK credential provider chain, such as environment credentials, a shared profile, or an IAM role.
|
|
138
140
|
|
|
139
141
|
```ts
|
|
140
142
|
const mailer = createMailer({
|
|
@@ -162,10 +164,12 @@ For local development, start Mailpit with `docker run --rm -p 1025:1025 -p 8025:
|
|
|
162
164
|
|
|
163
165
|
## Message options
|
|
164
166
|
|
|
165
|
-
`to` accepts an email string, a `{ email, name }` object, or an array. Provide `text` or `html` (or both). Optional fields include `from`, `replyTo`, `cc`, `bcc`, `headers`, `attachments`, `metadata`, and `idempotencyKey`; `messageId` is SMTP-only. Attachments may include `contentId` for inline images with Resend, Postmark, SendGrid, Mailgun, Amazon SES, and SMTP. Attachment content accepts strings or `Uint8Array`; the provider adapters buffer it for SDK requests, so use an application-managed upload or streaming workflow for large files. Set `maxAttachmentBytes` on `createMailer()` to reject a message before loading or calling the provider when the combined UTF-8 and binary attachment content exceeds your application's memory budget. It is unset by default for backward compatibility.
|
|
167
|
+
`to` accepts an email string, a `{ email, name }` object, or an array. Provide `text` or `html` (or both). Optional fields include `from`, `replyTo`, `cc`, `bcc`, `headers`, `attachments`, `metadata`, and `idempotencyKey`; `messageId` is SMTP-only. Attachments may include `contentId` for inline images with Resend, Postmark, SendGrid, Mailgun, Amazon SES, and SMTP. Attachment content accepts UTF-8 text strings or raw bytes as `Uint8Array`; the provider adapters buffer it for SDK requests, so use an application-managed upload or streaming workflow for large files. Set `maxAttachmentBytes` on `createMailer()` to reject a message before loading or calling the provider when the combined UTF-8 and binary attachment content exceeds your application's memory budget. It is unset by default for backward compatibility.
|
|
166
168
|
|
|
167
169
|
Subjects and attachment filenames cannot be blank. Supplied content types and inline content IDs must be non-empty. The library does not impose a fixed attachment-size limit.
|
|
168
170
|
|
|
171
|
+
Built-in and custom mailer configuration reject unknown option keys. Named sender strings validate the enclosed email address; display names cannot contain line breaks. Message/configuration validation failures are Zod errors. Built-in provider names are preserved in result types: a Resend mailer returns `SendMailResult<'resend'>`.
|
|
172
|
+
|
|
169
173
|
Provider capabilities differ. Unsupported fields return a `MailError` with code `unsupported` instead of being silently ignored.
|
|
170
174
|
|
|
171
175
|
| Provider | Custom `messageId` | `idempotencyKey` | Metadata | Inline attachments | `verifyConnection()` |
|
|
@@ -202,7 +206,7 @@ const result = await mailer.send({ to: 'person@example.com', subject: 'Hello', t
|
|
|
202
206
|
await mailer.close();
|
|
203
207
|
```
|
|
204
208
|
|
|
205
|
-
The custom adapter owns provider SDK setup, field support, and error handling. Throwing an error still passes through TypedMailer error normalization. Connection verification is reported as `unsupported` when the adapter does not implement it.
|
|
209
|
+
The custom adapter owns provider SDK setup, field support, and error handling. Throwing an error still passes through TypedMailer error normalization. Connection verification is reported as `unsupported` when the adapter does not implement it. Blank or invalid custom message IDs reject with code `provider` and `deliveryUnknown: true`.
|
|
206
210
|
|
|
207
211
|
```ts
|
|
208
212
|
await mailer.send({
|
|
@@ -232,7 +236,7 @@ Messages captured by `createTestMailer` return `provider: 'test'` so test result
|
|
|
232
236
|
|
|
233
237
|
## Verify provider webhooks
|
|
234
238
|
|
|
235
|
-
Use `verifyWebhook` from `typedmailer/webhooks` to authenticate a raw request and receive normalized events. Your HTTP route remains responsible for preserving the raw body, responding to the provider, deduplicating events durably, and applying application changes. See [Webhook verification](docs/webhooks.md) for provider-specific configuration and security requirements.
|
|
239
|
+
Use `verifyWebhook` from `typedmailer/webhooks` to authenticate a raw request and receive normalized events. Your HTTP route remains responsible for preserving the raw body, responding to the provider, deduplicating events durably, and applying application changes. See [Webhook verification](docs/webhooks.md) for provider-specific configuration and security requirements, and the tested [Next.js and Express route examples](docs/framework-webhooks.md) for raw-body capture and durable event acceptance.
|
|
236
240
|
|
|
237
241
|
## Runnable examples
|
|
238
242
|
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { MailAddress } from './types.js';
|
|
3
|
+
export declare const senderSchema: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
4
|
+
email: z.ZodString;
|
|
5
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, z.core.$strict>]>;
|
|
3
7
|
export declare const mailInputSchema: z.ZodObject<{
|
|
4
8
|
from: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
5
9
|
email: z.ZodString;
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
const emailSchema = z.string().email();
|
|
3
|
+
const addressObjectSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
email: emailSchema,
|
|
6
|
+
name: z
|
|
7
|
+
.string()
|
|
8
|
+
.refine((value) => !/[\r\n]/.test(value), 'Display names cannot contain line breaks.')
|
|
9
|
+
.optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const addressSchema = z.union([emailSchema, addressObjectSchema]);
|
|
13
|
+
export const senderSchema = z.union([
|
|
14
|
+
emailSchema,
|
|
15
|
+
z.string().refine((value) => {
|
|
16
|
+
const match = /^([^<>\r\n]+) <([^<>\s]+)>$/.exec(value);
|
|
17
|
+
return match !== null && match[1].trim().length > 0 && emailSchema.safeParse(match[2]).success;
|
|
18
|
+
}, 'Use a valid email address or "Name <email@example.com>".'),
|
|
19
|
+
addressObjectSchema,
|
|
10
20
|
]);
|
|
11
21
|
export const mailInputSchema = z
|
|
12
22
|
.object({
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type MailerOptions as BuiltInMailerOptions } from './providers/registry.js';
|
|
2
|
-
import type { CustomMailerOptions, Mailer
|
|
2
|
+
import type { CustomMailerOptions, Mailer } from './types.js';
|
|
3
3
|
export type MailerOptions = BuiltInMailerOptions | CustomMailerOptions;
|
|
4
4
|
export declare function createMailer<const TProvider extends string>(input: CustomMailerOptions<TProvider>): Mailer<TProvider>;
|
|
5
|
-
export declare function createMailer(input:
|
|
5
|
+
export declare function createMailer<const TOptions extends BuiltInMailerOptions>(input: TOptions): Mailer<TOptions['provider']>;
|
|
6
6
|
export declare function createMailer(input: MailerOptions): Mailer<string>;
|
|
7
7
|
export { MailError } from './errors.js';
|
|
8
8
|
export type { MailErrorCode, MailErrorOperation, MailErrorOptions } from './errors.js';
|
package/dist/index.js
CHANGED
|
@@ -109,6 +109,11 @@ export function createMailer(input) {
|
|
|
109
109
|
};
|
|
110
110
|
try {
|
|
111
111
|
const result = await runWithProvider((provider) => provider.send(normalized));
|
|
112
|
+
if (typeof result?.messageId !== 'string' || result.messageId.trim().length === 0) {
|
|
113
|
+
throw new MailError('The provider returned no message identifier.', 'provider', providerName, false, {
|
|
114
|
+
deliveryUnknown: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
112
117
|
return {
|
|
113
118
|
provider: providerName,
|
|
114
119
|
messageId: result.messageId,
|
|
@@ -6,7 +6,7 @@ export declare const mailerBaseOptionsSchema: z.ZodObject<{
|
|
|
6
6
|
name: z.ZodOptional<z.ZodString>;
|
|
7
7
|
}, z.core.$strict>]>;
|
|
8
8
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
9
|
-
}, z.core.$
|
|
9
|
+
}, z.core.$strict>;
|
|
10
10
|
export declare const providerOptions: {
|
|
11
11
|
readonly resend: z.ZodObject<{
|
|
12
12
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
@@ -16,7 +16,7 @@ export declare const providerOptions: {
|
|
|
16
16
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
17
17
|
provider: z.ZodLiteral<"resend">;
|
|
18
18
|
apiKey: z.ZodString;
|
|
19
|
-
}, z.core.$
|
|
19
|
+
}, z.core.$strict>;
|
|
20
20
|
readonly brevo: z.ZodObject<{
|
|
21
21
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
22
22
|
email: z.ZodString;
|
|
@@ -25,7 +25,7 @@ export declare const providerOptions: {
|
|
|
25
25
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
26
26
|
provider: z.ZodLiteral<"brevo">;
|
|
27
27
|
apiKey: z.ZodString;
|
|
28
|
-
}, z.core.$
|
|
28
|
+
}, z.core.$strict>;
|
|
29
29
|
readonly postmark: z.ZodObject<{
|
|
30
30
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
31
31
|
email: z.ZodString;
|
|
@@ -34,7 +34,7 @@ export declare const providerOptions: {
|
|
|
34
34
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
35
35
|
provider: z.ZodLiteral<"postmark">;
|
|
36
36
|
apiKey: z.ZodString;
|
|
37
|
-
}, z.core.$
|
|
37
|
+
}, z.core.$strict>;
|
|
38
38
|
readonly sendgrid: z.ZodObject<{
|
|
39
39
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
40
40
|
email: z.ZodString;
|
|
@@ -43,7 +43,7 @@ export declare const providerOptions: {
|
|
|
43
43
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
44
44
|
provider: z.ZodLiteral<"sendgrid">;
|
|
45
45
|
apiKey: z.ZodString;
|
|
46
|
-
}, z.core.$
|
|
46
|
+
}, z.core.$strict>;
|
|
47
47
|
readonly mailgun: z.ZodObject<{
|
|
48
48
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
49
49
|
email: z.ZodString;
|
|
@@ -57,7 +57,7 @@ export declare const providerOptions: {
|
|
|
57
57
|
us: "us";
|
|
58
58
|
eu: "eu";
|
|
59
59
|
}>>;
|
|
60
|
-
}, z.core.$
|
|
60
|
+
}, z.core.$strict>;
|
|
61
61
|
readonly ses: z.ZodObject<{
|
|
62
62
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
63
63
|
email: z.ZodString;
|
|
@@ -66,7 +66,7 @@ export declare const providerOptions: {
|
|
|
66
66
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
67
67
|
provider: z.ZodLiteral<"ses">;
|
|
68
68
|
region: z.ZodString;
|
|
69
|
-
}, z.core.$
|
|
69
|
+
}, z.core.$strict>;
|
|
70
70
|
readonly smtp: z.ZodObject<{
|
|
71
71
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
72
72
|
email: z.ZodString;
|
|
@@ -83,7 +83,7 @@ export declare const providerOptions: {
|
|
|
83
83
|
connectionTimeout: z.ZodDefault<z.ZodNumber>;
|
|
84
84
|
greetingTimeout: z.ZodDefault<z.ZodNumber>;
|
|
85
85
|
socketTimeout: z.ZodDefault<z.ZodNumber>;
|
|
86
|
-
}, z.core.$
|
|
86
|
+
}, z.core.$strict>;
|
|
87
87
|
};
|
|
88
88
|
export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
89
89
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
@@ -93,7 +93,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
93
93
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
94
94
|
provider: z.ZodLiteral<"resend">;
|
|
95
95
|
apiKey: z.ZodString;
|
|
96
|
-
}, z.core.$
|
|
96
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
97
97
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
98
98
|
email: z.ZodString;
|
|
99
99
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -101,7 +101,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
101
101
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
102
102
|
provider: z.ZodLiteral<"brevo">;
|
|
103
103
|
apiKey: z.ZodString;
|
|
104
|
-
}, z.core.$
|
|
104
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
105
105
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
106
106
|
email: z.ZodString;
|
|
107
107
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -109,7 +109,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
109
109
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
110
110
|
provider: z.ZodLiteral<"postmark">;
|
|
111
111
|
apiKey: z.ZodString;
|
|
112
|
-
}, z.core.$
|
|
112
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
113
113
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
114
114
|
email: z.ZodString;
|
|
115
115
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -117,7 +117,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
117
117
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
118
118
|
provider: z.ZodLiteral<"sendgrid">;
|
|
119
119
|
apiKey: z.ZodString;
|
|
120
|
-
}, z.core.$
|
|
120
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
121
121
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
122
122
|
email: z.ZodString;
|
|
123
123
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -130,7 +130,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
130
130
|
us: "us";
|
|
131
131
|
eu: "eu";
|
|
132
132
|
}>>;
|
|
133
|
-
}, z.core.$
|
|
133
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
134
134
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
135
135
|
email: z.ZodString;
|
|
136
136
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -138,7 +138,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
138
138
|
maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
|
|
139
139
|
provider: z.ZodLiteral<"ses">;
|
|
140
140
|
region: z.ZodString;
|
|
141
|
-
}, z.core.$
|
|
141
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
142
142
|
from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
|
|
143
143
|
email: z.ZodString;
|
|
144
144
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -154,7 +154,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
154
154
|
connectionTimeout: z.ZodDefault<z.ZodNumber>;
|
|
155
155
|
greetingTimeout: z.ZodDefault<z.ZodNumber>;
|
|
156
156
|
socketTimeout: z.ZodDefault<z.ZodNumber>;
|
|
157
|
-
}, z.core.$
|
|
157
|
+
}, z.core.$strict>], "provider">;
|
|
158
158
|
export type MailerOptions = z.input<typeof mailerOptionsSchema>;
|
|
159
159
|
export type ParsedMailerOptions = z.output<typeof mailerOptionsSchema>;
|
|
160
160
|
export declare function loadProvider(options: ParsedMailerOptions): Promise<MailProvider>;
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { MailError } from '../errors.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
z.object({ email: z.string().email(), name: z.string().optional() }).strict(),
|
|
8
|
-
]),
|
|
3
|
+
import { senderSchema } from '../config.js';
|
|
4
|
+
export const mailerBaseOptionsSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
from: senderSchema,
|
|
9
7
|
/** Optional aggregate in-memory attachment cap. No default preserves existing behavior. */
|
|
10
8
|
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
11
|
-
})
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
12
11
|
export const providerOptions = {
|
|
13
12
|
resend: mailerBaseOptionsSchema.extend({ provider: z.literal('resend'), apiKey: z.string().min(1) }),
|
|
14
13
|
brevo: mailerBaseOptionsSchema.extend({ provider: z.literal('brevo'), apiKey: z.string().min(1) }),
|
package/dist/providers/resend.js
CHANGED
|
@@ -28,7 +28,7 @@ export function createResendProvider(options) {
|
|
|
28
28
|
? {
|
|
29
29
|
attachments: input.attachments.map((attachment) => ({
|
|
30
30
|
filename: attachment.filename,
|
|
31
|
-
content:
|
|
31
|
+
content: Buffer.from(attachment.content).toString('base64'),
|
|
32
32
|
...(attachment.contentType ? { contentType: attachment.contentType } : {}),
|
|
33
33
|
...(attachment.contentId ? { contentId: attachment.contentId } : {}),
|
|
34
34
|
})),
|
package/dist/testing.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MailError } from './errors.js';
|
|
2
|
-
import { mailInputSchema } from './config.js';
|
|
2
|
+
import { mailInputSchema, senderSchema } from './config.js';
|
|
3
3
|
/** In-memory mailer for application tests and local flows. It never sends network requests. */
|
|
4
4
|
export function createTestMailer(options) {
|
|
5
|
+
const from = senderSchema.parse(options.from);
|
|
5
6
|
const sent = [];
|
|
6
7
|
let closed = false;
|
|
7
8
|
const assertOpen = () => {
|
|
@@ -15,7 +16,7 @@ export function createTestMailer(options) {
|
|
|
15
16
|
},
|
|
16
17
|
async send(input) {
|
|
17
18
|
assertOpen();
|
|
18
|
-
const message = { ...input, from: input.from ??
|
|
19
|
+
const message = { ...input, from: input.from ?? from };
|
|
19
20
|
mailInputSchema.parse(message);
|
|
20
21
|
sent.push(message);
|
|
21
22
|
return { provider: 'test', messageId: `test-${sent.length}`, acceptedAt: new Date() };
|
|
@@ -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,7 @@ 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
10
|
- 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
11
|
- Unsupported fields or operations fail with `MailError` code `unsupported`; adapters must not silently discard caller data.
|
|
12
12
|
- `close()` is safe to call repeatedly, stops new work, waits for in-flight operations, and closes the underlying transport at most once.
|
|
@@ -23,7 +23,13 @@ The adapters provide one `createMailer(...).send(...)` interface, while provider
|
|
|
23
23
|
| Amazon SES | No | No | Yes | Yes | Unsupported |
|
|
24
24
|
| SMTP | Yes | No | No | Yes | SMTP connection check |
|
|
25
25
|
|
|
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.
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
## SDK serialization compatibility
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
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
33
|
|
|
28
34
|
## Updating a provider adapter
|
|
29
35
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typedmailer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A type-safe Node.js email library with one API for Resend, Brevo, Postmark, SendGrid, Mailgun, Amazon SES, or SMTP.",
|
|
5
5
|
"author": "Erol Senol",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"docs/integration-testing.md",
|
|
37
37
|
"docs/quickstart.md",
|
|
38
38
|
"docs/webhooks.md",
|
|
39
|
-
"CODE_OF_CONDUCT.md"
|
|
39
|
+
"CODE_OF_CONDUCT.md",
|
|
40
|
+
"docs/migration-v2.md",
|
|
41
|
+
"docs/framework-webhooks.md"
|
|
40
42
|
],
|
|
41
43
|
"scripts": {
|
|
42
44
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
"zod": "^4.6.5"
|
|
68
70
|
},
|
|
69
71
|
"peerDependencies": {
|
|
70
|
-
"@aws-sdk/client-sesv2": ">=3.
|
|
72
|
+
"@aws-sdk/client-sesv2": ">=3.797.0 <4",
|
|
71
73
|
"@getbrevo/brevo": ">=6.0.1 <7",
|
|
72
74
|
"@sendgrid/mail": ">=8.0.0 <9",
|
|
73
75
|
"form-data": ">=4.0.0 <5",
|
|
@@ -103,12 +105,14 @@
|
|
|
103
105
|
}
|
|
104
106
|
},
|
|
105
107
|
"devDependencies": {
|
|
106
|
-
"@aws-sdk/client-sesv2": ">=3.
|
|
108
|
+
"@aws-sdk/client-sesv2": ">=3.797.0 <4",
|
|
107
109
|
"@getbrevo/brevo": ">=6.0.1 <7",
|
|
108
110
|
"@sendgrid/mail": ">=8.0.0 <9",
|
|
111
|
+
"@types/express": "^5.0.6",
|
|
109
112
|
"@types/node": "^26.6.3",
|
|
110
113
|
"@types/nodemailer": "^8.0.2",
|
|
111
114
|
"eslint": "^10.11.0",
|
|
115
|
+
"express": "^5.2.1",
|
|
112
116
|
"form-data": ">=4.0.0 <5",
|
|
113
117
|
"husky": "^9.1.7",
|
|
114
118
|
"lint-staged": "^17.6.0",
|