typedmailer 1.3.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 CHANGED
@@ -39,8 +39,12 @@ 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
 
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.
47
+
44
48
  Supported runtime and release guarantees are documented in [`SUPPORT.md`](SUPPORT.md). Provider behavior guarantees and their test coverage are described in [`docs/provider-contracts.md`](docs/provider-contracts.md). The scheduled and manual provider smoke workflow is documented in [`docs/integration-testing.md`](docs/integration-testing.md).
45
49
 
46
50
  TypedMailer is for trusted server-side Node.js runtimes. It is not intended for browser or mobile client bundles.
@@ -132,7 +136,7 @@ const mailer = createMailer({
132
136
 
133
137
  ### Amazon SES
134
138
 
135
- 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.
136
140
 
137
141
  ```ts
138
142
  const mailer = createMailer({
@@ -160,10 +164,12 @@ For local development, start Mailpit with `docker run --rm -p 1025:1025 -p 8025:
160
164
 
161
165
  ## Message options
162
166
 
163
- `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.
164
168
 
165
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.
166
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
+
167
173
  Provider capabilities differ. Unsupported fields return a `MailError` with code `unsupported` instead of being silently ignored.
168
174
 
169
175
  | Provider | Custom `messageId` | `idempotencyKey` | Metadata | Inline attachments | `verifyConnection()` |
@@ -200,7 +206,7 @@ const result = await mailer.send({ to: 'person@example.com', subject: 'Hello', t
200
206
  await mailer.close();
201
207
  ```
202
208
 
203
- 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`.
204
210
 
205
211
  ```ts
206
212
  await mailer.send({
@@ -230,7 +236,7 @@ Messages captured by `createTestMailer` return `provider: 'test'` so test result
230
236
 
231
237
  ## Verify provider webhooks
232
238
 
233
- 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.
234
240
 
235
241
  ## Runnable examples
236
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 addressSchema = z.union([
3
- z.string().email(),
4
- z.object({ email: z.string().email(), name: z.string().optional() }).strict(),
5
- ]);
6
- const senderSchema = z.union([
7
- z.string().email(),
8
- z.string().regex(/^.+ <[^<>\s]+@[^<>\s]+>$/, 'Use a valid email address or "Name <email@example.com>".'),
9
- z.object({ email: z.string().email(), name: z.string().optional() }).strict(),
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, ProviderName } from './types.js';
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: BuiltInMailerOptions): Mailer<ProviderName | 'test'>;
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,
@@ -44,7 +44,7 @@ export function createBrevoProvider(options) {
44
44
  return { messageId: result.messageId };
45
45
  }
46
46
  catch (error) {
47
- throw normalizeProviderError(error, 'brevo');
47
+ throw normalizeProviderError(error, 'brevo', 'send');
48
48
  }
49
49
  },
50
50
  async verifyConnection() {
@@ -62,7 +62,7 @@ export function createMailgunProvider(options) {
62
62
  return { messageId: response.id };
63
63
  }
64
64
  catch (error) {
65
- throw normalizeProviderError(error, 'mailgun');
65
+ throw normalizeProviderError(error, 'mailgun', 'send');
66
66
  }
67
67
  },
68
68
  async verifyConnection() {
@@ -42,7 +42,7 @@ export function createPostmarkProvider(options) {
42
42
  return { messageId: response.MessageID };
43
43
  }
44
44
  catch (error) {
45
- throw normalizeProviderError(error, 'postmark');
45
+ throw normalizeProviderError(error, 'postmark', 'send');
46
46
  }
47
47
  },
48
48
  async verifyConnection() {
@@ -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.$strip>;
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.$strip>;
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.$strip>;
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.$strip>;
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.$strip>;
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.$strip>;
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.$strip>;
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;
@@ -77,12 +77,13 @@ export declare const providerOptions: {
77
77
  host: z.ZodString;
78
78
  port: z.ZodNumber;
79
79
  secure: z.ZodBoolean;
80
+ requireTLS: z.ZodOptional<z.ZodBoolean>;
80
81
  user: z.ZodOptional<z.ZodString>;
81
82
  password: z.ZodOptional<z.ZodString>;
82
83
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
83
84
  greetingTimeout: z.ZodDefault<z.ZodNumber>;
84
85
  socketTimeout: z.ZodDefault<z.ZodNumber>;
85
- }, z.core.$strip>;
86
+ }, z.core.$strict>;
86
87
  };
87
88
  export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
88
89
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
@@ -92,7 +93,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
92
93
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
93
94
  provider: z.ZodLiteral<"resend">;
94
95
  apiKey: z.ZodString;
95
- }, z.core.$strip>, z.ZodObject<{
96
+ }, z.core.$strict>, z.ZodObject<{
96
97
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
97
98
  email: z.ZodString;
98
99
  name: z.ZodOptional<z.ZodString>;
@@ -100,7 +101,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
100
101
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
101
102
  provider: z.ZodLiteral<"brevo">;
102
103
  apiKey: z.ZodString;
103
- }, z.core.$strip>, z.ZodObject<{
104
+ }, z.core.$strict>, z.ZodObject<{
104
105
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
105
106
  email: z.ZodString;
106
107
  name: z.ZodOptional<z.ZodString>;
@@ -108,7 +109,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
108
109
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
109
110
  provider: z.ZodLiteral<"postmark">;
110
111
  apiKey: z.ZodString;
111
- }, z.core.$strip>, z.ZodObject<{
112
+ }, z.core.$strict>, z.ZodObject<{
112
113
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
113
114
  email: z.ZodString;
114
115
  name: z.ZodOptional<z.ZodString>;
@@ -116,7 +117,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
116
117
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
117
118
  provider: z.ZodLiteral<"sendgrid">;
118
119
  apiKey: z.ZodString;
119
- }, z.core.$strip>, z.ZodObject<{
120
+ }, z.core.$strict>, z.ZodObject<{
120
121
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
121
122
  email: z.ZodString;
122
123
  name: z.ZodOptional<z.ZodString>;
@@ -129,7 +130,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
129
130
  us: "us";
130
131
  eu: "eu";
131
132
  }>>;
132
- }, z.core.$strip>, z.ZodObject<{
133
+ }, z.core.$strict>, z.ZodObject<{
133
134
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
134
135
  email: z.ZodString;
135
136
  name: z.ZodOptional<z.ZodString>;
@@ -137,7 +138,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
137
138
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
138
139
  provider: z.ZodLiteral<"ses">;
139
140
  region: z.ZodString;
140
- }, z.core.$strip>, z.ZodObject<{
141
+ }, z.core.$strict>, z.ZodObject<{
141
142
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
142
143
  email: z.ZodString;
143
144
  name: z.ZodOptional<z.ZodString>;
@@ -147,12 +148,13 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
147
148
  host: z.ZodString;
148
149
  port: z.ZodNumber;
149
150
  secure: z.ZodBoolean;
151
+ requireTLS: z.ZodOptional<z.ZodBoolean>;
150
152
  user: z.ZodOptional<z.ZodString>;
151
153
  password: z.ZodOptional<z.ZodString>;
152
154
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
153
155
  greetingTimeout: z.ZodDefault<z.ZodNumber>;
154
156
  socketTimeout: z.ZodDefault<z.ZodNumber>;
155
- }, z.core.$strip>], "provider">;
157
+ }, z.core.$strict>], "provider">;
156
158
  export type MailerOptions = z.input<typeof mailerOptionsSchema>;
157
159
  export type ParsedMailerOptions = z.output<typeof mailerOptionsSchema>;
158
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
- export const mailerBaseOptionsSchema = z.object({
4
- from: z.union([
5
- z.string().email(),
6
- z.string().regex(/^.+ <[^<>\s]+@[^<>\s]+>$/, 'Use a valid email address or "Name <email@example.com>".'),
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) }),
@@ -27,6 +26,7 @@ export const providerOptions = {
27
26
  host: z.string().min(1),
28
27
  port: z.number().int().min(1).max(65535),
29
28
  secure: z.boolean(),
29
+ requireTLS: z.boolean().optional(),
30
30
  user: z.string().min(1).optional(),
31
31
  password: z.string().min(1).optional(),
32
32
  connectionTimeout: z.number().int().positive().default(8_000),
@@ -80,6 +80,7 @@ export async function loadProvider(options) {
80
80
  host: options.host,
81
81
  port: options.port,
82
82
  secure: options.secure,
83
+ ...(options.requireTLS !== undefined ? { requireTLS: options.requireTLS } : {}),
83
84
  ...(options.user ? { user: options.user } : {}),
84
85
  ...(options.password ? { password: options.password } : {}),
85
86
  connectionTimeout: options.connectionTimeout,
@@ -28,7 +28,7 @@ export function createResendProvider(options) {
28
28
  ? {
29
29
  attachments: input.attachments.map((attachment) => ({
30
30
  filename: attachment.filename,
31
- content: typeof attachment.content === 'string' ? attachment.content : Buffer.from(attachment.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
  })),
@@ -50,7 +50,7 @@ export function createSendGridProvider(options) {
50
50
  return { messageId };
51
51
  }
52
52
  catch (error) {
53
- throw normalizeProviderError(error, 'sendgrid');
53
+ throw normalizeProviderError(error, 'sendgrid', 'send');
54
54
  }
55
55
  },
56
56
  async verifyConnection() {
@@ -55,7 +55,7 @@ export function createSesProvider(options) {
55
55
  return { messageId: response.MessageId };
56
56
  }
57
57
  catch (error) {
58
- throw normalizeProviderError(error, 'ses');
58
+ throw normalizeProviderError(error, 'ses', 'send');
59
59
  }
60
60
  },
61
61
  async verifyConnection() {
@@ -3,6 +3,7 @@ interface SmtpOptions {
3
3
  host: string;
4
4
  port: number;
5
5
  secure: boolean;
6
+ requireTLS?: boolean;
6
7
  user?: string;
7
8
  password?: string;
8
9
  connectionTimeout: number;
@@ -11,7 +11,7 @@ export function createSmtpProvider(options) {
11
11
  host: options.host,
12
12
  port: options.port,
13
13
  secure: options.secure,
14
- ...(options.port === 587 && !options.secure ? { requireTLS: true } : {}),
14
+ requireTLS: options.requireTLS ?? (options.port === 587 && !options.secure),
15
15
  connectionTimeout: options.connectionTimeout,
16
16
  greetingTimeout: options.greetingTimeout,
17
17
  socketTimeout: options.socketTimeout,
@@ -53,7 +53,7 @@ export function createSmtpProvider(options) {
53
53
  return { messageId: result.messageId };
54
54
  }
55
55
  catch (error) {
56
- throw normalizeProviderError(error, 'smtp');
56
+ throw normalizeProviderError(error, 'smtp', 'send');
57
57
  }
58
58
  },
59
59
  async verifyConnection() {
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 ?? options.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() };
@@ -1,6 +1,6 @@
1
1
  import type { EmailWebhookEvent } from './types.js';
2
2
  export declare function normalizeResend(payload: unknown): readonly EmailWebhookEvent[];
3
3
  export declare function normalizeMailgun(payload: unknown): readonly EmailWebhookEvent[];
4
- export declare function normalizeSendGrid(payload: unknown): readonly EmailWebhookEvent[];
5
- export declare function normalizeSes(payload: unknown): readonly EmailWebhookEvent[];
6
- export declare function normalizeProviderEvents(provider: 'brevo' | 'postmark', payload: unknown): readonly EmailWebhookEvent[];
4
+ export declare function normalizeSendGrid(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
5
+ export declare function normalizeSes(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
6
+ export declare function normalizeProviderEvents(provider: 'brevo' | 'postmark', payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
@@ -31,9 +31,10 @@ export function normalizeMailgun(payload) {
31
31
  }),
32
32
  ];
33
33
  }
34
- export function normalizeSendGrid(payload) {
35
- if (!Array.isArray(payload))
34
+ export function normalizeSendGrid(payload, maxEvents) {
35
+ if (!Array.isArray(payload) || payload.length > maxEvents) {
36
36
  throw new WebhookVerificationError('invalid_payload');
37
+ }
37
38
  return payload.map((item) => {
38
39
  const event = asRecord(item);
39
40
  const eventName = asString(event.event);
@@ -47,7 +48,7 @@ export function normalizeSendGrid(payload) {
47
48
  });
48
49
  });
49
50
  }
50
- export function normalizeSes(payload) {
51
+ export function normalizeSes(payload, maxEvents) {
51
52
  const message = asRecord(payload);
52
53
  const eventName = asString(message.eventType) ?? asString(message.notificationType);
53
54
  const mail = asRecord(message.mail);
@@ -59,6 +60,10 @@ export function normalizeSes(payload) {
59
60
  : Array.isArray(complaint?.complainedRecipients)
60
61
  ? complaint.complainedRecipients
61
62
  : [];
63
+ if (recipients.length > maxEvents ||
64
+ (!recipients.length && Array.isArray(mail.destination) && mail.destination.length > maxEvents)) {
65
+ throw new WebhookVerificationError('invalid_payload');
66
+ }
62
67
  const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
63
68
  const emails = to.length > 0 ? to : asStringArray(mail.destination);
64
69
  return emails.length > 0
@@ -80,8 +85,10 @@ export function normalizeSes(payload) {
80
85
  }),
81
86
  ];
82
87
  }
83
- export function normalizeProviderEvents(provider, payload) {
88
+ export function normalizeProviderEvents(provider, payload, maxEvents) {
84
89
  const items = Array.isArray(payload) ? payload : [payload];
90
+ if (items.length > maxEvents)
91
+ throw new WebhookVerificationError('invalid_payload');
85
92
  return items.map((item) => {
86
93
  const event = asRecord(item);
87
94
  const eventName = provider === 'brevo' ? asString(event.event) : asString(event.RecordType);
@@ -16,6 +16,7 @@ interface RawWebhookInput {
16
16
  readonly rawBody: string | Uint8Array;
17
17
  readonly headers: WebhookHeaders;
18
18
  readonly maxBodyBytes?: number;
19
+ readonly maxEvents?: number;
19
20
  readonly now?: Date;
20
21
  readonly toleranceSeconds?: number;
21
22
  }
package/dist/webhooks.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import { WebhookVerificationError } from './webhooks/types.js';
2
2
  import { verifyAuthorization, verifyMailgun, verifyResend, verifySendGrid, verifySnsNotification, } from './webhooks/signatures.js';
3
3
  import { normalizeMailgun, normalizeProviderEvents, normalizeResend, normalizeSendGrid, normalizeSes, } from './webhooks/normalize.js';
4
+ const DEFAULT_MAX_BODY_BYTES = 1_048_576;
5
+ const DEFAULT_MAX_EVENTS = 1_000;
4
6
  export { WebhookVerificationError } from './webhooks/types.js';
5
7
  /** Authenticates a provider webhook before returning normalized, typed email events. */
6
8
  export async function verifyWebhook(input) {
7
9
  const rawBodyByteLength = typeof input.rawBody === 'string' ? Buffer.byteLength(input.rawBody) : input.rawBody.byteLength;
8
- if (input.maxBodyBytes !== undefined &&
9
- (!Number.isSafeInteger(input.maxBodyBytes) || input.maxBodyBytes < 1 || rawBodyByteLength > input.maxBodyBytes)) {
10
+ const maxBodyBytes = input.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
11
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || rawBodyByteLength > maxBodyBytes) {
12
+ throw new WebhookVerificationError('invalid_payload');
13
+ }
14
+ const maxEvents = input.maxEvents ?? DEFAULT_MAX_EVENTS;
15
+ if (!Number.isSafeInteger(maxEvents) || maxEvents < 1) {
10
16
  throw new WebhookVerificationError('invalid_payload');
11
17
  }
12
18
  const rawBody = Buffer.from(input.rawBody);
@@ -26,12 +32,12 @@ export async function verifyWebhook(input) {
26
32
  return normalizeMailgun(payload);
27
33
  case 'sendgrid':
28
34
  verifySendGrid(input, rawBody);
29
- return normalizeSendGrid(payload);
35
+ return normalizeSendGrid(payload, maxEvents);
30
36
  case 'brevo':
31
37
  case 'postmark':
32
38
  verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
33
- return normalizeProviderEvents(input.provider, payload);
39
+ return normalizeProviderEvents(input.provider, payload, maxEvents);
34
40
  case 'ses':
35
- return normalizeSes(await verifySnsNotification(input, payload));
41
+ return normalizeSes(await verifySnsNotification(input, payload), maxEvents);
36
42
  }
37
43
  }
@@ -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 the provider gives no ID, `messageId` is an empty string.
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,9 +2,11 @@
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
- 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. You can also set `maxBodyBytes` on `verifyWebhook()` to reject an oversized payload before JSON parsing; it has no default because provider event batches vary in size.
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.
8
10
 
9
11
  ## Authentication configuration
10
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typedmailer",
3
- "version": "1.3.0",
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.0.0 <4",
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.0.0 <4",
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",