typedmailer 2.0.0 → 2.1.1

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
@@ -232,7 +232,7 @@ await mailer.send({ to: 'person@example.test', subject: 'Hello', text: 'Hi' });
232
232
  console.log(mailer.sent[0]);
233
233
  ```
234
234
 
235
- Messages captured by `createTestMailer` return `provider: 'test'` so test results are not mistaken for SMTP deliveries.
235
+ Captures are independent copies of addresses, headers, metadata, and attachment bytes. `clear()` removes captures without reusing message IDs. Messages captured by `createTestMailer` return `provider: 'test'` so test results are not mistaken for SMTP deliveries.
236
236
 
237
237
  ## Verify provider webhooks
238
238
 
@@ -251,7 +251,7 @@ For Amazon SES, run `npm install typedmailer @aws-sdk/client-sesv2 && node --env
251
251
 
252
252
  ## Errors and delivery
253
253
 
254
- Provider and transport failures are normalized as `MailError`, with `code`, `provider`, `retryable`, `deliveryUnknown`, and the original error in `cause`. Codes mean:
254
+ Provider and transport failures are normalized as `MailError`, with `code`, `provider`, `retryable`, `deliveryUnknown`, optional response `status` and `retryAfterSeconds`, and the original error in `cause`. Codes mean:
255
255
 
256
256
  | Code | Meaning |
257
257
  | ---------------- | --------------------------------------------------------------------- |
@@ -264,6 +264,8 @@ Provider and transport failures are normalized as `MailError`, with `code`, `pro
264
264
 
265
265
  `retryable` is guidance from the normalized failure: recognized network failures and rate limits are retryable; API provider 5xx failures are retryable; authentication, configuration, unsupported, and SMTP 5xx failures are not. `deliveryUnknown` is separate: it is true when a send timeout/socket interruption, a provider 5xx, or an accepted response without a message ID means the provider may have accepted the message without returning a clear result. It stays false for verification failures, DNS lookup failures, authentication errors, rate limits, and SMTP response errors. This does not guarantee that retrying is safe. Use provider-supported idempotency where available and apply retry policy in your application. `cause` retains the original SDK error for diagnostics and can contain provider details; avoid logging it without reviewing your data handling policy.
266
266
 
267
+ SMTP results also expose optional `accepted` and `rejected` envelope recipients. Partial rejection resolves with both lists; do not resend the whole recipient list. Use the [reliability guide](docs/reliability.md) for isolated `onSend` metrics hooks and tested durable inbox/outbox examples.
268
+
267
269
  A successful `send()` means the provider accepted the request; it does not confirm inbox delivery. Verify delivery, bounce, and complaint callbacks with [`typedmailer/webhooks`](docs/webhooks.md).
268
270
 
269
271
  `verifyConnection()` currently supports SMTP. API provider adapters report `unsupported` because they do not expose a side-effect-free credential check through this API; verify credentials with a controlled provider test message.
package/dist/config.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { z } from 'zod';
2
+ const headerValueSchema = z
3
+ .string()
4
+ .refine((value) => !/[\r\n\0]/.test(value), 'Header values cannot contain line breaks or NUL.');
5
+ const headerNameSchema = z.string().regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/, 'Use a valid header field name.');
2
6
  const emailSchema = z.string().email();
3
7
  const addressObjectSchema = z
4
8
  .object({
@@ -25,23 +29,25 @@ export const mailInputSchema = z
25
29
  subject: z
26
30
  .string()
27
31
  .min(1)
28
- .refine((value) => value.trim().length > 0, 'Subject cannot be blank.'),
29
- messageId: z.string().min(1).optional(),
32
+ .refine((value) => value.trim().length > 0, 'Subject cannot be blank.')
33
+ .refine((value) => !/[\r\n\0]/.test(value), 'Subject cannot contain line breaks or NUL.'),
34
+ messageId: headerValueSchema.min(1).optional(),
30
35
  text: z.string().optional(),
31
36
  html: z.string().optional(),
32
37
  replyTo: addressSchema.optional(),
33
38
  cc: z.union([addressSchema, z.array(addressSchema)]).optional(),
34
39
  bcc: z.union([addressSchema, z.array(addressSchema)]).optional(),
35
- headers: z.record(z.string(), z.string()).optional(),
40
+ headers: z.record(headerNameSchema, headerValueSchema).optional(),
36
41
  attachments: z
37
42
  .array(z.object({
38
43
  filename: z
39
44
  .string()
40
45
  .min(1)
41
- .refine((value) => value.trim().length > 0, 'Attachment filename cannot be blank.'),
46
+ .refine((value) => value.trim().length > 0, 'Attachment filename cannot be blank.')
47
+ .refine((value) => !/[\r\n\0]/.test(value), 'Attachment filename cannot contain line breaks or NUL.'),
42
48
  content: z.union([z.string(), z.instanceof(Uint8Array)]),
43
- contentType: z.string().min(1).optional(),
44
- contentId: z.string().min(1).optional(),
49
+ contentType: headerValueSchema.min(1).optional(),
50
+ contentId: headerValueSchema.min(1).optional(),
45
51
  }))
46
52
  .optional(),
47
53
  idempotencyKey: z.string().min(1).optional(),
package/dist/errors.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  export type MailErrorCode = 'configuration' | 'authentication' | 'rate_limit' | 'network' | 'provider' | 'unsupported';
2
2
  export interface MailErrorOptions extends ErrorOptions {
3
3
  readonly deliveryUnknown?: boolean;
4
+ /** HTTP or SMTP response status, when supplied by the SDK. */
5
+ readonly status?: number;
6
+ /** Provider retry delay in seconds. Does not authorize automatic retry. */
7
+ readonly retryAfterSeconds?: number;
4
8
  }
5
9
  export declare class MailError extends Error {
6
10
  readonly code: MailErrorCode;
7
11
  readonly provider: string;
8
12
  readonly retryable: boolean;
9
13
  readonly deliveryUnknown: boolean;
14
+ readonly status: number | undefined;
15
+ readonly retryAfterSeconds: number | undefined;
10
16
  constructor(message: string, code: MailErrorCode, provider: string, retryable: boolean, options?: MailErrorOptions);
11
17
  }
12
18
  export type MailErrorOperation = 'send' | 'verify';
package/dist/errors.js CHANGED
@@ -3,6 +3,8 @@ export class MailError extends Error {
3
3
  provider;
4
4
  retryable;
5
5
  deliveryUnknown;
6
+ status;
7
+ retryAfterSeconds;
6
8
  constructor(message, code, provider, retryable, options) {
7
9
  super(message, options);
8
10
  this.code = code;
@@ -10,6 +12,8 @@ export class MailError extends Error {
10
12
  this.retryable = retryable;
11
13
  this.name = 'MailError';
12
14
  this.deliveryUnknown = options?.deliveryUnknown ?? false;
15
+ this.status = options?.status;
16
+ this.retryAfterSeconds = options?.retryAfterSeconds;
13
17
  }
14
18
  }
15
19
  const networkErrorCodes = new Set([
@@ -25,19 +29,8 @@ const networkErrorCodes = new Set([
25
29
  ]);
26
30
  const ambiguousNetworkErrorCodes = new Set(['ETIMEDOUT', 'ECONNECTION', 'ECONNRESET', 'EPIPE', 'ESOCKET']);
27
31
  function mayHaveBeenAccepted(error, provider) {
28
- const candidate = error;
29
- const status = typeof candidate?.statusCode === 'number'
30
- ? candidate.statusCode
31
- : typeof candidate?.status === 'number'
32
- ? candidate.status
33
- : typeof candidate?.code === 'number'
34
- ? candidate.code
35
- : typeof error?.responseCode === 'number'
36
- ? error.responseCode
37
- : typeof error?.$metadata?.httpStatusCode ===
38
- 'number'
39
- ? error.$metadata.httpStatusCode
40
- : undefined;
32
+ const candidate = record(error);
33
+ const status = responseStatus(error);
41
34
  const code = typeof candidate?.code === 'string' ? candidate.code : candidate?.name;
42
35
  if (provider === 'smtp' && status !== undefined)
43
36
  return false;
@@ -56,21 +49,12 @@ export function normalizeProviderError(error, provider, operation = 'verify') {
56
49
  return new MailError(error.message, error.code, error.provider, error.retryable, {
57
50
  ...(error.cause !== undefined ? { cause: error.cause } : {}),
58
51
  deliveryUnknown,
52
+ ...(error.status !== undefined ? { status: error.status } : {}),
53
+ ...(error.retryAfterSeconds !== undefined ? { retryAfterSeconds: error.retryAfterSeconds } : {}),
59
54
  });
60
55
  }
61
- const candidate = error;
62
- const status = typeof candidate?.statusCode === 'number'
63
- ? candidate.statusCode
64
- : typeof candidate?.status === 'number'
65
- ? candidate.status
66
- : typeof candidate?.code === 'number'
67
- ? candidate.code
68
- : typeof error?.responseCode === 'number'
69
- ? error.responseCode
70
- : typeof error?.$metadata?.httpStatusCode ===
71
- 'number'
72
- ? error.$metadata.httpStatusCode
73
- : undefined;
56
+ const candidate = record(error);
57
+ const status = responseStatus(error);
74
58
  const code = typeof candidate?.code === 'string'
75
59
  ? candidate.code
76
60
  : typeof candidate?.name === 'string'
@@ -102,8 +86,40 @@ export function normalizeProviderError(error, provider, operation = 'verify') {
102
86
  const retryable = errorCode === 'network' ||
103
87
  errorCode === 'rate_limit' ||
104
88
  (provider !== 'smtp' && status !== undefined && status >= 500 && status < 600);
89
+ const retryAfterSeconds = retryAfter(error);
105
90
  return new MailError(safeMessage, errorCode, provider, retryable, {
106
91
  cause: error,
92
+ ...(status !== undefined ? { status } : {}),
93
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
107
94
  deliveryUnknown: operation === 'send' && mayHaveBeenAccepted(error, provider),
108
95
  });
109
96
  }
97
+ function record(value) {
98
+ return typeof value === 'object' && value !== null ? value : {};
99
+ }
100
+ function responseStatus(error) {
101
+ const candidate = record(error);
102
+ const metadata = record(candidate.$metadata);
103
+ const response = record(candidate.response);
104
+ return [
105
+ candidate.statusCode,
106
+ candidate.status,
107
+ candidate.code,
108
+ candidate.responseCode,
109
+ metadata.httpStatusCode,
110
+ response.status,
111
+ ].find((value) => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599);
112
+ }
113
+ function retryAfter(error) {
114
+ const candidate = record(error);
115
+ const response = record(candidate.response);
116
+ const headers = response.headers ?? candidate.headers;
117
+ const value = headers instanceof Headers
118
+ ? headers.get('retry-after')
119
+ : Object.entries(record(headers)).find(([key]) => key.toLowerCase() === 'retry-after')?.[1];
120
+ const text = Array.isArray(value) ? value[0] : value;
121
+ if (typeof text !== 'string' && typeof text !== 'number')
122
+ return undefined;
123
+ const seconds = typeof text === 'number' || /^\d+(?:\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - Date.now()) / 1_000;
124
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds) : undefined;
125
+ }
package/dist/index.d.ts CHANGED
@@ -6,4 +6,4 @@ export declare function createMailer<const TOptions extends BuiltInMailerOptions
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';
9
- export type { CustomMailerOptions, MailAddress, MailAttachment, Mailer, NormalizedMailInput, ProviderAdapter, ProviderName, ProviderSendResult, SendMailInput, SendMailResult, } from './types.js';
9
+ export type { CustomMailerOptions, MailAddress, MailAttachment, Mailer, MailSendEvent, MailSendObserver, NormalizedMailInput, ProviderAdapter, ProviderName, ProviderSendResult, SendMailInput, SendMailResult, } from './types.js';
package/dist/index.js CHANGED
@@ -44,6 +44,15 @@ export function createMailer(input) {
44
44
  ? customMailerOptionsSchema.parse(input)
45
45
  : mailerOptionsSchema.parse(input);
46
46
  const providerName = typeof parsedOptions.provider === 'string' ? parsedOptions.provider : parsedOptions.provider.name;
47
+ const notify = (event) => {
48
+ try {
49
+ // Telemetry must never alter delivery or produce an unhandled rejection.
50
+ void Promise.resolve(parsedOptions.onSend?.(event)).catch(() => undefined);
51
+ }
52
+ catch {
53
+ // A synchronous observer failure is isolated as well.
54
+ }
55
+ };
47
56
  const assertOpen = () => {
48
57
  if (closed)
49
58
  throw new MailError('Mailer has been closed.', 'configuration', providerName, false);
@@ -107,6 +116,8 @@ export function createMailer(input) {
107
116
  ...(bcc ? { bcc } : {}),
108
117
  ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
109
118
  };
119
+ const startedAt = performance.now();
120
+ notify({ type: 'started', provider: providerName });
110
121
  try {
111
122
  const result = await runWithProvider((provider) => provider.send(normalized));
112
123
  if (typeof result?.messageId !== 'string' || result.messageId.trim().length === 0) {
@@ -114,14 +125,34 @@ export function createMailer(input) {
114
125
  deliveryUnknown: true,
115
126
  });
116
127
  }
117
- return {
128
+ for (const recipients of [result.accepted, result.rejected]) {
129
+ if (recipients !== undefined &&
130
+ (!Array.isArray(recipients) ||
131
+ recipients.some((recipient) => typeof recipient !== 'string' || !recipient.trim()))) {
132
+ throw new MailError('The provider returned an invalid recipient receipt.', 'provider', providerName, false, { deliveryUnknown: true });
133
+ }
134
+ }
135
+ const receipt = {
136
+ ...(result.accepted ? { accepted: [...result.accepted] } : {}),
137
+ ...(result.rejected ? { rejected: [...result.rejected] } : {}),
118
138
  provider: providerName,
119
139
  messageId: result.messageId,
120
140
  acceptedAt: new Date(),
121
141
  };
142
+ notify({ type: 'succeeded', provider: providerName, durationMs: performance.now() - startedAt });
143
+ return receipt;
122
144
  }
123
145
  catch (error) {
124
- throw normalizeProviderError(error, providerName, 'send');
146
+ const normalizedError = normalizeProviderError(error, providerName, 'send');
147
+ notify({
148
+ type: 'failed',
149
+ provider: providerName,
150
+ durationMs: performance.now() - startedAt,
151
+ code: normalizedError.code,
152
+ retryable: normalizedError.retryable,
153
+ deliveryUnknown: normalizedError.deliveryUnknown,
154
+ });
155
+ throw normalizedError;
125
156
  }
126
157
  },
127
158
  async verifyConnection() {
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
- import type { MailProvider } from '../types.js';
2
+ import type { MailProvider, MailSendObserver } from '../types.js';
3
3
  export declare const mailerBaseOptionsSchema: z.ZodObject<{
4
4
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
5
5
  email: z.ZodString;
6
6
  name: z.ZodOptional<z.ZodString>;
7
7
  }, z.core.$strict>]>;
8
8
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
9
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
9
10
  }, z.core.$strict>;
10
11
  export declare const providerOptions: {
11
12
  readonly resend: z.ZodObject<{
@@ -14,6 +15,7 @@ export declare const providerOptions: {
14
15
  name: z.ZodOptional<z.ZodString>;
15
16
  }, z.core.$strict>]>;
16
17
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
18
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
17
19
  provider: z.ZodLiteral<"resend">;
18
20
  apiKey: z.ZodString;
19
21
  }, z.core.$strict>;
@@ -23,6 +25,7 @@ export declare const providerOptions: {
23
25
  name: z.ZodOptional<z.ZodString>;
24
26
  }, z.core.$strict>]>;
25
27
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
28
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
26
29
  provider: z.ZodLiteral<"brevo">;
27
30
  apiKey: z.ZodString;
28
31
  }, z.core.$strict>;
@@ -32,6 +35,7 @@ export declare const providerOptions: {
32
35
  name: z.ZodOptional<z.ZodString>;
33
36
  }, z.core.$strict>]>;
34
37
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
38
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
35
39
  provider: z.ZodLiteral<"postmark">;
36
40
  apiKey: z.ZodString;
37
41
  }, z.core.$strict>;
@@ -41,6 +45,7 @@ export declare const providerOptions: {
41
45
  name: z.ZodOptional<z.ZodString>;
42
46
  }, z.core.$strict>]>;
43
47
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
48
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
44
49
  provider: z.ZodLiteral<"sendgrid">;
45
50
  apiKey: z.ZodString;
46
51
  }, z.core.$strict>;
@@ -50,6 +55,7 @@ export declare const providerOptions: {
50
55
  name: z.ZodOptional<z.ZodString>;
51
56
  }, z.core.$strict>]>;
52
57
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
58
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
53
59
  provider: z.ZodLiteral<"mailgun">;
54
60
  apiKey: z.ZodString;
55
61
  domain: z.ZodString;
@@ -64,6 +70,7 @@ export declare const providerOptions: {
64
70
  name: z.ZodOptional<z.ZodString>;
65
71
  }, z.core.$strict>]>;
66
72
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
73
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
67
74
  provider: z.ZodLiteral<"ses">;
68
75
  region: z.ZodString;
69
76
  }, z.core.$strict>;
@@ -73,6 +80,7 @@ export declare const providerOptions: {
73
80
  name: z.ZodOptional<z.ZodString>;
74
81
  }, z.core.$strict>]>;
75
82
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
83
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
76
84
  provider: z.ZodLiteral<"smtp">;
77
85
  host: z.ZodString;
78
86
  port: z.ZodNumber;
@@ -91,6 +99,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
91
99
  name: z.ZodOptional<z.ZodString>;
92
100
  }, z.core.$strict>]>;
93
101
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
102
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
94
103
  provider: z.ZodLiteral<"resend">;
95
104
  apiKey: z.ZodString;
96
105
  }, z.core.$strict>, z.ZodObject<{
@@ -99,6 +108,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
99
108
  name: z.ZodOptional<z.ZodString>;
100
109
  }, z.core.$strict>]>;
101
110
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
111
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
102
112
  provider: z.ZodLiteral<"brevo">;
103
113
  apiKey: z.ZodString;
104
114
  }, z.core.$strict>, z.ZodObject<{
@@ -107,6 +117,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
107
117
  name: z.ZodOptional<z.ZodString>;
108
118
  }, z.core.$strict>]>;
109
119
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
120
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
110
121
  provider: z.ZodLiteral<"postmark">;
111
122
  apiKey: z.ZodString;
112
123
  }, z.core.$strict>, z.ZodObject<{
@@ -115,6 +126,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
115
126
  name: z.ZodOptional<z.ZodString>;
116
127
  }, z.core.$strict>]>;
117
128
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
129
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
118
130
  provider: z.ZodLiteral<"sendgrid">;
119
131
  apiKey: z.ZodString;
120
132
  }, z.core.$strict>, z.ZodObject<{
@@ -123,6 +135,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
123
135
  name: z.ZodOptional<z.ZodString>;
124
136
  }, z.core.$strict>]>;
125
137
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
138
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
126
139
  provider: z.ZodLiteral<"mailgun">;
127
140
  apiKey: z.ZodString;
128
141
  domain: z.ZodString;
@@ -136,6 +149,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
136
149
  name: z.ZodOptional<z.ZodString>;
137
150
  }, z.core.$strict>]>;
138
151
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
152
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
139
153
  provider: z.ZodLiteral<"ses">;
140
154
  region: z.ZodString;
141
155
  }, z.core.$strict>, z.ZodObject<{
@@ -144,6 +158,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
144
158
  name: z.ZodOptional<z.ZodString>;
145
159
  }, z.core.$strict>]>;
146
160
  maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
161
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
147
162
  provider: z.ZodLiteral<"smtp">;
148
163
  host: z.ZodString;
149
164
  port: z.ZodNumber;
@@ -6,6 +6,9 @@ export const mailerBaseOptionsSchema = z
6
6
  from: senderSchema,
7
7
  /** Optional aggregate in-memory attachment cap. No default preserves existing behavior. */
8
8
  maxAttachmentBytes: z.number().int().positive().optional(),
9
+ onSend: z
10
+ .custom((value) => typeof value === 'function', 'Expected an observer function.')
11
+ .optional(),
9
12
  })
10
13
  .strict();
11
14
  export const providerOptions = {
@@ -35,7 +35,7 @@ export function createSendGridProvider(options) {
35
35
  filename: attachment.filename,
36
36
  content: Buffer.from(attachment.content).toString('base64'),
37
37
  ...(attachment.contentType ? { type: attachment.contentType } : {}),
38
- ...(attachment.contentId ? { contentId: attachment.contentId, disposition: 'inline' } : {}),
38
+ ...(attachment.contentId ? { content_id: attachment.contentId, disposition: 'inline' } : {}),
39
39
  })),
40
40
  }
41
41
  : {}),
@@ -6,6 +6,9 @@ function toNodemailerAddress(address) {
6
6
  return address;
7
7
  return { address: address.email, ...(address.name !== undefined ? { name: address.name } : {}) };
8
8
  }
9
+ function envelopeEmail(address) {
10
+ return typeof address === 'string' ? address : address.address;
11
+ }
9
12
  export function createSmtpProvider(options) {
10
13
  const transport = nodemailer.createTransport({
11
14
  host: options.host,
@@ -50,7 +53,11 @@ export function createSmtpProvider(options) {
50
53
  deliveryUnknown: true,
51
54
  });
52
55
  }
53
- return { messageId: result.messageId };
56
+ return {
57
+ messageId: result.messageId,
58
+ ...(Array.isArray(result.accepted) ? { accepted: result.accepted.map(envelopeEmail) } : {}),
59
+ ...(Array.isArray(result.rejected) ? { rejected: result.rejected.map(envelopeEmail) } : {}),
60
+ };
54
61
  }
55
62
  catch (error) {
56
63
  throw normalizeProviderError(error, 'smtp', 'send');
package/dist/testing.js CHANGED
@@ -5,6 +5,7 @@ export function createTestMailer(options) {
5
5
  const from = senderSchema.parse(options.from);
6
6
  const sent = [];
7
7
  let closed = false;
8
+ let sequence = 0;
8
9
  const assertOpen = () => {
9
10
  if (closed)
10
11
  throw new MailError('Mailer has been closed.', 'configuration', 'test', false);
@@ -16,10 +17,10 @@ export function createTestMailer(options) {
16
17
  },
17
18
  async send(input) {
18
19
  assertOpen();
19
- const message = { ...input, from: input.from ?? from };
20
+ const message = structuredClone({ ...input, from: input.from ?? from });
20
21
  mailInputSchema.parse(message);
21
22
  sent.push(message);
22
- return { provider: 'test', messageId: `test-${sent.length}`, acceptedAt: new Date() };
23
+ return { provider: 'test', messageId: `test-${++sequence}`, acceptedAt: new Date() };
23
24
  },
24
25
  async verifyConnection() {
25
26
  assertOpen();
package/dist/types.d.ts CHANGED
@@ -29,6 +29,9 @@ export interface SendMailResult<TProvider extends string = ProviderName | 'test'
29
29
  readonly messageId: string;
30
30
  /** Time when the provider accepted the request. This does not confirm inbox delivery. */
31
31
  readonly acceptedAt: Date;
32
+ /** SMTP envelope recipients accepted/rejected by the relay; not proof of inbox delivery. */
33
+ readonly accepted?: readonly string[];
34
+ readonly rejected?: readonly string[];
32
35
  }
33
36
  export interface Mailer<TProvider extends string = ProviderName | 'test'> {
34
37
  send(input: SendMailInput): Promise<SendMailResult<TProvider>>;
@@ -46,6 +49,7 @@ export interface CustomMailerOptions<TProvider extends string = string> {
46
49
  readonly provider: ProviderAdapter<TProvider>;
47
50
  readonly from: MailAddress;
48
51
  readonly maxAttachmentBytes?: number;
52
+ readonly onSend?: MailSendObserver;
49
53
  }
50
54
  export interface NormalizedMailInput extends Omit<SendMailInput, 'from' | 'to' | 'cc' | 'bcc' | 'replyTo'> {
51
55
  readonly from: MailAddress;
@@ -56,9 +60,29 @@ export interface NormalizedMailInput extends Omit<SendMailInput, 'from' | 'to' |
56
60
  }
57
61
  export interface ProviderSendResult {
58
62
  readonly messageId: string;
63
+ readonly accepted?: readonly string[];
64
+ readonly rejected?: readonly string[];
59
65
  }
60
66
  export interface MailProvider {
61
67
  send(input: NormalizedMailInput): Promise<ProviderSendResult>;
62
68
  verifyConnection(): Promise<void>;
63
69
  close(): Promise<void>;
64
70
  }
71
+ /** Contains no addresses, message content, credentials, or arbitrary provider errors. */
72
+ export type MailSendEvent = {
73
+ readonly type: 'started';
74
+ readonly provider: string;
75
+ } | {
76
+ readonly type: 'succeeded';
77
+ readonly provider: string;
78
+ readonly durationMs: number;
79
+ } | {
80
+ readonly type: 'failed';
81
+ readonly provider: string;
82
+ readonly durationMs: number;
83
+ readonly code: import('./errors.js').MailErrorCode;
84
+ readonly retryable: boolean;
85
+ readonly deliveryUnknown: boolean;
86
+ };
87
+ /** Observer failures are isolated. Async observers do not delay sends or close(). */
88
+ export type MailSendObserver = (event: MailSendEvent) => void | Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { EmailWebhookEvent } from './types.js';
2
- export declare function normalizeResend(payload: unknown): readonly EmailWebhookEvent[];
2
+ export declare function normalizeResend(payload: unknown, maxRecipients?: number): readonly EmailWebhookEvent[];
3
3
  export declare function normalizeMailgun(payload: unknown): readonly EmailWebhookEvent[];
4
4
  export declare function normalizeSendGrid(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
5
5
  export declare function normalizeSes(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
@@ -1,8 +1,12 @@
1
1
  import { WebhookVerificationError } from './types.js';
2
2
  import { asId, asOptionalRecord, asRecord, asString, asStringArray, firstString, isString, parseDate, } from './shared.js';
3
- export function normalizeResend(payload) {
3
+ export function normalizeResend(payload, maxRecipients = 1_000) {
4
4
  const root = asRecord(payload);
5
5
  const data = asRecord(root.data);
6
+ const singleRecipient = asString(data.to);
7
+ const recipients = singleRecipient ? [singleRecipient] : asStringArray(data.to);
8
+ if ((Array.isArray(data.to) && data.to.length > maxRecipients) || recipients.length > maxRecipients)
9
+ throw new WebhookVerificationError('invalid_payload');
6
10
  return [
7
11
  makeEvent('resend', root, {
8
12
  id: asString(root.id),
@@ -10,6 +14,7 @@ export function normalizeResend(payload) {
10
14
  type: mapEventType(asString(root.type)),
11
15
  messageId: asString(data.email_id),
12
16
  recipient: firstString(data.to),
17
+ ...(recipients.length ? { recipients } : {}),
13
18
  occurredAt: parseDate(root.created_at),
14
19
  }),
15
20
  ];
@@ -66,6 +71,14 @@ export function normalizeSes(payload, maxEvents) {
66
71
  }
67
72
  const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
68
73
  const emails = to.length > 0 ? to : asStringArray(mail.destination);
74
+ if (emails.length > maxEvents)
75
+ throw new WebhookVerificationError('invalid_payload');
76
+ const occurredAt = parseDate(delivery?.timestamp) ??
77
+ parseDate(bounce?.timestamp) ??
78
+ parseDate(complaint?.timestamp) ??
79
+ parseDate(asOptionalRecord(message.deliveryDelay)?.timestamp) ??
80
+ parseDate(asOptionalRecord(message.reject)?.timestamp) ??
81
+ parseDate(mail.timestamp);
69
82
  return emails.length > 0
70
83
  ? emails.map((recipient) => makeEvent('ses', message, {
71
84
  id: asString(message.messageId),
@@ -73,7 +86,7 @@ export function normalizeSes(payload, maxEvents) {
73
86
  type: mapEventType(eventName),
74
87
  messageId: asString(mail.messageId),
75
88
  recipient,
76
- occurredAt: parseDate(delivery?.timestamp) ?? parseDate(bounce?.timestamp),
89
+ occurredAt,
77
90
  }))
78
91
  : [
79
92
  makeEvent('ses', message, {
@@ -81,7 +94,7 @@ export function normalizeSes(payload, maxEvents) {
81
94
  eventType: eventName,
82
95
  type: mapEventType(eventName),
83
96
  messageId: asString(mail.messageId),
84
- occurredAt: parseDate(asRecord(message.delivery).timestamp),
97
+ occurredAt,
85
98
  }),
86
99
  ];
87
100
  }
@@ -96,6 +109,7 @@ export function normalizeProviderEvents(provider, payload, maxEvents) {
96
109
  id: provider === 'brevo'
97
110
  ? (asId(event.id) ?? asString(event['message-id']))
98
111
  : (asId(event.ID) ?? asString(event.MessageID)),
112
+ eventId: provider === 'brevo' ? asId(event.id) : asId(event.ID),
99
113
  eventType: eventName,
100
114
  type: mapEventType(eventName),
101
115
  messageId: provider === 'brevo' ? asString(event['message-id']) : asString(event.MessageID),
@@ -107,12 +121,15 @@ export function normalizeProviderEvents(provider, payload, maxEvents) {
107
121
  function makeEvent(provider, raw, fields) {
108
122
  if (!fields.eventType)
109
123
  throw new WebhookVerificationError('invalid_payload');
124
+ const eventId = fields.eventId ?? (provider !== 'brevo' && provider !== 'postmark' && provider !== 'ses' ? fields.id : undefined);
110
125
  return {
111
126
  provider,
112
127
  raw,
113
128
  eventType: fields.eventType,
114
129
  type: fields.type,
115
130
  ...(fields.id ? { id: fields.id } : {}),
131
+ ...(eventId ? { eventId } : {}),
132
+ ...(fields.recipients ? { recipients: fields.recipients } : {}),
116
133
  ...(fields.messageId ? { messageId: fields.messageId } : {}),
117
134
  ...(fields.recipient ? { recipient: fields.recipient } : {}),
118
135
  ...(fields.occurredAt ? { occurredAt: fields.occurredAt } : {}),
@@ -1,7 +1,7 @@
1
1
  import type { VerifyWebhookInput } from './types.js';
2
2
  export declare function verifyResend(input: Extract<VerifyWebhookInput, {
3
3
  provider: 'resend';
4
- }>, rawBody: Uint8Array): void;
4
+ }>, rawBody: Uint8Array): string;
5
5
  export declare function verifyMailgun(input: Extract<VerifyWebhookInput, {
6
6
  provider: 'mailgun';
7
7
  }>, payload: unknown): void;
@@ -13,4 +13,7 @@ export declare function verifyAuthorization(input: Extract<VerifyWebhookInput, {
13
13
  }>, headerName: string): void;
14
14
  export declare function verifySnsNotification(input: Extract<VerifyWebhookInput, {
15
15
  provider: 'ses';
16
- }>, payload: unknown): Promise<unknown>;
16
+ }>, payload: unknown): Promise<{
17
+ readonly payload: unknown;
18
+ readonly deliveryId: string;
19
+ }>;
@@ -24,6 +24,7 @@ export function verifyResend(input, rawBody) {
24
24
  .some((item) => item.startsWith('v1,') && safeEqual(expected, decodeBase64(item.slice(3))));
25
25
  if (!matches)
26
26
  throw new WebhookVerificationError('invalid_signature');
27
+ return messageId;
27
28
  }
28
29
  export function verifyMailgun(input, payload) {
29
30
  const root = asRecord(payload);
@@ -73,7 +74,8 @@ export async function verifySnsNotification(input, payload) {
73
74
  const signingCertUrl = asString(envelope.SigningCertURL);
74
75
  const signature = asString(envelope.Signature);
75
76
  const signatureVersion = asString(envelope.SignatureVersion);
76
- if (envelope.Type !== 'Notification' ||
77
+ if (!asString(envelope.MessageId) ||
78
+ envelope.Type !== 'Notification' ||
77
79
  topicArn !== input.topicArn ||
78
80
  !signingCertUrl ||
79
81
  !signature ||
@@ -114,13 +116,32 @@ export async function verifySnsNotification(input, payload) {
114
116
  throw new WebhookVerificationError('invalid_signature');
115
117
  let certificateBody;
116
118
  try {
117
- certificateBody = await response.text();
119
+ if (!response.body)
120
+ throw new WebhookVerificationError('invalid_signature');
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let length = 0;
124
+ try {
125
+ for (;;) {
126
+ const { done, value } = await reader.read();
127
+ if (done)
128
+ break;
129
+ length += value.byteLength;
130
+ if (length > 32_768) {
131
+ await reader.cancel();
132
+ throw new WebhookVerificationError('invalid_signature');
133
+ }
134
+ chunks.push(value);
135
+ }
136
+ certificateBody = Buffer.concat(chunks, length).toString('utf8');
137
+ }
138
+ finally {
139
+ reader.releaseLock();
140
+ }
118
141
  }
119
142
  catch {
120
143
  throw new WebhookVerificationError('invalid_signature');
121
144
  }
122
- if (certificateBody.length > 32_768)
123
- throw new WebhookVerificationError('invalid_signature');
124
145
  try {
125
146
  const certificate = new X509Certificate(certificateBody);
126
147
  const now = input.now ?? new Date();
@@ -144,7 +165,7 @@ export async function verifySnsNotification(input, payload) {
144
165
  }
145
166
  try {
146
167
  const message = JSON.parse(asString(envelope.Message) ?? '');
147
- return message;
168
+ return { payload: message, deliveryId: asString(envelope.MessageId) };
148
169
  }
149
170
  catch {
150
171
  throw new WebhookVerificationError('invalid_payload');
@@ -2,11 +2,18 @@ export type ProviderName = import('../types.js').ProviderName;
2
2
  export type EmailWebhookEventType = 'accepted' | 'delivered' | 'bounced' | 'complained' | 'delayed' | 'opened' | 'clicked' | 'unsubscribed' | 'rejected' | 'failed' | 'other';
3
3
  export interface EmailWebhookEvent {
4
4
  readonly provider: Exclude<ProviderName, 'smtp'>;
5
+ /** Legacy provider identifier; may fall back to the email message ID. */
5
6
  readonly id?: string;
7
+ /** Provider event ID, without a message-ID fallback. */
8
+ readonly eventId?: string;
9
+ /** Authenticated transport notification ID (Resend/SNS). Shared by events in one notification. */
10
+ readonly deliveryId?: string;
6
11
  readonly type: EmailWebhookEventType;
7
12
  readonly eventType: string;
8
13
  readonly messageId?: string;
9
14
  readonly recipient?: string;
15
+ /** All Resend recipients; recipient retains the first address for compatibility. */
16
+ readonly recipients?: readonly string[];
10
17
  readonly occurredAt?: Date;
11
18
  /** Original provider data. It can contain message metadata and personal information. */
12
19
  readonly raw: unknown;
package/dist/webhooks.js CHANGED
@@ -24,9 +24,10 @@ export async function verifyWebhook(input) {
24
24
  throw new WebhookVerificationError('invalid_payload');
25
25
  }
26
26
  switch (input.provider) {
27
- case 'resend':
28
- verifyResend(input, rawBody);
29
- return normalizeResend(payload);
27
+ case 'resend': {
28
+ const deliveryId = verifyResend(input, rawBody);
29
+ return normalizeResend(payload, maxEvents).map((event) => ({ ...event, deliveryId }));
30
+ }
30
31
  case 'mailgun':
31
32
  verifyMailgun(input, payload);
32
33
  return normalizeMailgun(payload);
@@ -37,7 +38,12 @@ export async function verifyWebhook(input) {
37
38
  case 'postmark':
38
39
  verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
39
40
  return normalizeProviderEvents(input.provider, payload, maxEvents);
40
- case 'ses':
41
- return normalizeSes(await verifySnsNotification(input, payload), maxEvents);
41
+ case 'ses': {
42
+ const notification = await verifySnsNotification(input, payload);
43
+ return normalizeSes(notification.payload, maxEvents).map((event) => ({
44
+ ...event,
45
+ deliveryId: notification.deliveryId,
46
+ }));
47
+ }
42
48
  }
43
49
  }
@@ -7,6 +7,8 @@ The adapters provide one `createMailer(...).send(...)` interface, while provider
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
9
  - A successful result includes the selected provider, a non-blank provider message ID, and local acceptance timestamp. If a built-in or custom provider gives no usable ID, `send()` rejects with a `MailError` with code `provider` and `deliveryUnknown: true`.
10
+ - SMTP results include optional `accepted`/`rejected` envelope recipients. Partial acceptance resolves, while complete rejection rejects; acceptance does not prove inbox delivery.
11
+ - Optional `onSend` observers expose sanitized lifecycle metrics, with synchronous and asynchronous failures isolated. See [reliability](reliability.md).
10
12
  - TypedMailer does not retry automatically. `retryable` and `deliveryUnknown` are diagnostic signals, not a safe retry instruction. A timeout or ambiguous provider response can mean the provider accepted the message.
11
13
  - Unsupported fields or operations fail with `MailError` code `unsupported`; adapters must not silently discard caller data.
12
14
  - `close()` is safe to call repeatedly, stops new work, waits for in-flight operations, and closes the underlying transport at most once.
@@ -37,4 +39,4 @@ When changing an adapter, add its configuration schema and lazy loader in `src/p
37
39
 
38
40
  ## Custom adapters
39
41
 
40
- Applications can provide a `ProviderAdapter<TName>` directly to `createMailer()` without adding a built-in provider. Its `send()` method receives `NormalizedMailInput`: `from` is always set, and `to`, `cc`, and `bcc` are arrays when present. Resolve with `{ messageId }` after the SDK accepts the message. `verifyConnection()` and `close()` are optional; verification rejects with `unsupported` when omitted, while close is a no-op. TypedMailer normalizes thrown errors and tags results with the adapter's name. The generic adapter name is preserved in `SendMailResult<TName>` for TypeScript callers. Custom adapters are responsible for documenting and enforcing unsupported message fields.
42
+ Applications can provide a `ProviderAdapter<TName>` directly to `createMailer()` without adding a built-in provider. Its `send()` method receives `NormalizedMailInput`: `from` is always set, and `to`, `cc`, and `bcc` are arrays when present. Resolve with `{ messageId }` and optional `accepted`/`rejected` recipient lists after the SDK accepts the message. `verifyConnection()` and `close()` are optional; verification rejects with `unsupported` when omitted, while close is a no-op. TypedMailer normalizes thrown errors and tags results with the adapter's name. The generic adapter name is preserved in `SendMailResult<TName>` for TypeScript callers. Custom adapters are responsible for documenting and enforcing unsupported message fields.
@@ -0,0 +1,59 @@
1
+ # Reliable application delivery
2
+
3
+ TypedMailer reports provider acceptance, not inbox delivery. It performs no automatic retry, queueing, database migration, or failover. Version 2.1 adds optional SMTP recipient results, structured provider errors, authenticated webhook notification IDs, and isolated send observers.
4
+
5
+ ## SMTP partial acceptance
6
+
7
+ ```ts
8
+ const result = await mailer.send(message);
9
+ if (result.rejected?.length) {
10
+ await recordPartialAcceptance(result);
11
+ }
12
+ ```
13
+
14
+ SMTP `accepted` and `rejected` contain envelope recipient addresses reported by the relay, including CC/BCC. A send with some rejected recipients still resolves when the relay accepts the remaining recipients. Do not retry the entire original message: recipients already accepted could receive duplicates. When every recipient is rejected, the SMTP adapter rejects. These fields are optional for other and custom providers. Treat the recipient lists as personal data.
15
+
16
+ ## Error diagnostics and observations
17
+
18
+ `MailError.status` contains the HTTP or SMTP response status when the SDK exposes it. `retryAfterSeconds` contains an optional parsed `Retry-After` delay, rounded up to whole seconds; unsupported/malformed delays remain undefined. These values do not override `deliveryUnknown` or make a retry safe. The original SDK error remains in `cause` and can contain sensitive information.
19
+
20
+ ```ts
21
+ const mailer = createMailer({
22
+ provider: 'resend',
23
+ apiKey,
24
+ from: 'sender@example.test',
25
+ onSend(event) {
26
+ if (event.type === 'started') return;
27
+ metrics.record(event.provider, event.type, event.durationMs);
28
+ if (event.type === 'failed') metrics.recordFailure(event.code);
29
+ },
30
+ });
31
+ ```
32
+
33
+ `onSend` emits `started` followed by `succeeded` or `failed` for a validated send that reaches provider execution. Payloads contain provider, duration, and safe error classifications, never addresses, subject, content, headers, credentials, or arbitrary SDK errors. Construction/message validation failures and calls after close do not emit send events. Observer exceptions and rejected promises are ignored deliberately. Async observers are not awaited by `send()` or `close()`; applications own flushing and error reporting for their telemetry. Keep synchronous callbacks short. No telemetry dependency is installed by the package.
34
+
35
+ ## Durable webhook inbox
36
+
37
+ Copy [`durable-mail.ts`](../examples/reliability/durable-mail.ts) and [`schema.sql`](../examples/reliability/schema.sql) into your application. Supply a `SqlDatabase` adapter for your PostgreSQL client. `acceptWebhookDelivery` inserts one complete notification with a unique `(provider, delivery_id)` constraint. Use it from the framework handler's `acceptEvents` callback and resolve only after database commit. A storage failure must return a retryable HTTP failure, not an acknowledgment.
38
+
39
+ For Resend and SES, `verifyWebhook()` provides an authenticated `deliveryId`. It identifies the whole notification; SES can produce multiple recipient events sharing this ID. Store the complete event array once, then apply business changes in a transaction with an inbox processing marker. Do not deduplicate each recipient separately using only `deliveryId`. For other providers, use genuine `eventId` values where present, or an application-defined provider-specific key. The legacy `id` can equal a message ID and must not be assumed unique across delivery/open/bounce events. Do not invent an unsigned delivery ID from a request header.
40
+
41
+ The supplied inbox schema stores normalized events including `raw`. Define retention, access controls, encryption, and any payload redaction in your application. Processing markers and business transactions depend on your domain and are not implemented by this example.
42
+
43
+ ## Transactional outbox
44
+
45
+ `enqueueMail` uses a stable business-operation ID to prevent duplicate queue entries. Validate the message first and call it in the same database transaction as the business change. Apply `schema.sql` using your application's migration process. The adapter passed to enqueue can represent an open transaction; worker and inbox adapters must commit before reporting success. Binary attachments are encoded explicitly for JSON storage and restored as `Uint8Array`.
46
+
47
+ `createSqlOutboxStore` claims one queued row with `FOR UPDATE SKIP LOCKED`, committing `sending` before network I/O. `processNextMail` sends once and records one of these states:
48
+
49
+ | State | Meaning and next action |
50
+ | ---------- | ---------------------------------------------------------------------------------------------------- |
51
+ | `accepted` | Provider accepted the request. Follow delivery webhooks separately. |
52
+ | `partial` | SMTP rejected some recipients. Reconcile the rejected subset. |
53
+ | `failed` | Classified rejection with no uncertain acceptance. Application policy decides whether to requeue. |
54
+ | `unknown` | Acceptance cannot be established. Reconcile with the provider before resending. |
55
+ | `sending` | Claimed, but no outcome persisted yet. A worker crash or failed receipt commit can leave this state. |
56
+
57
+ The example deliberately does not reclaim stale `sending` rows automatically. Reconcile them and move to `unknown` when acceptance cannot be proven. A failed receipt commit propagates to the worker; it never reclassifies an accepted send as a failure. Use provider idempotency only where supported and keep the key stable across retries. Never resend the whole partial/unknown job automatically.
58
+
59
+ Tests run the SQL against embedded PostgreSQL (PGlite), verify deduplication and state transitions, and exercise database failure paths. Production database concurrency, migrations, worker deployment, provider delivery, and operational recovery require application-level verification.
package/docs/webhooks.md CHANGED
@@ -44,13 +44,15 @@ for (const event of events) {
44
44
 
45
45
  ## Normalized event shape
46
46
 
47
- Each event includes `provider`, provider `eventType`, a normalized `type`, and the original provider payload in `raw`. When available, it also includes a provider event ID (`id`), message ID, recipient, and event timestamp (`occurredAt`). `raw` may contain personal data and provider metadata; avoid logging it without review. Unknown provider event names map to `type: 'other'` and retain their original `eventType`.
47
+ Each event includes `provider`, provider `eventType`, a normalized `type`, and the original provider payload in `raw`. When available, it also includes `eventId` (a genuine provider event ID), `messageId`, `recipient`, and `occurredAt`. Resend/SNS events also carry an authenticated `deliveryId`. Resend preserves all recipients in `recipients` while retaining the first in `recipient` and still returning one event. SES returns one event per recipient and preserves complaint/delay timestamps.
48
+
49
+ `id` remains a legacy identifier for compatibility: Brevo/Postmark can fall back to the message ID. Do not assume it is unique across different events for the same email. `raw` may contain personal data and provider metadata; avoid logging it without review. Unknown provider event names map to `type: 'other'` and retain their original `eventType`.
48
50
 
49
51
  Normalized types are `accepted`, `delivered`, `bounced`, `complained`, `delayed`, `opened`, `clicked`, `unsubscribed`, `rejected`, `failed`, and `other`.
50
52
 
51
53
  ## Delivery, retries, and deduplication
52
54
 
53
- Providers retry webhook delivery. `verifyWebhook()` verifies authenticity but does not prevent the same authentic event from being processed twice. Persist event IDs (or a stable provider/message/event key when the provider has no event ID) with a unique constraint before applying side effects. Acknowledge only after durable acceptance by your application. Do not log secrets or full event payloads by default.
55
+ Providers retry webhook delivery. `verifyWebhook()` verifies authenticity but does not prevent the same authentic event from being processed twice. Persist complete notifications by `(provider, deliveryId)` where available, or use genuine provider `eventId` values and a provider-specific fallback when absent. SES recipient events share one notification ID; do not discard siblings by deduplicating each with that ID alone. Use a unique constraint before applying side effects. See [durable inbox/outbox examples](reliability.md). Acknowledge only after durable acceptance by your application. Do not log secrets or full event payloads by default.
54
56
 
55
57
  ## Provider references
56
58
 
@@ -0,0 +1,134 @@
1
+ import { MailError, type Mailer, type SendMailInput, type SendMailResult } from 'typedmailer';
2
+ import type { EmailWebhookEvent } from 'typedmailer/webhooks';
3
+
4
+ /** Implement using a database client. Inbox/worker queries must commit before resolving; enqueue may use a business transaction. */
5
+ export interface SqlDatabase {
6
+ query(sql: string, values: readonly unknown[]): Promise<{ readonly rows: readonly Record<string, unknown>[] }>;
7
+ }
8
+
9
+ export interface OutboxJob {
10
+ readonly id: string;
11
+ readonly input: SendMailInput;
12
+ }
13
+
14
+ export interface OutboxFailure {
15
+ readonly state: 'failed' | 'unknown';
16
+ readonly code: string;
17
+ readonly retryable: boolean;
18
+ }
19
+
20
+ export interface OutboxStore {
21
+ claim(): Promise<OutboxJob | undefined>;
22
+ accept(id: string, result: SendMailResult<string>): Promise<void>;
23
+ fail(id: string, failure: OutboxFailure): Promise<void>;
24
+ }
25
+
26
+ /** One committed row per authenticated notification, including every normalized event. */
27
+ export async function acceptWebhookDelivery(
28
+ database: SqlDatabase,
29
+ deliveryId: string,
30
+ events: readonly EmailWebhookEvent[],
31
+ ): Promise<void> {
32
+ const provider = events[0]?.provider;
33
+ if (!deliveryId.trim() || !provider || events.some((event) => event.provider !== provider)) {
34
+ throw new Error('Expected one provider and an authenticated notification ID.');
35
+ }
36
+ await database.query(
37
+ `INSERT INTO mail_webhook_inbox (provider, delivery_id, events)
38
+ VALUES ($1, $2, $3::jsonb) ON CONFLICT (provider, delivery_id) DO NOTHING`,
39
+ [provider, deliveryId, JSON.stringify(events)],
40
+ );
41
+ }
42
+
43
+ /** Validate input before calling; enqueue in the SAME transaction as the business change. */
44
+ export async function enqueueMail(database: SqlDatabase, id: string, input: SendMailInput): Promise<void> {
45
+ if (!id.trim()) throw new Error('Expected a stable business operation ID.');
46
+ await database.query(`INSERT INTO mail_outbox (id, input) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO NOTHING`, [
47
+ id,
48
+ JSON.stringify({
49
+ ...input,
50
+ ...(input.attachments
51
+ ? {
52
+ attachments: input.attachments.map((attachment) => ({
53
+ ...attachment,
54
+ content:
55
+ typeof attachment.content === 'string'
56
+ ? attachment.content
57
+ : { encoding: 'typedmailer-bytes', base64: Buffer.from(attachment.content).toString('base64') },
58
+ })),
59
+ }
60
+ : {}),
61
+ }),
62
+ ]);
63
+ }
64
+
65
+ /** Claim commits BEFORE network I/O. A crash leaves sending for reconciliation, never blind resend. */
66
+ export function createSqlOutboxStore(database: SqlDatabase): OutboxStore {
67
+ return {
68
+ async claim() {
69
+ const result = await database.query(
70
+ `WITH candidate AS (
71
+ SELECT id FROM mail_outbox WHERE state = 'queued'
72
+ ORDER BY created_at, id FOR UPDATE SKIP LOCKED LIMIT 1
73
+ ) UPDATE mail_outbox SET state = 'sending', updated_at = now()
74
+ FROM candidate WHERE mail_outbox.id = candidate.id
75
+ RETURNING mail_outbox.id, mail_outbox.input`,
76
+ [],
77
+ );
78
+ const row = result.rows[0];
79
+ if (!row) return undefined;
80
+ if (typeof row.id !== 'string') throw new Error('Invalid outbox row ID.');
81
+ // Stored JSON comes from validated application input. The mailer validates again before sending.
82
+ const input = JSON.parse(JSON.stringify(row.input), (_key, value: unknown) => {
83
+ if (
84
+ typeof value === 'object' &&
85
+ value !== null &&
86
+ 'encoding' in value &&
87
+ value.encoding === 'typedmailer-bytes' &&
88
+ 'base64' in value &&
89
+ typeof value.base64 === 'string'
90
+ ) {
91
+ return new Uint8Array(Buffer.from(value.base64, 'base64'));
92
+ }
93
+ return value;
94
+ }) as SendMailInput;
95
+ return { id: row.id, input };
96
+ },
97
+ async accept(id, result) {
98
+ const updated = await database.query(
99
+ `UPDATE mail_outbox SET state = $2, receipt = $3::jsonb, updated_at = now()
100
+ WHERE id = $1 AND state = 'sending' RETURNING id`,
101
+ [id, result.rejected?.length ? 'partial' : 'accepted', JSON.stringify(result)],
102
+ );
103
+ if (updated.rows[0]?.id !== id) throw new Error('Outbox receipt was not committed.');
104
+ },
105
+ async fail(id, failure) {
106
+ const updated = await database.query(
107
+ `UPDATE mail_outbox SET state = $2, failure = $3::jsonb, updated_at = now()
108
+ WHERE id = $1 AND state = 'sending' RETURNING id`,
109
+ [id, failure.state, JSON.stringify(failure)],
110
+ );
111
+ if (updated.rows[0]?.id !== id) throw new Error('Outbox failure was not committed.');
112
+ },
113
+ };
114
+ }
115
+
116
+ /** No automatic retry: the application reconciles unknown and partial delivery states. */
117
+ export async function processNextMail(mailer: Mailer<string>, store: OutboxStore): Promise<boolean> {
118
+ const job = await store.claim();
119
+ if (!job) return false;
120
+ let result: SendMailResult<string>;
121
+ try {
122
+ result = await mailer.send(job.input);
123
+ } catch (error) {
124
+ const failure: OutboxFailure =
125
+ error instanceof MailError
126
+ ? { state: error.deliveryUnknown ? 'unknown' : 'failed', code: error.code, retryable: error.retryable }
127
+ : { state: 'unknown', code: 'unclassified', retryable: false };
128
+ await store.fail(job.id, failure);
129
+ return true;
130
+ }
131
+ // Persistence failures propagate. Never reinterpret an accepted send as a send failure.
132
+ await store.accept(job.id, result);
133
+ return true;
134
+ }
@@ -0,0 +1,20 @@
1
+ CREATE TABLE mail_webhook_inbox (
2
+ provider text NOT NULL,
3
+ delivery_id text NOT NULL,
4
+ events jsonb NOT NULL,
5
+ created_at timestamptz NOT NULL DEFAULT now(),
6
+ PRIMARY KEY (provider, delivery_id)
7
+ );
8
+
9
+ CREATE TABLE mail_outbox (
10
+ id text PRIMARY KEY,
11
+ input jsonb NOT NULL,
12
+ state text NOT NULL DEFAULT 'queued'
13
+ CHECK (state IN ('queued', 'sending', 'accepted', 'partial', 'failed', 'unknown')),
14
+ receipt jsonb,
15
+ failure jsonb,
16
+ created_at timestamptz NOT NULL DEFAULT now(),
17
+ updated_at timestamptz NOT NULL DEFAULT now()
18
+ );
19
+
20
+ CREATE INDEX mail_outbox_queued ON mail_outbox (created_at, id) WHERE state = 'queued';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typedmailer",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
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",
@@ -38,7 +38,9 @@
38
38
  "docs/webhooks.md",
39
39
  "CODE_OF_CONDUCT.md",
40
40
  "docs/migration-v2.md",
41
- "docs/framework-webhooks.md"
41
+ "docs/framework-webhooks.md",
42
+ "docs/reliability.md",
43
+ "examples/reliability"
42
44
  ],
43
45
  "scripts": {
44
46
  "build": "tsc -p tsconfig.build.json",
@@ -106,6 +108,7 @@
106
108
  },
107
109
  "devDependencies": {
108
110
  "@aws-sdk/client-sesv2": ">=3.797.0 <4",
111
+ "@electric-sql/pglite": "^0.5.8",
109
112
  "@getbrevo/brevo": ">=6.0.1 <7",
110
113
  "@sendgrid/mail": ">=8.0.0 <9",
111
114
  "@types/express": "^5.0.6",
@@ -117,6 +120,7 @@
117
120
  "husky": "^9.1.7",
118
121
  "lint-staged": "^17.6.0",
119
122
  "mailgun.js": ">=14.0.0 <15",
123
+ "nock": "^14.0.17",
120
124
  "nodemailer": ">=7.0.0 <11",
121
125
  "postmark": ">=5.0.0 <6",
122
126
  "prettier": "^3.9.9",