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 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 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,
@@ -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;
@@ -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.$strip>;
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.$strip>, z.ZodObject<{
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.$strip>, z.ZodObject<{
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.$strip>, z.ZodObject<{
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.$strip>, z.ZodObject<{
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.$strip>, z.ZodObject<{
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.$strip>, z.ZodObject<{
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.$strip>], "provider">;
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
- 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) }),
@@ -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
  })),
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() };
@@ -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,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": "1.4.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",