typedmailer 1.1.0 → 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.
- package/README.md +1 -1
- package/dist/index.d.ts +2 -72
- package/dist/index.js +10 -108
- package/dist/providers/registry.d.ts +151 -0
- package/dist/providers/registry.js +109 -0
- 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 +43 -0
- package/dist/webhooks/types.js +13 -0
- package/dist/webhooks.d.ts +3 -42
- package/dist/webhooks.js +4 -389
- package/docs/provider-contracts.md +2 -2
- package/package.json +3 -2
|
@@ -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;
|
package/dist/webhooks.d.ts
CHANGED
|
@@ -1,44 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export type
|
|
3
|
-
export
|
|
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
|
-
}
|
|
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';
|
|
42
4
|
/** Authenticates a provider webhook before returning normalized, typed email events. */
|
|
43
5
|
export declare function verifyWebhook(input: VerifyWebhookInput): Promise<readonly EmailWebhookEvent[]>;
|
|
44
|
-
export {};
|