typedmailer 1.1.0 → 1.3.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 +27 -1
- package/dist/config.d.ts +3 -8
- package/dist/config.js +3 -2
- package/dist/index.d.ts +7 -75
- package/dist/index.js +56 -118
- package/dist/providers/registry.d.ts +158 -0
- package/dist/providers/registry.js +109 -0
- package/dist/testing.d.ts +1 -1
- package/dist/types.d.ts +16 -5
- package/dist/webhooks/normalize.d.ts +6 -0
- package/dist/webhooks/normalize.js +154 -0
- package/dist/webhooks/shared.d.ts +14 -0
- package/dist/webhooks/shared.js +69 -0
- package/dist/webhooks/signatures.d.ts +16 -0
- package/dist/webhooks/signatures.js +159 -0
- package/dist/webhooks/types.d.ts +44 -0
- package/dist/webhooks/types.js +13 -0
- package/dist/webhooks.d.ts +3 -42
- package/dist/webhooks.js +9 -389
- package/docs/provider-contracts.md +7 -3
- package/docs/webhooks.md +1 -1
- package/package.json +3 -2
package/dist/webhooks.js
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
super(code === 'invalid_signature'
|
|
6
|
-
? 'The webhook request could not be authenticated.'
|
|
7
|
-
: code === 'unsupported_event'
|
|
8
|
-
? 'The provider webhook event is not supported.'
|
|
9
|
-
: 'The provider webhook payload is invalid.');
|
|
10
|
-
this.code = code;
|
|
11
|
-
this.name = 'WebhookVerificationError';
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
const defaultTimestampToleranceSeconds = 300;
|
|
1
|
+
import { WebhookVerificationError } from './webhooks/types.js';
|
|
2
|
+
import { verifyAuthorization, verifyMailgun, verifyResend, verifySendGrid, verifySnsNotification, } from './webhooks/signatures.js';
|
|
3
|
+
import { normalizeMailgun, normalizeProviderEvents, normalizeResend, normalizeSendGrid, normalizeSes, } from './webhooks/normalize.js';
|
|
4
|
+
export { WebhookVerificationError } from './webhooks/types.js';
|
|
15
5
|
/** Authenticates a provider webhook before returning normalized, typed email events. */
|
|
16
6
|
export async function verifyWebhook(input) {
|
|
7
|
+
const rawBodyByteLength = typeof input.rawBody === 'string' ? Buffer.byteLength(input.rawBody) : input.rawBody.byteLength;
|
|
8
|
+
if (input.maxBodyBytes !== undefined &&
|
|
9
|
+
(!Number.isSafeInteger(input.maxBodyBytes) || input.maxBodyBytes < 1 || rawBodyByteLength > input.maxBodyBytes)) {
|
|
10
|
+
throw new WebhookVerificationError('invalid_payload');
|
|
11
|
+
}
|
|
17
12
|
const rawBody = Buffer.from(input.rawBody);
|
|
18
13
|
let payload;
|
|
19
14
|
try {
|
|
@@ -40,378 +35,3 @@ export async function verifyWebhook(input) {
|
|
|
40
35
|
return normalizeSes(await verifySnsNotification(input, payload));
|
|
41
36
|
}
|
|
42
37
|
}
|
|
43
|
-
function verifyResend(input, rawBody) {
|
|
44
|
-
const messageId = getHeader(input.headers, 'svix-id');
|
|
45
|
-
const timestamp = getHeader(input.headers, 'svix-timestamp');
|
|
46
|
-
const signatures = getHeader(input.headers, 'svix-signature');
|
|
47
|
-
assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
|
|
48
|
-
if (!messageId || !signatures || !input.webhookSecret.startsWith('whsec_')) {
|
|
49
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
50
|
-
}
|
|
51
|
-
let key;
|
|
52
|
-
try {
|
|
53
|
-
key = Buffer.from(input.webhookSecret.slice('whsec_'.length), 'base64');
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
57
|
-
}
|
|
58
|
-
if (key.length === 0)
|
|
59
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
60
|
-
const expected = createHmac('sha256', key).update(`${messageId}.${timestamp}.`).update(rawBody).digest();
|
|
61
|
-
const matches = signatures
|
|
62
|
-
.split(' ')
|
|
63
|
-
.some((item) => item.startsWith('v1,') && safeEqual(expected, decodeBase64(item.slice(3))));
|
|
64
|
-
if (!matches)
|
|
65
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
66
|
-
}
|
|
67
|
-
function verifyMailgun(input, payload) {
|
|
68
|
-
const root = asRecord(payload);
|
|
69
|
-
const signature = asRecord(root.signature);
|
|
70
|
-
const timestamp = asString(signature.timestamp);
|
|
71
|
-
const token = asString(signature.token);
|
|
72
|
-
const received = asString(signature.signature);
|
|
73
|
-
assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
|
|
74
|
-
if (!timestamp || !token || !received || !input.signingKey) {
|
|
75
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
76
|
-
}
|
|
77
|
-
const expected = createHmac('sha256', input.signingKey).update(`${timestamp}${token}`).digest('hex');
|
|
78
|
-
if (!safeEqual(Buffer.from(expected, 'hex'), decodeHex(received))) {
|
|
79
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function verifySendGrid(input, rawBody) {
|
|
83
|
-
const timestamp = getHeader(input.headers, 'x-twilio-email-event-webhook-timestamp');
|
|
84
|
-
const signature = getHeader(input.headers, 'x-twilio-email-event-webhook-signature');
|
|
85
|
-
assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
|
|
86
|
-
if (!timestamp || !signature || !input.publicKey)
|
|
87
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
88
|
-
try {
|
|
89
|
-
const verifier = createVerify('sha256');
|
|
90
|
-
verifier.update(timestamp);
|
|
91
|
-
verifier.update(rawBody);
|
|
92
|
-
verifier.end();
|
|
93
|
-
if (!verifier.verify(input.publicKey, signature, 'base64')) {
|
|
94
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
if (error instanceof WebhookVerificationError)
|
|
99
|
-
throw error;
|
|
100
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
function verifyAuthorization(input, headerName) {
|
|
104
|
-
const received = getHeader(input.headers, headerName);
|
|
105
|
-
if (!received || !input.authorization || !safeEqual(Buffer.from(received), Buffer.from(input.authorization))) {
|
|
106
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
async function verifySnsNotification(input, payload) {
|
|
110
|
-
const envelope = asRecord(payload);
|
|
111
|
-
const topicArn = asString(envelope.TopicArn);
|
|
112
|
-
const signingCertUrl = asString(envelope.SigningCertURL);
|
|
113
|
-
const signature = asString(envelope.Signature);
|
|
114
|
-
const signatureVersion = asString(envelope.SignatureVersion);
|
|
115
|
-
if (envelope.Type !== 'Notification' ||
|
|
116
|
-
topicArn !== input.topicArn ||
|
|
117
|
-
!signingCertUrl ||
|
|
118
|
-
!signature ||
|
|
119
|
-
(signatureVersion !== '1' && signatureVersion !== '2')) {
|
|
120
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
121
|
-
}
|
|
122
|
-
let certificateUrl;
|
|
123
|
-
try {
|
|
124
|
-
certificateUrl = new URL(signingCertUrl);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
128
|
-
}
|
|
129
|
-
const topicMatch = /^arn:(aws|aws-us-gov|aws-cn):sns:([a-z0-9-]+):\d{12}:[^:]+$/.exec(input.topicArn);
|
|
130
|
-
const expectedHost = topicMatch
|
|
131
|
-
? topicMatch[1] === 'aws-cn'
|
|
132
|
-
? `sns.${topicMatch[2]}.amazonaws.com.cn`
|
|
133
|
-
: `sns.${topicMatch[2]}.amazonaws.com`
|
|
134
|
-
: undefined;
|
|
135
|
-
if (certificateUrl.protocol !== 'https:' ||
|
|
136
|
-
certificateUrl.username ||
|
|
137
|
-
certificateUrl.password ||
|
|
138
|
-
certificateUrl.port ||
|
|
139
|
-
certificateUrl.search ||
|
|
140
|
-
certificateUrl.hash ||
|
|
141
|
-
certificateUrl.hostname !== expectedHost ||
|
|
142
|
-
!/^\/SimpleNotificationService-[A-Za-z0-9_-]+\.pem$/.test(certificateUrl.pathname)) {
|
|
143
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
144
|
-
}
|
|
145
|
-
let response;
|
|
146
|
-
try {
|
|
147
|
-
response = await fetch(certificateUrl, { redirect: 'error', signal: AbortSignal.timeout(5_000) });
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
151
|
-
}
|
|
152
|
-
if (!response.ok)
|
|
153
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
154
|
-
let certificateBody;
|
|
155
|
-
try {
|
|
156
|
-
certificateBody = await response.text();
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
160
|
-
}
|
|
161
|
-
if (certificateBody.length > 32_768)
|
|
162
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
163
|
-
try {
|
|
164
|
-
const certificate = new X509Certificate(certificateBody);
|
|
165
|
-
const now = input.now ?? new Date();
|
|
166
|
-
if (!certificate.subject.split('\n').some((line) => line === 'CN=Amazon SNS') ||
|
|
167
|
-
now < new Date(certificate.validFrom) ||
|
|
168
|
-
now > new Date(certificate.validTo)) {
|
|
169
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
170
|
-
}
|
|
171
|
-
const fields = snsFields(envelope);
|
|
172
|
-
const verifier = createVerify(signatureVersion === '2' ? 'sha256' : 'sha1');
|
|
173
|
-
verifier.update(fields);
|
|
174
|
-
verifier.end();
|
|
175
|
-
if (!verifier.verify(certificate.publicKey, signature, 'base64')) {
|
|
176
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
if (error instanceof WebhookVerificationError)
|
|
181
|
-
throw error;
|
|
182
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
const message = JSON.parse(asString(envelope.Message) ?? '');
|
|
186
|
-
return message;
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
throw new WebhookVerificationError('invalid_payload');
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
function snsFields(envelope) {
|
|
193
|
-
const fields = ['Message', 'MessageId'];
|
|
194
|
-
if (envelope.Subject !== undefined)
|
|
195
|
-
fields.push('Subject');
|
|
196
|
-
fields.push('Timestamp', 'TopicArn', 'Type');
|
|
197
|
-
return fields.map((field) => `${field}\n${asString(envelope[field]) ?? ''}\n`).join('');
|
|
198
|
-
}
|
|
199
|
-
function normalizeResend(payload) {
|
|
200
|
-
const root = asRecord(payload);
|
|
201
|
-
const data = asRecord(root.data);
|
|
202
|
-
return [
|
|
203
|
-
makeEvent('resend', root, {
|
|
204
|
-
id: asString(root.id),
|
|
205
|
-
eventType: asString(root.type),
|
|
206
|
-
type: mapEventType(asString(root.type)),
|
|
207
|
-
messageId: asString(data.email_id),
|
|
208
|
-
recipient: firstString(data.to),
|
|
209
|
-
occurredAt: parseDate(root.created_at),
|
|
210
|
-
}),
|
|
211
|
-
];
|
|
212
|
-
}
|
|
213
|
-
function normalizeMailgun(payload) {
|
|
214
|
-
const root = asRecord(payload);
|
|
215
|
-
const event = asRecord(root['event-data']);
|
|
216
|
-
const message = asRecord(event.message);
|
|
217
|
-
const headers = asRecord(message.headers);
|
|
218
|
-
const eventName = asString(event.event);
|
|
219
|
-
return [
|
|
220
|
-
makeEvent('mailgun', event, {
|
|
221
|
-
id: asId(event.id),
|
|
222
|
-
eventType: eventName,
|
|
223
|
-
type: mapEventType(eventName),
|
|
224
|
-
messageId: asString(headers['message-id']),
|
|
225
|
-
recipient: asString(event.recipient),
|
|
226
|
-
occurredAt: parseDate(event.timestamp),
|
|
227
|
-
}),
|
|
228
|
-
];
|
|
229
|
-
}
|
|
230
|
-
function normalizeSendGrid(payload) {
|
|
231
|
-
if (!Array.isArray(payload))
|
|
232
|
-
throw new WebhookVerificationError('invalid_payload');
|
|
233
|
-
return payload.map((item) => {
|
|
234
|
-
const event = asRecord(item);
|
|
235
|
-
const eventName = asString(event.event);
|
|
236
|
-
return makeEvent('sendgrid', event, {
|
|
237
|
-
id: asString(event.sg_event_id),
|
|
238
|
-
eventType: eventName,
|
|
239
|
-
type: mapEventType(eventName),
|
|
240
|
-
messageId: asString(event.sg_message_id),
|
|
241
|
-
recipient: asString(event.email),
|
|
242
|
-
occurredAt: parseDate(event.timestamp),
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
function normalizeSes(payload) {
|
|
247
|
-
const message = asRecord(payload);
|
|
248
|
-
const eventName = asString(message.eventType) ?? asString(message.notificationType);
|
|
249
|
-
const mail = asRecord(message.mail);
|
|
250
|
-
const bounce = asOptionalRecord(message.bounce);
|
|
251
|
-
const complaint = asOptionalRecord(message.complaint);
|
|
252
|
-
const delivery = asOptionalRecord(message.delivery);
|
|
253
|
-
const recipients = Array.isArray(bounce?.bouncedRecipients)
|
|
254
|
-
? bounce.bouncedRecipients
|
|
255
|
-
: Array.isArray(complaint?.complainedRecipients)
|
|
256
|
-
? complaint.complainedRecipients
|
|
257
|
-
: [];
|
|
258
|
-
const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
|
|
259
|
-
const emails = to.length > 0 ? to : asStringArray(mail.destination);
|
|
260
|
-
return emails.length > 0
|
|
261
|
-
? emails.map((recipient) => makeEvent('ses', message, {
|
|
262
|
-
id: asString(message.messageId),
|
|
263
|
-
eventType: eventName,
|
|
264
|
-
type: mapEventType(eventName),
|
|
265
|
-
messageId: asString(mail.messageId),
|
|
266
|
-
recipient,
|
|
267
|
-
occurredAt: parseDate(delivery?.timestamp) ?? parseDate(bounce?.timestamp),
|
|
268
|
-
}))
|
|
269
|
-
: [
|
|
270
|
-
makeEvent('ses', message, {
|
|
271
|
-
id: asString(message.messageId),
|
|
272
|
-
eventType: eventName,
|
|
273
|
-
type: mapEventType(eventName),
|
|
274
|
-
messageId: asString(mail.messageId),
|
|
275
|
-
occurredAt: parseDate(asRecord(message.delivery).timestamp),
|
|
276
|
-
}),
|
|
277
|
-
];
|
|
278
|
-
}
|
|
279
|
-
function normalizeProviderEvents(provider, payload) {
|
|
280
|
-
const items = Array.isArray(payload) ? payload : [payload];
|
|
281
|
-
return items.map((item) => {
|
|
282
|
-
const event = asRecord(item);
|
|
283
|
-
const eventName = provider === 'brevo' ? asString(event.event) : asString(event.RecordType);
|
|
284
|
-
return makeEvent(provider, event, {
|
|
285
|
-
id: provider === 'brevo'
|
|
286
|
-
? (asId(event.id) ?? asString(event['message-id']))
|
|
287
|
-
: (asId(event.ID) ?? asString(event.MessageID)),
|
|
288
|
-
eventType: eventName,
|
|
289
|
-
type: mapEventType(eventName),
|
|
290
|
-
messageId: provider === 'brevo' ? asString(event['message-id']) : asString(event.MessageID),
|
|
291
|
-
recipient: provider === 'brevo' ? asString(event.email) : asString(event.Recipient),
|
|
292
|
-
occurredAt: parseDate(provider === 'brevo' ? (event.ts_epoch ?? event.date) : (event.DeliveredAt ?? event.BouncedAt)),
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
function makeEvent(provider, raw, fields) {
|
|
297
|
-
if (!fields.eventType)
|
|
298
|
-
throw new WebhookVerificationError('invalid_payload');
|
|
299
|
-
return {
|
|
300
|
-
provider,
|
|
301
|
-
raw,
|
|
302
|
-
eventType: fields.eventType,
|
|
303
|
-
type: fields.type,
|
|
304
|
-
...(fields.id ? { id: fields.id } : {}),
|
|
305
|
-
...(fields.messageId ? { messageId: fields.messageId } : {}),
|
|
306
|
-
...(fields.recipient ? { recipient: fields.recipient } : {}),
|
|
307
|
-
...(fields.occurredAt ? { occurredAt: fields.occurredAt } : {}),
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
function mapEventType(eventType) {
|
|
311
|
-
const event = eventType?.toLowerCase().replaceAll(/[._ -]/g, '') ?? '';
|
|
312
|
-
const mappings = {
|
|
313
|
-
accepted: 'accepted',
|
|
314
|
-
sent: 'accepted',
|
|
315
|
-
request: 'accepted',
|
|
316
|
-
emailsent: 'accepted',
|
|
317
|
-
delivery: 'delivered',
|
|
318
|
-
delivered: 'delivered',
|
|
319
|
-
emaildelivered: 'delivered',
|
|
320
|
-
bounce: 'bounced',
|
|
321
|
-
bounced: 'bounced',
|
|
322
|
-
emailbounced: 'bounced',
|
|
323
|
-
hardbounce: 'bounced',
|
|
324
|
-
softbounce: 'bounced',
|
|
325
|
-
spamcomplaint: 'complained',
|
|
326
|
-
complaint: 'complained',
|
|
327
|
-
complained: 'complained',
|
|
328
|
-
emailcomplained: 'complained',
|
|
329
|
-
spamreport: 'complained',
|
|
330
|
-
deferred: 'delayed',
|
|
331
|
-
deliverydelayed: 'delayed',
|
|
332
|
-
deliverydelay: 'delayed',
|
|
333
|
-
emaildeliverydelayed: 'delayed',
|
|
334
|
-
open: 'opened',
|
|
335
|
-
opened: 'opened',
|
|
336
|
-
emailopened: 'opened',
|
|
337
|
-
click: 'clicked',
|
|
338
|
-
clicked: 'clicked',
|
|
339
|
-
emailclicked: 'clicked',
|
|
340
|
-
unsubscribed: 'unsubscribed',
|
|
341
|
-
groupunsubscribe: 'unsubscribed',
|
|
342
|
-
reject: 'rejected',
|
|
343
|
-
rejected: 'rejected',
|
|
344
|
-
dropped: 'failed',
|
|
345
|
-
failed: 'failed',
|
|
346
|
-
emailfailed: 'failed',
|
|
347
|
-
renderingfailure: 'failed',
|
|
348
|
-
};
|
|
349
|
-
return mappings[event] ?? 'other';
|
|
350
|
-
}
|
|
351
|
-
function assertRecentTimestamp(value, now, toleranceSeconds) {
|
|
352
|
-
if (!value || !/^\d+$/.test(value))
|
|
353
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
354
|
-
const timestamp = Number(value);
|
|
355
|
-
const tolerance = toleranceSeconds ?? defaultTimestampToleranceSeconds;
|
|
356
|
-
if (!Number.isSafeInteger(timestamp) || !Number.isSafeInteger(tolerance) || tolerance < 1) {
|
|
357
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
358
|
-
}
|
|
359
|
-
const seconds = Math.floor((now ?? new Date()).getTime() / 1000);
|
|
360
|
-
if (Math.abs(seconds - timestamp) > tolerance)
|
|
361
|
-
throw new WebhookVerificationError('invalid_signature');
|
|
362
|
-
}
|
|
363
|
-
function getHeader(headers, name) {
|
|
364
|
-
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
365
|
-
return typeof entry === 'string' ? entry : Array.isArray(entry) ? entry.join(' ') : undefined;
|
|
366
|
-
}
|
|
367
|
-
function safeEqual(left, right) {
|
|
368
|
-
return left.byteLength === right.byteLength && timingSafeEqual(left, right);
|
|
369
|
-
}
|
|
370
|
-
function decodeBase64(value) {
|
|
371
|
-
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value))
|
|
372
|
-
return Buffer.alloc(0);
|
|
373
|
-
return Buffer.from(value, 'base64');
|
|
374
|
-
}
|
|
375
|
-
function decodeHex(value) {
|
|
376
|
-
if (!/^(?:[a-fA-F0-9]{2})+$/.test(value))
|
|
377
|
-
return Buffer.alloc(0);
|
|
378
|
-
return Buffer.from(value, 'hex');
|
|
379
|
-
}
|
|
380
|
-
function parseDate(value) {
|
|
381
|
-
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
382
|
-
const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
|
|
383
|
-
const result = new Date(milliseconds);
|
|
384
|
-
return Number.isNaN(result.getTime()) ? undefined : result;
|
|
385
|
-
}
|
|
386
|
-
if (typeof value === 'string' && value.length > 0) {
|
|
387
|
-
const result = new Date(value);
|
|
388
|
-
return Number.isNaN(result.getTime()) ? undefined : result;
|
|
389
|
-
}
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
function asRecord(value) {
|
|
393
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
394
|
-
throw new WebhookVerificationError('invalid_payload');
|
|
395
|
-
}
|
|
396
|
-
return value;
|
|
397
|
-
}
|
|
398
|
-
function asOptionalRecord(value) {
|
|
399
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
400
|
-
? value
|
|
401
|
-
: undefined;
|
|
402
|
-
}
|
|
403
|
-
function asString(value) {
|
|
404
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
405
|
-
}
|
|
406
|
-
function asId(value) {
|
|
407
|
-
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined;
|
|
408
|
-
}
|
|
409
|
-
function isString(value) {
|
|
410
|
-
return typeof value === 'string';
|
|
411
|
-
}
|
|
412
|
-
function asStringArray(value) {
|
|
413
|
-
return Array.isArray(value) ? value.filter(isString) : [];
|
|
414
|
-
}
|
|
415
|
-
function firstString(value) {
|
|
416
|
-
return asString(value) ?? asStringArray(value)[0];
|
|
417
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Provider behavior contracts
|
|
2
2
|
|
|
3
|
-
The adapters provide one `createMailer(...).send(...)` interface, while provider APIs differ. This page records the behavior callers may rely on. The capability table in the README is the quick reference; adapter contract tests
|
|
3
|
+
The adapters provide one `createMailer(...).send(...)` interface, while provider APIs differ. This page records the behavior callers may rely on. The capability table in the README is the quick reference; built-in adapter contract tests are organized by provider under `tests/providers/`.
|
|
4
4
|
|
|
5
5
|
## Shared guarantees
|
|
6
6
|
|
|
@@ -23,8 +23,12 @@ The adapters provide one `createMailer(...).send(...)` interface, while provider
|
|
|
23
23
|
| Amazon SES | No | No | Yes | Yes | Unsupported |
|
|
24
24
|
| SMTP | Yes | No | No | Yes | SMTP connection check |
|
|
25
25
|
|
|
26
|
-
Attachments are buffered for provider SDK requests. Large-file streaming belongs in the application. `contentId` is supported by the providers listed as supporting inline attachments. `verifyConnection()` is only available for SMTP because the API providers do not offer a side-effect-free check through this interface.
|
|
26
|
+
Attachments are buffered for provider SDK requests. Configure `maxAttachmentBytes` on `createMailer()` to enforce an aggregate byte cap over all attachment content; strings are counted as UTF-8 bytes and `Uint8Array` content by `byteLength`. The option has no default cap to preserve existing behavior. Large-file streaming belongs in the application. `contentId` is supported by the providers listed as supporting inline attachments. `verifyConnection()` is only available for SMTP because the API providers do not offer a side-effect-free check through this interface.
|
|
27
27
|
|
|
28
28
|
## Updating a provider adapter
|
|
29
29
|
|
|
30
|
-
When changing an adapter, update the README capability table and this contract if caller-visible behavior changes. Add or adjust adapter tests for the provider payload, unsupported fields, returned message ID, and error normalization. Keep provider SDK versions within the declared peer range and verify both the locked SDK and minimum supported SDK set.
|
|
30
|
+
When changing an adapter, add its configuration schema and lazy loader in `src/providers/registry.ts`, keep its implementation in `src/providers/<provider>.ts`, and update the README capability table and this contract if caller-visible behavior changes. Add or adjust adapter tests for the provider payload, unsupported fields, returned message ID, and error normalization. Keep provider SDK versions within the declared peer range and verify both the locked SDK and minimum supported SDK set. CI derives the minimum-version install list from each peer range's explicit lower bound so the support metadata and compatibility check stay aligned.
|
|
31
|
+
|
|
32
|
+
## Custom adapters
|
|
33
|
+
|
|
34
|
+
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.
|
package/docs/webhooks.md
CHANGED
|
@@ -4,7 +4,7 @@ TypedMailer exposes `verifyWebhook()` from `typedmailer/webhooks`. It authentica
|
|
|
4
4
|
|
|
5
5
|
## Raw request bodies
|
|
6
6
|
|
|
7
|
-
Pass the exact bytes received by your HTTP framework. Parsing JSON and serializing it again changes the signed input and causes verification to fail. `rawBody` accepts a string or `Uint8Array`; `headers` is a case-insensitive record of request header names and values. If the framework parses request bodies automatically, configure a raw-body capture before its JSON parser.
|
|
7
|
+
Pass the exact bytes received by your HTTP framework. Parsing JSON and serializing it again changes the signed input and causes verification to fail. `rawBody` accepts a string or `Uint8Array`; `headers` is a case-insensitive record of request header names and values. If the framework parses request bodies automatically, configure a raw-body capture before its JSON parser. Apply an HTTP request-body size limit before buffering the body. You can also set `maxBodyBytes` on `verifyWebhook()` to reject an oversized payload before JSON parsing; it has no default because provider event batches vary in size.
|
|
8
8
|
|
|
9
9
|
## Authentication configuration
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typedmailer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"format:check": "prettier --check .",
|
|
46
46
|
"test": "vitest run",
|
|
47
47
|
"release:check": "node scripts/release-preflight.mjs",
|
|
48
|
+
"provider:peer-floor": "node scripts/check-provider-peer-floor.mjs",
|
|
48
49
|
"examples:check": "node --check examples/resend.mjs && node --check examples/ses.mjs && node --check examples/smtp-mailpit.mjs",
|
|
49
50
|
"security:audit": "npm audit --audit-level=high",
|
|
50
51
|
"package:smoke": "node scripts/package-smoke.mjs",
|
|
@@ -116,7 +117,7 @@
|
|
|
116
117
|
"postmark": ">=5.0.0 <6",
|
|
117
118
|
"prettier": "^3.9.9",
|
|
118
119
|
"resend": ">=6.0.0 <7",
|
|
119
|
-
"typescript": "^
|
|
120
|
+
"typescript": "^6.0.3",
|
|
120
121
|
"typescript-eslint": "^8.70.1",
|
|
121
122
|
"vitest": "^5.0.2"
|
|
122
123
|
},
|