typedmailer 1.0.1 → 1.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.
@@ -17,4 +17,4 @@ Harassment, insults, discriminatory language, intimidation, unwelcome sexual att
17
17
 
18
18
  ## Enforcement
19
19
 
20
- Report conduct concerns privately through GitHub's contact/reporting tools for the repository owner. Reports will be reviewed and handled as fairly and promptly as possible. Maintainers may remove content or restrict participation for behavior that violates this code.
20
+ Report conduct concerns privately using [GitHub's contact and reporting tools](https://github.com/contact) for the repository owner. Reports will be reviewed and handled as fairly and promptly as possible. Maintainers may remove content or restrict participation for behavior that violates this code.
package/README.md CHANGED
@@ -17,6 +17,8 @@ TypedMailer is a type-safe email library for Node.js and TypeScript applications
17
17
 
18
18
  Use TypedMailer when you want to switch email providers without coupling application code to a provider SDK. Provider SDKs are optional peer dependencies, and only the selected adapter is loaded.
19
19
 
20
+ The separate `typedmailer/webhooks` entry point verifies and normalizes inbound provider events. It does not provide HTTP routing, persistence, or event processing.
21
+
20
22
  ## Install
21
23
 
22
24
  Install the `typedmailer` npm package with the provider SDK you plan to use:
@@ -45,6 +47,8 @@ TypedMailer is for trusted server-side Node.js runtimes. It is not intended for
45
47
 
46
48
  ## Quick start
47
49
 
50
+ Want to try TypedMailer without provider credentials? Follow the [local Mailpit quick start](docs/quickstart.md).
51
+
48
52
  ```ts
49
53
  import { createMailer } from 'typedmailer';
50
54
 
@@ -54,15 +58,18 @@ const mailer = createMailer({
54
58
  from: 'Example App <noreply@example.com>',
55
59
  });
56
60
 
57
- const result = await mailer.send({
58
- to: 'person@example.com',
59
- subject: 'Welcome',
60
- text: 'Your account is ready.',
61
- html: '<p>Your account is ready.</p>',
62
- });
63
-
64
- console.log(result.messageId);
65
- await mailer.close();
61
+ try {
62
+ const result = await mailer.send({
63
+ to: 'person@example.com',
64
+ subject: 'Welcome',
65
+ text: 'Your account is ready.',
66
+ html: '<p>Your account is ready.</p>',
67
+ });
68
+
69
+ console.log(result.messageId);
70
+ } finally {
71
+ await mailer.close();
72
+ }
66
73
  ```
67
74
 
68
75
  ## Providers
@@ -195,6 +202,10 @@ console.log(mailer.sent[0]);
195
202
 
196
203
  Messages captured by `createTestMailer` return `provider: 'test'` so test results are not mistaken for SMTP deliveries.
197
204
 
205
+ ## Verify provider webhooks
206
+
207
+ 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.
208
+
198
209
  ## Runnable examples
199
210
 
200
211
  The repository includes complete Resend, Amazon SES, and local SMTP/Mailpit examples in [`examples/`](examples/). From a clone, copy `examples/.env.example` to `.env`, replace the example sender and recipient with addresses valid for your account, then install the SDK for the chosen provider:
@@ -221,7 +232,7 @@ Provider and transport failures are normalized as `MailError`, with `code`, `pro
221
232
 
222
233
  `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.
223
234
 
224
- A successful `send()` means the provider accepted the request; it does not confirm inbox delivery. Delivery, bounce, and complaint events require provider webhooks and are outside this package's current scope.
235
+ 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).
225
236
 
226
237
  `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.
227
238
 
@@ -238,6 +249,8 @@ A successful `send()` means the provider accepted the request; it does not confi
238
249
 
239
250
  Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for development and contribution guidelines.
240
251
 
252
+ Contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
253
+
241
254
  After cloning, run `npm ci` to install dependencies and enable the local Git hooks. Commits run staged-file lint and format checks plus unit tests. Pushes run the full `npm run check` quality gate, including an isolated npm tarball consumer smoke test; GitHub Actions runs it on Node.js 22 and 24 and audits dependencies before merge and publish.
242
255
 
243
256
  ## License
package/SECURITY.md CHANGED
@@ -6,4 +6,4 @@ Only the latest published version is currently supported.
6
6
 
7
7
  ## Reporting a vulnerability
8
8
 
9
- Please do not report security vulnerabilities in public issues. Use GitHub's private vulnerability reporting for this repository. Include the affected version, impact, and a minimal reproduction. Do not include real credentials, recipient data, or message contents.
9
+ Please do not report security vulnerabilities in public issues. Use [GitHub's private vulnerability reporting](https://github.com/erolsenol/typedmailer/security/advisories/new) for this repository. Include the affected version, impact, and a minimal reproduction. Do not include real credentials, recipient data, or message contents.
package/SUPPORT.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Runtime support
4
4
 
5
- TypedMailer requires Node.js 22 or newer. CI tests Node.js 22 and 24. We support the latest patch release of each Node.js major that is still in its official maintenance or active LTS period; end-of-life Node.js releases are unsupported even when they satisfy the package engine range.
5
+ TypedMailer requires Node.js 22 or newer. CI tests Node.js 22 and 24; Node.js 26 runs as a non-blocking compatibility canary while it is Current. We support the latest patch release of each Node.js major that is in its official maintenance or active LTS period; end-of-life Node.js releases are unsupported even when they satisfy the package engine range.
6
6
 
7
7
  Install the provider SDK required by your selected adapter. Provider SDKs are optional peer dependencies; see the [provider contracts](docs/provider-contracts.md) and README compatibility table.
8
8
 
@@ -0,0 +1,44 @@
1
+ import type { ProviderName } from './types.js';
2
+ export type EmailWebhookEventType = 'accepted' | 'delivered' | 'bounced' | 'complained' | 'delayed' | 'opened' | 'clicked' | 'unsubscribed' | 'rejected' | 'failed' | 'other';
3
+ export interface EmailWebhookEvent {
4
+ readonly provider: Exclude<ProviderName, 'smtp'>;
5
+ readonly id?: string;
6
+ readonly type: EmailWebhookEventType;
7
+ readonly eventType: string;
8
+ readonly messageId?: string;
9
+ readonly recipient?: string;
10
+ readonly occurredAt?: Date;
11
+ /** Original provider data. It can contain message metadata and personal information. */
12
+ readonly raw: unknown;
13
+ }
14
+ export type WebhookHeaders = Readonly<Record<string, string | readonly string[] | undefined>>;
15
+ interface RawWebhookInput {
16
+ readonly rawBody: string | Uint8Array;
17
+ readonly headers: WebhookHeaders;
18
+ readonly now?: Date;
19
+ readonly toleranceSeconds?: number;
20
+ }
21
+ export type VerifyWebhookInput = (RawWebhookInput & {
22
+ readonly provider: 'resend';
23
+ readonly webhookSecret: string;
24
+ }) | (RawWebhookInput & {
25
+ readonly provider: 'mailgun';
26
+ readonly signingKey: string;
27
+ }) | (RawWebhookInput & {
28
+ readonly provider: 'sendgrid';
29
+ readonly publicKey: string;
30
+ }) | (RawWebhookInput & {
31
+ readonly provider: 'brevo' | 'postmark';
32
+ readonly authorization: string;
33
+ readonly authorizationHeader?: string;
34
+ }) | (RawWebhookInput & {
35
+ readonly provider: 'ses';
36
+ readonly topicArn: string;
37
+ });
38
+ export declare class WebhookVerificationError extends Error {
39
+ readonly code: 'invalid_signature' | 'invalid_payload' | 'unsupported_event';
40
+ constructor(code: 'invalid_signature' | 'invalid_payload' | 'unsupported_event');
41
+ }
42
+ /** Authenticates a provider webhook before returning normalized, typed email events. */
43
+ export declare function verifyWebhook(input: VerifyWebhookInput): Promise<readonly EmailWebhookEvent[]>;
44
+ export {};
@@ -0,0 +1,417 @@
1
+ import { createHmac, createVerify, timingSafeEqual, X509Certificate } from 'node:crypto';
2
+ export class WebhookVerificationError extends Error {
3
+ code;
4
+ constructor(code) {
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;
15
+ /** Authenticates a provider webhook before returning normalized, typed email events. */
16
+ export async function verifyWebhook(input) {
17
+ const rawBody = Buffer.from(input.rawBody);
18
+ let payload;
19
+ try {
20
+ payload = JSON.parse(rawBody.toString('utf8'));
21
+ }
22
+ catch {
23
+ throw new WebhookVerificationError('invalid_payload');
24
+ }
25
+ switch (input.provider) {
26
+ case 'resend':
27
+ verifyResend(input, rawBody);
28
+ return normalizeResend(payload);
29
+ case 'mailgun':
30
+ verifyMailgun(input, payload);
31
+ return normalizeMailgun(payload);
32
+ case 'sendgrid':
33
+ verifySendGrid(input, rawBody);
34
+ return normalizeSendGrid(payload);
35
+ case 'brevo':
36
+ case 'postmark':
37
+ verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
38
+ return normalizeProviderEvents(input.provider, payload);
39
+ case 'ses':
40
+ return normalizeSes(await verifySnsNotification(input, payload));
41
+ }
42
+ }
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
  # Integration smoke workflow
2
2
 
3
- The `Provider integration smoke` workflow runs weekly or manually. It is intentionally separate from pull request CI. It always sends one message to an isolated Mailpit service; this does not contact a real mailbox.
3
+ Pull request CI runs a Mailpit integration job with a pinned service image. It verifies SMTP acceptance and then checks Mailpit's REST API for the unique message, recipient, subject, and body. This does not contact a real mailbox. The separate `Provider integration smoke` workflow also runs weekly or manually and always sends one message to an isolated Mailpit service.
4
4
 
5
5
  The workflow can also send controlled live messages through Resend or Amazon SES. To enable one, set the repository variable `TYPEDMAILER_INTEGRATION_PROVIDERS` to a comma-separated list such as `mailpit,resend` or `mailpit,ses`, then configure the following values:
6
6
 
@@ -0,0 +1,63 @@
1
+ # TypedMailer quick start
2
+
3
+ Send your first message to a local inbox without provider credentials or a real recipient.
4
+
5
+ ## 1. Install TypedMailer and its SMTP adapter
6
+
7
+ ```sh
8
+ npm install typedmailer nodemailer
9
+ ```
10
+
11
+ ## 2. Start Mailpit
12
+
13
+ With Docker:
14
+
15
+ ```sh
16
+ docker run --rm --name typedmailer-mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit:v1.31.3
17
+ ```
18
+
19
+ Or with Homebrew:
20
+
21
+ ```sh
22
+ brew install mailpit
23
+ mailpit
24
+ ```
25
+
26
+ Mailpit captures messages locally. Open its inbox at <http://127.0.0.1:8025>.
27
+
28
+ ## 3. Create `send.mjs`
29
+
30
+ ```js
31
+ import { createMailer } from 'typedmailer';
32
+
33
+ const mailer = createMailer({
34
+ provider: 'smtp',
35
+ host: '127.0.0.1',
36
+ port: 1025,
37
+ secure: false,
38
+ from: 'TypedMailer Demo <sender@example.test>',
39
+ });
40
+
41
+ try {
42
+ await mailer.verifyConnection();
43
+ const result = await mailer.send({
44
+ to: 'reader@example.test',
45
+ subject: 'My first TypedMailer message',
46
+ text: 'This message was captured by local Mailpit.',
47
+ });
48
+
49
+ console.log(`Mailpit accepted ${result.messageId}`);
50
+ } finally {
51
+ await mailer.close();
52
+ }
53
+ ```
54
+
55
+ ## 4. Run it
56
+
57
+ ```sh
58
+ node send.mjs
59
+ ```
60
+
61
+ Check the captured message in Mailpit. Both addresses use the reserved `.test` domain; this setup sends nothing to the public internet.
62
+
63
+ To try a real provider, follow the provider-specific setup in the [README](../README.md#providers). To receive delivery, bounce, and complaint events, follow the [webhook guide](webhooks.md).
@@ -0,0 +1,60 @@
1
+ # Verifying and normalizing email webhooks
2
+
3
+ TypedMailer exposes `verifyWebhook()` from `typedmailer/webhooks`. It authenticates a raw HTTP webhook request before returning normalized event records. It does not start an HTTP server, acknowledge requests, persist event IDs, or perform application actions. Your route owns HTTP responses, durable deduplication, and business logic.
4
+
5
+ ## Raw request bodies
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.
8
+
9
+ ## Authentication configuration
10
+
11
+ | Provider | Verification input | Provider requirements |
12
+ | ---------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | Resend | `webhookSecret` | The endpoint `whsec_...` signing secret; Svix headers are checked and timestamps older than five minutes are rejected by default. |
14
+ | Mailgun | `signingKey` | The account webhook signing key; the signed timestamp and token are checked with HMAC-SHA256 and a five-minute default freshness window. |
15
+ | SendGrid | `publicKey` | The Event Webhook ECDSA public key; signature and timestamp headers are verified against the exact raw body. |
16
+ | Brevo | `authorization` and optional `authorizationHeader` | Configure a custom header or bearer token in Brevo. Brevo does not sign event payloads cryptographically. |
17
+ | Postmark | `authorization` and optional `authorizationHeader` | Configure HTTPS Basic Authentication for the webhook URL. Pass the expected header value, including the `Basic ` prefix, as `authorization`. Postmark does not sign webhook payloads. |
18
+ | Amazon SES | `topicArn` | SNS Notification envelope signatures and expected topic ARN are checked. SNS signing certificates are fetched only from the matching regional SNS HTTPS host. |
19
+
20
+ Resend, Mailgun, and SendGrid reject timestamps outside a five-minute window by default. Set `toleranceSeconds` to change that window or `now` to supply a clock value in deterministic tests. Amazon SNS verification supports `Notification` envelopes; subscription confirmation and unsubscribe envelopes are not handled. SNS verification makes an HTTPS request to retrieve the signing certificate, so account for that outbound request in your network policy.
21
+
22
+ For providers without signed payloads, protect the endpoint with HTTPS and the provider's configured authentication. For Brevo, `authorization` is the exact value configured for its custom authorization header; pass the same header name through `authorizationHeader` when it is not `authorization`. Do not treat an IP allowlist as the only control unless you can keep it current. SMTP has no provider webhook adapter.
23
+
24
+ ## Example: Resend
25
+
26
+ ```ts
27
+ import { verifyWebhook } from 'typedmailer/webhooks';
28
+
29
+ const events = await verifyWebhook({
30
+ provider: 'resend',
31
+ rawBody: requestBody, // exact bytes or string from the incoming request
32
+ headers: requestHeaders,
33
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
34
+ });
35
+
36
+ for (const event of events) {
37
+ if (event.type === 'bounced' && event.messageId) {
38
+ await markMessageBounced(event.messageId);
39
+ }
40
+ }
41
+ ```
42
+
43
+ ## Normalized event shape
44
+
45
+ Each event includes `provider`, provider `eventType`, a normalized `type`, and the original provider payload in `raw`. When available, it also includes a provider event ID (`id`), message ID, recipient, and event timestamp (`occurredAt`). `raw` may contain personal data and provider metadata; avoid logging it without review. Unknown provider event names map to `type: 'other'` and retain their original `eventType`.
46
+
47
+ Normalized types are `accepted`, `delivered`, `bounced`, `complained`, `delayed`, `opened`, `clicked`, `unsubscribed`, `rejected`, `failed`, and `other`.
48
+
49
+ ## Delivery, retries, and deduplication
50
+
51
+ Providers retry webhook delivery. `verifyWebhook()` verifies authenticity but does not prevent the same authentic event from being processed twice. Persist event IDs (or a stable provider/message/event key when the provider has no event ID) with a unique constraint before applying side effects. Acknowledge only after durable acceptance by your application. Do not log secrets or full event payloads by default.
52
+
53
+ ## Provider references
54
+
55
+ - [Resend webhook verification](https://resend.com/docs/webhooks/introduction)
56
+ - [Mailgun securing webhooks](https://documentation.mailgun.com/docs/mailgun/user-manual/webhooks/securing-webhooks)
57
+ - [SendGrid signed Event Webhooks](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features)
58
+ - [Brevo secure webhook calls](https://developers.brevo.com/docs/secured-webhooks)
59
+ - [Postmark webhooks](https://postmarkapp.com/developer/webhooks/webhooks-overview)
60
+ - [Amazon SNS signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typedmailer",
3
- "version": "1.0.1",
3
+ "version": "1.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",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/testing.d.ts",
20
20
  "import": "./dist/testing.js"
21
21
  },
22
+ "./webhooks": {
23
+ "types": "./dist/webhooks.d.ts",
24
+ "import": "./dist/webhooks.js"
25
+ },
22
26
  "./package.json": "./package.json"
23
27
  },
24
28
  "files": [
@@ -30,6 +34,8 @@
30
34
  "SUPPORT.md",
31
35
  "docs/provider-contracts.md",
32
36
  "docs/integration-testing.md",
37
+ "docs/quickstart.md",
38
+ "docs/webhooks.md",
33
39
  "CODE_OF_CONDUCT.md"
34
40
  ],
35
41
  "scripts": {
@@ -99,8 +105,8 @@
99
105
  "@aws-sdk/client-sesv2": ">=3.0.0 <4",
100
106
  "@getbrevo/brevo": ">=6.0.1 <7",
101
107
  "@sendgrid/mail": ">=8.0.0 <9",
102
- "@types/node": "^24.5.2",
103
- "@types/nodemailer": "^7.0.1",
108
+ "@types/node": "^26.6.3",
109
+ "@types/nodemailer": "^8.0.2",
104
110
  "eslint": "^10.11.0",
105
111
  "form-data": ">=4.0.0 <5",
106
112
  "husky": "^9.1.7",