typedmailer 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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({
@@ -228,11 +232,11 @@ await mailer.send({ to: 'person@example.test', subject: 'Hello', text: 'Hi' });
228
232
  console.log(mailer.sent[0]);
229
233
  ```
230
234
 
231
- 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.
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
 
@@ -247,7 +251,7 @@ For Amazon SES, run `npm install typedmailer @aws-sdk/client-sesv2 && node --env
247
251
 
248
252
  ## Errors and delivery
249
253
 
250
- 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:
251
255
 
252
256
  | Code | Meaning |
253
257
  | ---------------- | --------------------------------------------------------------------- |
@@ -260,6 +264,8 @@ Provider and transport failures are normalized as `MailError`, with `code`, `pro
260
264
 
261
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.
262
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
+
263
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).
264
270
 
265
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.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/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
@@ -1,9 +1,9 @@
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';
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,16 +116,43 @@ 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
- return {
123
+ if (typeof result?.messageId !== 'string' || result.messageId.trim().length === 0) {
124
+ throw new MailError('The provider returned no message identifier.', 'provider', providerName, false, {
125
+ deliveryUnknown: true,
126
+ });
127
+ }
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] } : {}),
113
138
  provider: providerName,
114
139
  messageId: result.messageId,
115
140
  acceptedAt: new Date(),
116
141
  };
142
+ notify({ type: 'succeeded', provider: providerName, durationMs: performance.now() - startedAt });
143
+ return receipt;
117
144
  }
118
145
  catch (error) {
119
- 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;
120
156
  }
121
157
  },
122
158
  async verifyConnection() {
@@ -1,12 +1,13 @@
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
- }, z.core.$strip>;
9
+ onSend: z.ZodOptional<z.ZodCustom<MailSendObserver, MailSendObserver>>;
10
+ }, z.core.$strict>;
10
11
  export declare const providerOptions: {
11
12
  readonly resend: z.ZodObject<{
12
13
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
@@ -14,42 +15,47 @@ 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
- }, z.core.$strip>;
21
+ }, z.core.$strict>;
20
22
  readonly brevo: z.ZodObject<{
21
23
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
22
24
  email: z.ZodString;
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
- }, z.core.$strip>;
31
+ }, z.core.$strict>;
29
32
  readonly postmark: z.ZodObject<{
30
33
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
31
34
  email: z.ZodString;
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
- }, z.core.$strip>;
41
+ }, z.core.$strict>;
38
42
  readonly sendgrid: z.ZodObject<{
39
43
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
40
44
  email: z.ZodString;
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
- }, z.core.$strip>;
51
+ }, z.core.$strict>;
47
52
  readonly mailgun: z.ZodObject<{
48
53
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
49
54
  email: z.ZodString;
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;
@@ -57,22 +63,24 @@ export declare const providerOptions: {
57
63
  us: "us";
58
64
  eu: "eu";
59
65
  }>>;
60
- }, z.core.$strip>;
66
+ }, z.core.$strict>;
61
67
  readonly ses: z.ZodObject<{
62
68
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
63
69
  email: z.ZodString;
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
- }, z.core.$strip>;
76
+ }, z.core.$strict>;
70
77
  readonly smtp: z.ZodObject<{
71
78
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
72
79
  email: z.ZodString;
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;
@@ -83,7 +91,7 @@ export declare const providerOptions: {
83
91
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
84
92
  greetingTimeout: z.ZodDefault<z.ZodNumber>;
85
93
  socketTimeout: z.ZodDefault<z.ZodNumber>;
86
- }, z.core.$strip>;
94
+ }, z.core.$strict>;
87
95
  };
88
96
  export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
89
97
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
@@ -91,38 +99,43 @@ 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
- }, z.core.$strip>, z.ZodObject<{
105
+ }, z.core.$strict>, z.ZodObject<{
97
106
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
98
107
  email: z.ZodString;
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
- }, z.core.$strip>, z.ZodObject<{
114
+ }, z.core.$strict>, z.ZodObject<{
105
115
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
106
116
  email: z.ZodString;
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
- }, z.core.$strip>, z.ZodObject<{
123
+ }, z.core.$strict>, z.ZodObject<{
113
124
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
114
125
  email: z.ZodString;
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
- }, z.core.$strip>, z.ZodObject<{
132
+ }, z.core.$strict>, z.ZodObject<{
121
133
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
122
134
  email: z.ZodString;
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;
@@ -130,20 +143,22 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
130
143
  us: "us";
131
144
  eu: "eu";
132
145
  }>>;
133
- }, z.core.$strip>, z.ZodObject<{
146
+ }, z.core.$strict>, z.ZodObject<{
134
147
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
135
148
  email: z.ZodString;
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
- }, z.core.$strip>, z.ZodObject<{
155
+ }, z.core.$strict>, z.ZodObject<{
142
156
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
143
157
  email: z.ZodString;
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;
@@ -154,7 +169,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
154
169
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
155
170
  greetingTimeout: z.ZodDefault<z.ZodNumber>;
156
171
  socketTimeout: z.ZodDefault<z.ZodNumber>;
157
- }, z.core.$strip>], "provider">;
172
+ }, z.core.$strict>], "provider">;
158
173
  export type MailerOptions = z.input<typeof mailerOptionsSchema>;
159
174
  export type ParsedMailerOptions = z.output<typeof mailerOptionsSchema>;
160
175
  export declare function loadProvider(options: ParsedMailerOptions): Promise<MailProvider>;
@@ -1,14 +1,16 @@
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
+ onSend: z
10
+ .custom((value) => typeof value === 'function', 'Expected an observer function.')
11
+ .optional(),
12
+ })
13
+ .strict();
12
14
  export const providerOptions = {
13
15
  resend: mailerBaseOptionsSchema.extend({ provider: z.literal('resend'), apiKey: z.string().min(1) }),
14
16
  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
  })),
@@ -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
@@ -1,9 +1,11 @@
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;
8
+ let sequence = 0;
7
9
  const assertOpen = () => {
8
10
  if (closed)
9
11
  throw new MailError('Mailer has been closed.', 'configuration', 'test', false);
@@ -15,10 +17,10 @@ export function createTestMailer(options) {
15
17
  },
16
18
  async send(input) {
17
19
  assertOpen();
18
- const message = { ...input, from: input.from ?? options.from };
20
+ const message = structuredClone({ ...input, from: input.from ?? from });
19
21
  mailInputSchema.parse(message);
20
22
  sent.push(message);
21
- return { provider: 'test', messageId: `test-${sent.length}`, acceptedAt: new Date() };
23
+ return { provider: 'test', messageId: `test-${++sequence}`, acceptedAt: new Date() };
22
24
  },
23
25
  async verifyConnection() {
24
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>;