typedmailer 2.0.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 +4 -2
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +42 -26
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -2
- package/dist/providers/registry.d.ts +16 -1
- package/dist/providers/registry.js +3 -0
- package/dist/providers/sendgrid.js +1 -1
- package/dist/providers/smtp.js +8 -1
- package/dist/testing.js +3 -2
- 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/provider-contracts.md +3 -1
- package/docs/reliability.md +59 -0
- package/docs/webhooks.md +4 -2
- package/examples/reliability/durable-mail.ts +134 -0
- package/examples/reliability/schema.sql +20 -0
- package/package.json +6 -2
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/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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ? {
|
|
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
|
@@ -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-${
|
|
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
|
|
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
|
|
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):
|
|
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<
|
|
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.
|
|
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
|
-
|
|
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');
|
package/dist/webhooks/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A type-safe Node.js email library with one API for Resend, Brevo, Postmark, SendGrid, Mailgun, Amazon SES, or SMTP.",
|
|
5
5
|
"author": "Erol Senol",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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",
|