typedmailer 1.0.1 → 1.2.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.
@@ -0,0 +1,154 @@
1
+ import { WebhookVerificationError } from './types.js';
2
+ import { asId, asOptionalRecord, asRecord, asString, asStringArray, firstString, isString, parseDate, } from './shared.js';
3
+ export function normalizeResend(payload) {
4
+ const root = asRecord(payload);
5
+ const data = asRecord(root.data);
6
+ return [
7
+ makeEvent('resend', root, {
8
+ id: asString(root.id),
9
+ eventType: asString(root.type),
10
+ type: mapEventType(asString(root.type)),
11
+ messageId: asString(data.email_id),
12
+ recipient: firstString(data.to),
13
+ occurredAt: parseDate(root.created_at),
14
+ }),
15
+ ];
16
+ }
17
+ export function normalizeMailgun(payload) {
18
+ const root = asRecord(payload);
19
+ const event = asRecord(root['event-data']);
20
+ const message = asRecord(event.message);
21
+ const headers = asRecord(message.headers);
22
+ const eventName = asString(event.event);
23
+ return [
24
+ makeEvent('mailgun', event, {
25
+ id: asId(event.id),
26
+ eventType: eventName,
27
+ type: mapEventType(eventName),
28
+ messageId: asString(headers['message-id']),
29
+ recipient: asString(event.recipient),
30
+ occurredAt: parseDate(event.timestamp),
31
+ }),
32
+ ];
33
+ }
34
+ export function normalizeSendGrid(payload) {
35
+ if (!Array.isArray(payload))
36
+ throw new WebhookVerificationError('invalid_payload');
37
+ return payload.map((item) => {
38
+ const event = asRecord(item);
39
+ const eventName = asString(event.event);
40
+ return makeEvent('sendgrid', event, {
41
+ id: asString(event.sg_event_id),
42
+ eventType: eventName,
43
+ type: mapEventType(eventName),
44
+ messageId: asString(event.sg_message_id),
45
+ recipient: asString(event.email),
46
+ occurredAt: parseDate(event.timestamp),
47
+ });
48
+ });
49
+ }
50
+ export function normalizeSes(payload) {
51
+ const message = asRecord(payload);
52
+ const eventName = asString(message.eventType) ?? asString(message.notificationType);
53
+ const mail = asRecord(message.mail);
54
+ const bounce = asOptionalRecord(message.bounce);
55
+ const complaint = asOptionalRecord(message.complaint);
56
+ const delivery = asOptionalRecord(message.delivery);
57
+ const recipients = Array.isArray(bounce?.bouncedRecipients)
58
+ ? bounce.bouncedRecipients
59
+ : Array.isArray(complaint?.complainedRecipients)
60
+ ? complaint.complainedRecipients
61
+ : [];
62
+ const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
63
+ const emails = to.length > 0 ? to : asStringArray(mail.destination);
64
+ return emails.length > 0
65
+ ? emails.map((recipient) => makeEvent('ses', message, {
66
+ id: asString(message.messageId),
67
+ eventType: eventName,
68
+ type: mapEventType(eventName),
69
+ messageId: asString(mail.messageId),
70
+ recipient,
71
+ occurredAt: parseDate(delivery?.timestamp) ?? parseDate(bounce?.timestamp),
72
+ }))
73
+ : [
74
+ makeEvent('ses', message, {
75
+ id: asString(message.messageId),
76
+ eventType: eventName,
77
+ type: mapEventType(eventName),
78
+ messageId: asString(mail.messageId),
79
+ occurredAt: parseDate(asRecord(message.delivery).timestamp),
80
+ }),
81
+ ];
82
+ }
83
+ export function normalizeProviderEvents(provider, payload) {
84
+ const items = Array.isArray(payload) ? payload : [payload];
85
+ return items.map((item) => {
86
+ const event = asRecord(item);
87
+ const eventName = provider === 'brevo' ? asString(event.event) : asString(event.RecordType);
88
+ return makeEvent(provider, event, {
89
+ id: provider === 'brevo'
90
+ ? (asId(event.id) ?? asString(event['message-id']))
91
+ : (asId(event.ID) ?? asString(event.MessageID)),
92
+ eventType: eventName,
93
+ type: mapEventType(eventName),
94
+ messageId: provider === 'brevo' ? asString(event['message-id']) : asString(event.MessageID),
95
+ recipient: provider === 'brevo' ? asString(event.email) : asString(event.Recipient),
96
+ occurredAt: parseDate(provider === 'brevo' ? (event.ts_epoch ?? event.date) : (event.DeliveredAt ?? event.BouncedAt)),
97
+ });
98
+ });
99
+ }
100
+ function makeEvent(provider, raw, fields) {
101
+ if (!fields.eventType)
102
+ throw new WebhookVerificationError('invalid_payload');
103
+ return {
104
+ provider,
105
+ raw,
106
+ eventType: fields.eventType,
107
+ type: fields.type,
108
+ ...(fields.id ? { id: fields.id } : {}),
109
+ ...(fields.messageId ? { messageId: fields.messageId } : {}),
110
+ ...(fields.recipient ? { recipient: fields.recipient } : {}),
111
+ ...(fields.occurredAt ? { occurredAt: fields.occurredAt } : {}),
112
+ };
113
+ }
114
+ function mapEventType(eventType) {
115
+ const event = eventType?.toLowerCase().replaceAll(/[._ -]/g, '') ?? '';
116
+ const mappings = {
117
+ accepted: 'accepted',
118
+ sent: 'accepted',
119
+ request: 'accepted',
120
+ emailsent: 'accepted',
121
+ delivery: 'delivered',
122
+ delivered: 'delivered',
123
+ emaildelivered: 'delivered',
124
+ bounce: 'bounced',
125
+ bounced: 'bounced',
126
+ emailbounced: 'bounced',
127
+ hardbounce: 'bounced',
128
+ softbounce: 'bounced',
129
+ spamcomplaint: 'complained',
130
+ complaint: 'complained',
131
+ complained: 'complained',
132
+ emailcomplained: 'complained',
133
+ spamreport: 'complained',
134
+ deferred: 'delayed',
135
+ deliverydelayed: 'delayed',
136
+ deliverydelay: 'delayed',
137
+ emaildeliverydelayed: 'delayed',
138
+ open: 'opened',
139
+ opened: 'opened',
140
+ emailopened: 'opened',
141
+ click: 'clicked',
142
+ clicked: 'clicked',
143
+ emailclicked: 'clicked',
144
+ unsubscribed: 'unsubscribed',
145
+ groupunsubscribe: 'unsubscribed',
146
+ reject: 'rejected',
147
+ rejected: 'rejected',
148
+ dropped: 'failed',
149
+ failed: 'failed',
150
+ emailfailed: 'failed',
151
+ renderingfailure: 'failed',
152
+ };
153
+ return mappings[event] ?? 'other';
154
+ }
@@ -0,0 +1,14 @@
1
+ import type { WebhookHeaders } from './types.js';
2
+ export declare function assertRecentTimestamp(value: string | undefined, now: Date | undefined, toleranceSeconds: number | undefined): void;
3
+ export declare function getHeader(headers: WebhookHeaders, name: string): string | undefined;
4
+ export declare function safeEqual(left: Uint8Array, right: Uint8Array): boolean;
5
+ export declare function decodeBase64(value: string): Buffer;
6
+ export declare function decodeHex(value: string): Buffer;
7
+ export declare function parseDate(value: unknown): Date | undefined;
8
+ export declare function asRecord(value: unknown): Record<string, unknown>;
9
+ export declare function asOptionalRecord(value: unknown): Record<string, unknown> | undefined;
10
+ export declare function asString(value: unknown): string | undefined;
11
+ export declare function asId(value: unknown): string | undefined;
12
+ export declare function isString(value: unknown): value is string;
13
+ export declare function asStringArray(value: unknown): string[];
14
+ export declare function firstString(value: unknown): string | undefined;
@@ -0,0 +1,69 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { WebhookVerificationError, defaultTimestampToleranceSeconds } from './types.js';
3
+ export function assertRecentTimestamp(value, now, toleranceSeconds) {
4
+ if (!value || !/^\d+$/.test(value))
5
+ throw new WebhookVerificationError('invalid_signature');
6
+ const timestamp = Number(value);
7
+ const tolerance = toleranceSeconds ?? defaultTimestampToleranceSeconds;
8
+ if (!Number.isSafeInteger(timestamp) || !Number.isSafeInteger(tolerance) || tolerance < 1) {
9
+ throw new WebhookVerificationError('invalid_signature');
10
+ }
11
+ const seconds = Math.floor((now ?? new Date()).getTime() / 1000);
12
+ if (Math.abs(seconds - timestamp) > tolerance)
13
+ throw new WebhookVerificationError('invalid_signature');
14
+ }
15
+ export function getHeader(headers, name) {
16
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
17
+ return typeof entry === 'string' ? entry : Array.isArray(entry) ? entry.join(' ') : undefined;
18
+ }
19
+ export function safeEqual(left, right) {
20
+ return left.byteLength === right.byteLength && timingSafeEqual(left, right);
21
+ }
22
+ export function decodeBase64(value) {
23
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value))
24
+ return Buffer.alloc(0);
25
+ return Buffer.from(value, 'base64');
26
+ }
27
+ export function decodeHex(value) {
28
+ if (!/^(?:[a-fA-F0-9]{2})+$/.test(value))
29
+ return Buffer.alloc(0);
30
+ return Buffer.from(value, 'hex');
31
+ }
32
+ export function parseDate(value) {
33
+ if (typeof value === 'number' && Number.isFinite(value)) {
34
+ const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
35
+ const result = new Date(milliseconds);
36
+ return Number.isNaN(result.getTime()) ? undefined : result;
37
+ }
38
+ if (typeof value === 'string' && value.length > 0) {
39
+ const result = new Date(value);
40
+ return Number.isNaN(result.getTime()) ? undefined : result;
41
+ }
42
+ return undefined;
43
+ }
44
+ export function asRecord(value) {
45
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
46
+ throw new WebhookVerificationError('invalid_payload');
47
+ }
48
+ return value;
49
+ }
50
+ export function asOptionalRecord(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ ? value
53
+ : undefined;
54
+ }
55
+ export function asString(value) {
56
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
57
+ }
58
+ export function asId(value) {
59
+ return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined;
60
+ }
61
+ export function isString(value) {
62
+ return typeof value === 'string';
63
+ }
64
+ export function asStringArray(value) {
65
+ return Array.isArray(value) ? value.filter(isString) : [];
66
+ }
67
+ export function firstString(value) {
68
+ return asString(value) ?? asStringArray(value)[0];
69
+ }
@@ -0,0 +1,16 @@
1
+ import type { VerifyWebhookInput } from './types.js';
2
+ export declare function verifyResend(input: Extract<VerifyWebhookInput, {
3
+ provider: 'resend';
4
+ }>, rawBody: Uint8Array): void;
5
+ export declare function verifyMailgun(input: Extract<VerifyWebhookInput, {
6
+ provider: 'mailgun';
7
+ }>, payload: unknown): void;
8
+ export declare function verifySendGrid(input: Extract<VerifyWebhookInput, {
9
+ provider: 'sendgrid';
10
+ }>, rawBody: Uint8Array): void;
11
+ export declare function verifyAuthorization(input: Extract<VerifyWebhookInput, {
12
+ provider: 'brevo' | 'postmark';
13
+ }>, headerName: string): void;
14
+ export declare function verifySnsNotification(input: Extract<VerifyWebhookInput, {
15
+ provider: 'ses';
16
+ }>, payload: unknown): Promise<unknown>;
@@ -0,0 +1,159 @@
1
+ import { createHmac, createVerify, X509Certificate } from 'node:crypto';
2
+ import { WebhookVerificationError } from './types.js';
3
+ import { asRecord, asString, assertRecentTimestamp, decodeBase64, decodeHex, getHeader, safeEqual } from './shared.js';
4
+ export function verifyResend(input, rawBody) {
5
+ const messageId = getHeader(input.headers, 'svix-id');
6
+ const timestamp = getHeader(input.headers, 'svix-timestamp');
7
+ const signatures = getHeader(input.headers, 'svix-signature');
8
+ assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
9
+ if (!messageId || !signatures || !input.webhookSecret.startsWith('whsec_')) {
10
+ throw new WebhookVerificationError('invalid_signature');
11
+ }
12
+ let key;
13
+ try {
14
+ key = Buffer.from(input.webhookSecret.slice('whsec_'.length), 'base64');
15
+ }
16
+ catch {
17
+ throw new WebhookVerificationError('invalid_signature');
18
+ }
19
+ if (key.length === 0)
20
+ throw new WebhookVerificationError('invalid_signature');
21
+ const expected = createHmac('sha256', key).update(`${messageId}.${timestamp}.`).update(rawBody).digest();
22
+ const matches = signatures
23
+ .split(' ')
24
+ .some((item) => item.startsWith('v1,') && safeEqual(expected, decodeBase64(item.slice(3))));
25
+ if (!matches)
26
+ throw new WebhookVerificationError('invalid_signature');
27
+ }
28
+ export function verifyMailgun(input, payload) {
29
+ const root = asRecord(payload);
30
+ const signature = asRecord(root.signature);
31
+ const timestamp = asString(signature.timestamp);
32
+ const token = asString(signature.token);
33
+ const received = asString(signature.signature);
34
+ assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
35
+ if (!timestamp || !token || !received || !input.signingKey) {
36
+ throw new WebhookVerificationError('invalid_signature');
37
+ }
38
+ const expected = createHmac('sha256', input.signingKey).update(`${timestamp}${token}`).digest('hex');
39
+ if (!safeEqual(Buffer.from(expected, 'hex'), decodeHex(received))) {
40
+ throw new WebhookVerificationError('invalid_signature');
41
+ }
42
+ }
43
+ export function verifySendGrid(input, rawBody) {
44
+ const timestamp = getHeader(input.headers, 'x-twilio-email-event-webhook-timestamp');
45
+ const signature = getHeader(input.headers, 'x-twilio-email-event-webhook-signature');
46
+ assertRecentTimestamp(timestamp, input.now, input.toleranceSeconds);
47
+ if (!timestamp || !signature || !input.publicKey)
48
+ throw new WebhookVerificationError('invalid_signature');
49
+ try {
50
+ const verifier = createVerify('sha256');
51
+ verifier.update(timestamp);
52
+ verifier.update(rawBody);
53
+ verifier.end();
54
+ if (!verifier.verify(input.publicKey, signature, 'base64')) {
55
+ throw new WebhookVerificationError('invalid_signature');
56
+ }
57
+ }
58
+ catch (error) {
59
+ if (error instanceof WebhookVerificationError)
60
+ throw error;
61
+ throw new WebhookVerificationError('invalid_signature');
62
+ }
63
+ }
64
+ export function verifyAuthorization(input, headerName) {
65
+ const received = getHeader(input.headers, headerName);
66
+ if (!received || !input.authorization || !safeEqual(Buffer.from(received), Buffer.from(input.authorization))) {
67
+ throw new WebhookVerificationError('invalid_signature');
68
+ }
69
+ }
70
+ export async function verifySnsNotification(input, payload) {
71
+ const envelope = asRecord(payload);
72
+ const topicArn = asString(envelope.TopicArn);
73
+ const signingCertUrl = asString(envelope.SigningCertURL);
74
+ const signature = asString(envelope.Signature);
75
+ const signatureVersion = asString(envelope.SignatureVersion);
76
+ if (envelope.Type !== 'Notification' ||
77
+ topicArn !== input.topicArn ||
78
+ !signingCertUrl ||
79
+ !signature ||
80
+ (signatureVersion !== '1' && signatureVersion !== '2')) {
81
+ throw new WebhookVerificationError('invalid_signature');
82
+ }
83
+ let certificateUrl;
84
+ try {
85
+ certificateUrl = new URL(signingCertUrl);
86
+ }
87
+ catch {
88
+ throw new WebhookVerificationError('invalid_signature');
89
+ }
90
+ const topicMatch = /^arn:(aws|aws-us-gov|aws-cn):sns:([a-z0-9-]+):\d{12}:[^:]+$/.exec(input.topicArn);
91
+ const expectedHost = topicMatch
92
+ ? topicMatch[1] === 'aws-cn'
93
+ ? `sns.${topicMatch[2]}.amazonaws.com.cn`
94
+ : `sns.${topicMatch[2]}.amazonaws.com`
95
+ : undefined;
96
+ if (certificateUrl.protocol !== 'https:' ||
97
+ certificateUrl.username ||
98
+ certificateUrl.password ||
99
+ certificateUrl.port ||
100
+ certificateUrl.search ||
101
+ certificateUrl.hash ||
102
+ certificateUrl.hostname !== expectedHost ||
103
+ !/^\/SimpleNotificationService-[A-Za-z0-9_-]+\.pem$/.test(certificateUrl.pathname)) {
104
+ throw new WebhookVerificationError('invalid_signature');
105
+ }
106
+ let response;
107
+ try {
108
+ response = await fetch(certificateUrl, { redirect: 'error', signal: AbortSignal.timeout(5_000) });
109
+ }
110
+ catch {
111
+ throw new WebhookVerificationError('invalid_signature');
112
+ }
113
+ if (!response.ok)
114
+ throw new WebhookVerificationError('invalid_signature');
115
+ let certificateBody;
116
+ try {
117
+ certificateBody = await response.text();
118
+ }
119
+ catch {
120
+ throw new WebhookVerificationError('invalid_signature');
121
+ }
122
+ if (certificateBody.length > 32_768)
123
+ throw new WebhookVerificationError('invalid_signature');
124
+ try {
125
+ const certificate = new X509Certificate(certificateBody);
126
+ const now = input.now ?? new Date();
127
+ if (!certificate.subject.split('\n').some((line) => line === 'CN=Amazon SNS') ||
128
+ now < new Date(certificate.validFrom) ||
129
+ now > new Date(certificate.validTo)) {
130
+ throw new WebhookVerificationError('invalid_signature');
131
+ }
132
+ const fields = snsFields(envelope);
133
+ const verifier = createVerify(signatureVersion === '2' ? 'sha256' : 'sha1');
134
+ verifier.update(fields);
135
+ verifier.end();
136
+ if (!verifier.verify(certificate.publicKey, signature, 'base64')) {
137
+ throw new WebhookVerificationError('invalid_signature');
138
+ }
139
+ }
140
+ catch (error) {
141
+ if (error instanceof WebhookVerificationError)
142
+ throw error;
143
+ throw new WebhookVerificationError('invalid_signature');
144
+ }
145
+ try {
146
+ const message = JSON.parse(asString(envelope.Message) ?? '');
147
+ return message;
148
+ }
149
+ catch {
150
+ throw new WebhookVerificationError('invalid_payload');
151
+ }
152
+ }
153
+ function snsFields(envelope) {
154
+ const fields = ['Message', 'MessageId'];
155
+ if (envelope.Subject !== undefined)
156
+ fields.push('Subject');
157
+ fields.push('Timestamp', 'TopicArn', 'Type');
158
+ return fields.map((field) => `${field}\n${asString(envelope[field]) ?? ''}\n`).join('');
159
+ }
@@ -0,0 +1,43 @@
1
+ export type ProviderName = import('../types.js').ProviderName;
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
+ export declare const defaultTimestampToleranceSeconds = 300;
43
+ export {};
@@ -0,0 +1,13 @@
1
+ export class WebhookVerificationError extends Error {
2
+ code;
3
+ constructor(code) {
4
+ super(code === 'invalid_signature'
5
+ ? 'The webhook request could not be authenticated.'
6
+ : code === 'unsupported_event'
7
+ ? 'The provider webhook event is not supported.'
8
+ : 'The provider webhook payload is invalid.');
9
+ this.code = code;
10
+ this.name = 'WebhookVerificationError';
11
+ }
12
+ }
13
+ export const defaultTimestampToleranceSeconds = 300;
@@ -0,0 +1,5 @@
1
+ import type { EmailWebhookEvent, VerifyWebhookInput } from './webhooks/types.js';
2
+ export type { EmailWebhookEvent, EmailWebhookEventType, VerifyWebhookInput, WebhookHeaders } from './webhooks/types.js';
3
+ export { WebhookVerificationError } from './webhooks/types.js';
4
+ /** Authenticates a provider webhook before returning normalized, typed email events. */
5
+ export declare function verifyWebhook(input: VerifyWebhookInput): Promise<readonly EmailWebhookEvent[]>;
@@ -0,0 +1,32 @@
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';
5
+ /** Authenticates a provider webhook before returning normalized, typed email events. */
6
+ export async function verifyWebhook(input) {
7
+ const rawBody = Buffer.from(input.rawBody);
8
+ let payload;
9
+ try {
10
+ payload = JSON.parse(rawBody.toString('utf8'));
11
+ }
12
+ catch {
13
+ throw new WebhookVerificationError('invalid_payload');
14
+ }
15
+ switch (input.provider) {
16
+ case 'resend':
17
+ verifyResend(input, rawBody);
18
+ return normalizeResend(payload);
19
+ case 'mailgun':
20
+ verifyMailgun(input, payload);
21
+ return normalizeMailgun(payload);
22
+ case 'sendgrid':
23
+ verifySendGrid(input, rawBody);
24
+ return normalizeSendGrid(payload);
25
+ case 'brevo':
26
+ case 'postmark':
27
+ verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
28
+ return normalizeProviderEvents(input.provider, payload);
29
+ case 'ses':
30
+ return normalizeSes(await verifySnsNotification(input, payload));
31
+ }
32
+ }
@@ -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
 
@@ -23,8 +23,8 @@ 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.
@@ -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)