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 +12 -6
- package/dist/config.d.ts +4 -0
- package/dist/config.js +18 -8
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +42 -26
- package/dist/index.d.ts +3 -3
- package/dist/index.js +38 -2
- package/dist/providers/registry.d.ts +31 -16
- package/dist/providers/registry.js +9 -7
- package/dist/providers/resend.js +1 -1
- package/dist/providers/sendgrid.js +1 -1
- package/dist/providers/smtp.js +8 -1
- package/dist/testing.js +5 -3
- package/dist/types.d.ts +24 -0
- package/dist/webhooks/normalize.d.ts +1 -1
- package/dist/webhooks/normalize.js +20 -3
- package/dist/webhooks/signatures.d.ts +5 -2
- package/dist/webhooks/signatures.js +26 -5
- package/dist/webhooks/types.d.ts +7 -0
- package/dist/webhooks.js +11 -5
- package/docs/framework-webhooks.md +60 -0
- package/docs/migration-v2.md +35 -0
- package/docs/provider-contracts.md +11 -3
- package/docs/reliability.md +59 -0
- package/docs/webhooks.md +6 -2
- package/examples/reliability/durable-mail.ts +134 -0
- package/examples/reliability/schema.sql +20 -0
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ npm install typedmailer mailgun.js form-data
|
|
|
39
39
|
npm install typedmailer @aws-sdk/client-sesv2
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
Upgrading from v1? Read the [v2 migration guide](docs/migration-v2.md), including the SES SDK minimum and attachment encoding changes.
|
|
43
|
+
|
|
42
44
|
Requires Node.js 22 or newer. Provider SDKs are optional peers and are loaded only when their adapter is selected.
|
|
43
45
|
|
|
44
46
|
For SMTP, use `secure: true` with implicit TLS (commonly port 465), or use STARTTLS with `secure: false` and `requireTLS: true`. Port 587 requires STARTTLS by default; other ports use opportunistic TLS unless `requireTLS` is set.
|
|
@@ -134,7 +136,7 @@ const mailer = createMailer({
|
|
|
134
136
|
|
|
135
137
|
### Amazon SES
|
|
136
138
|
|
|
137
|
-
SES requires a region and uses the AWS SDK credential provider chain, such as environment credentials, a shared profile, or an IAM role.
|
|
139
|
+
SES requires `@aws-sdk/client-sesv2 >=3.797.0 <4`, a region, and uses the AWS SDK credential provider chain, such as environment credentials, a shared profile, or an IAM role.
|
|
138
140
|
|
|
139
141
|
```ts
|
|
140
142
|
const mailer = createMailer({
|
|
@@ -162,10 +164,12 @@ For local development, start Mailpit with `docker run --rm -p 1025:1025 -p 8025:
|
|
|
162
164
|
|
|
163
165
|
## Message options
|
|
164
166
|
|
|
165
|
-
`to` accepts an email string, a `{ email, name }` object, or an array. Provide `text` or `html` (or both). Optional fields include `from`, `replyTo`, `cc`, `bcc`, `headers`, `attachments`, `metadata`, and `idempotencyKey`; `messageId` is SMTP-only. Attachments may include `contentId` for inline images with Resend, Postmark, SendGrid, Mailgun, Amazon SES, and SMTP. Attachment content accepts strings or `Uint8Array`; the provider adapters buffer it for SDK requests, so use an application-managed upload or streaming workflow for large files. Set `maxAttachmentBytes` on `createMailer()` to reject a message before loading or calling the provider when the combined UTF-8 and binary attachment content exceeds your application's memory budget. It is unset by default for backward compatibility.
|
|
167
|
+
`to` accepts an email string, a `{ email, name }` object, or an array. Provide `text` or `html` (or both). Optional fields include `from`, `replyTo`, `cc`, `bcc`, `headers`, `attachments`, `metadata`, and `idempotencyKey`; `messageId` is SMTP-only. Attachments may include `contentId` for inline images with Resend, Postmark, SendGrid, Mailgun, Amazon SES, and SMTP. Attachment content accepts UTF-8 text strings or raw bytes as `Uint8Array`; the provider adapters buffer it for SDK requests, so use an application-managed upload or streaming workflow for large files. Set `maxAttachmentBytes` on `createMailer()` to reject a message before loading or calling the provider when the combined UTF-8 and binary attachment content exceeds your application's memory budget. It is unset by default for backward compatibility.
|
|
166
168
|
|
|
167
169
|
Subjects and attachment filenames cannot be blank. Supplied content types and inline content IDs must be non-empty. The library does not impose a fixed attachment-size limit.
|
|
168
170
|
|
|
171
|
+
Built-in and custom mailer configuration reject unknown option keys. Named sender strings validate the enclosed email address; display names cannot contain line breaks. Message/configuration validation failures are Zod errors. Built-in provider names are preserved in result types: a Resend mailer returns `SendMailResult<'resend'>`.
|
|
172
|
+
|
|
169
173
|
Provider capabilities differ. Unsupported fields return a `MailError` with code `unsupported` instead of being silently ignored.
|
|
170
174
|
|
|
171
175
|
| Provider | Custom `messageId` | `idempotencyKey` | Metadata | Inline attachments | `verifyConnection()` |
|
|
@@ -202,7 +206,7 @@ const result = await mailer.send({ to: 'person@example.com', subject: 'Hello', t
|
|
|
202
206
|
await mailer.close();
|
|
203
207
|
```
|
|
204
208
|
|
|
205
|
-
The custom adapter owns provider SDK setup, field support, and error handling. Throwing an error still passes through TypedMailer error normalization. Connection verification is reported as `unsupported` when the adapter does not implement it.
|
|
209
|
+
The custom adapter owns provider SDK setup, field support, and error handling. Throwing an error still passes through TypedMailer error normalization. Connection verification is reported as `unsupported` when the adapter does not implement it. Blank or invalid custom message IDs reject with code `provider` and `deliveryUnknown: true`.
|
|
206
210
|
|
|
207
211
|
```ts
|
|
208
212
|
await mailer.send({
|
|
@@ -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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
const emailSchema = z.string().email();
|
|
3
|
+
const addressObjectSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
email: emailSchema,
|
|
6
|
+
name: z
|
|
7
|
+
.string()
|
|
8
|
+
.refine((value) => !/[\r\n]/.test(value), 'Display names cannot contain line breaks.')
|
|
9
|
+
.optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const addressSchema = z.union([emailSchema, addressObjectSchema]);
|
|
13
|
+
export const senderSchema = z.union([
|
|
14
|
+
emailSchema,
|
|
15
|
+
z.string().refine((value) => {
|
|
16
|
+
const match = /^([^<>\r\n]+) <([^<>\s]+)>$/.exec(value);
|
|
17
|
+
return match !== null && match[1].trim().length > 0 && emailSchema.safeParse(match[2]).success;
|
|
18
|
+
}, 'Use a valid email address or "Name <email@example.com>".'),
|
|
19
|
+
addressObjectSchema,
|
|
10
20
|
]);
|
|
11
21
|
export const mailInputSchema = z
|
|
12
22
|
.object({
|
package/dist/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 =
|
|
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 =
|
|
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
|
|
2
|
+
import type { CustomMailerOptions, Mailer } from './types.js';
|
|
3
3
|
export type MailerOptions = BuiltInMailerOptions | CustomMailerOptions;
|
|
4
4
|
export declare function createMailer<const TProvider extends string>(input: CustomMailerOptions<TProvider>): Mailer<TProvider>;
|
|
5
|
-
export declare function createMailer(input:
|
|
5
|
+
export declare function createMailer<const TOptions extends BuiltInMailerOptions>(input: TOptions): Mailer<TOptions['provider']>;
|
|
6
6
|
export declare function createMailer(input: MailerOptions): Mailer<string>;
|
|
7
7
|
export { MailError } from './errors.js';
|
|
8
8
|
export type { MailErrorCode, MailErrorOperation, MailErrorOptions } from './errors.js';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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.$
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
z.object({ email: z.string().email(), name: z.string().optional() }).strict(),
|
|
8
|
-
]),
|
|
3
|
+
import { senderSchema } from '../config.js';
|
|
4
|
+
export const mailerBaseOptionsSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
from: senderSchema,
|
|
9
7
|
/** Optional aggregate in-memory attachment cap. No default preserves existing behavior. */
|
|
10
8
|
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
11
|
-
|
|
9
|
+
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) }),
|
package/dist/providers/resend.js
CHANGED
|
@@ -28,7 +28,7 @@ export function createResendProvider(options) {
|
|
|
28
28
|
? {
|
|
29
29
|
attachments: input.attachments.map((attachment) => ({
|
|
30
30
|
filename: attachment.filename,
|
|
31
|
-
content:
|
|
31
|
+
content: Buffer.from(attachment.content).toString('base64'),
|
|
32
32
|
...(attachment.contentType ? { contentType: attachment.contentType } : {}),
|
|
33
33
|
...(attachment.contentId ? { contentId: attachment.contentId } : {}),
|
|
34
34
|
})),
|
|
@@ -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 ? {
|
|
38
|
+
...(attachment.contentId ? { content_id: attachment.contentId, disposition: 'inline' } : {}),
|
|
39
39
|
})),
|
|
40
40
|
}
|
|
41
41
|
: {}),
|
package/dist/providers/smtp.js
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
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-${
|
|
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>;
|