@stacksjs/email 0.70.88 → 0.70.91
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/dist/css-inliner.d.ts +26 -0
- package/dist/css-inliner.js +172 -0
- package/dist/drivers/base.d.ts +14 -0
- package/dist/drivers/base.js +128 -0
- package/dist/drivers/capture.d.ts +55 -0
- package/dist/drivers/capture.js +30 -0
- package/dist/drivers/index.d.ts +10 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/log.d.ts +30 -0
- package/dist/drivers/log.js +81 -0
- package/dist/drivers/mailgun.d.ts +8 -0
- package/dist/drivers/mailgun.js +127 -0
- package/dist/drivers/mailtrap.d.ts +8 -0
- package/dist/drivers/mailtrap.js +129 -0
- package/dist/drivers/sendgrid.d.ts +8 -0
- package/dist/drivers/sendgrid.js +136 -0
- package/dist/drivers/ses.d.ts +8 -0
- package/dist/drivers/ses.js +113 -0
- package/dist/drivers/smtp.d.ts +14 -0
- package/dist/drivers/smtp.js +225 -0
- package/dist/email.d.ts +39 -0
- package/dist/email.js +198 -0
- package/dist/idempotency.d.ts +20 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +19 -0
- package/dist/mailable.d.ts +116 -0
- package/dist/mailable.js +145 -0
- package/dist/mime.d.ts +37 -0
- package/dist/mime.js +89 -0
- package/dist/preview-ui.d.ts +11 -0
- package/dist/preview-ui.js +132 -0
- package/dist/preview.d.ts +50 -0
- package/dist/preview.js +97 -0
- package/dist/sdk/index.d.ts +81 -0
- package/dist/sdk/index.js +219 -0
- package/dist/send.d.ts +1 -0
- package/dist/send.js +0 -0
- package/dist/server/converter.d.ts +1 -0
- package/dist/server/converter.js +0 -0
- package/dist/server/inbound.d.ts +1 -0
- package/dist/server/inbound.js +0 -0
- package/dist/server/outbound.d.ts +1 -0
- package/dist/server/outbound.js +0 -0
- package/dist/suppression.d.ts +82 -0
- package/dist/suppression.js +104 -0
- package/dist/template.d.ts +99 -0
- package/dist/template.js +170 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +0 -0
- package/dist/unsubscribe.d.ts +39 -0
- package/dist/unsubscribe.js +65 -0
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +3 -0
- package/dist/validation.d.ts +48 -0
- package/dist/validation.js +22 -0
- package/dist/webhook-dedup.d.ts +10 -0
- package/dist/webhook-dedup.js +33 -0
- package/dist/webhook-events.d.ts +34 -0
- package/dist/webhook-events.js +37 -0
- package/dist/webhook-handlers.d.ts +27 -0
- package/dist/webhook-handlers.js +264 -0
- package/dist/webhook-signatures.d.ts +91 -0
- package/dist/webhook-signatures.js +148 -0
- package/package.json +5 -5
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { suppress } from "./suppression";
|
|
3
|
+
import { recordWebhookEventOrSkip } from "./webhook-dedup";
|
|
4
|
+
import {
|
|
5
|
+
emitEmailBounceHard,
|
|
6
|
+
emitEmailBounceSoft,
|
|
7
|
+
emitEmailComplaint,
|
|
8
|
+
emitEmailUnsubscribe,
|
|
9
|
+
suppressionTypeFor
|
|
10
|
+
} from "./webhook-events";
|
|
11
|
+
import {
|
|
12
|
+
verifyMailgunSignature,
|
|
13
|
+
verifyPostmarkAuth,
|
|
14
|
+
verifySendgridSignature,
|
|
15
|
+
verifySesSnsSignature
|
|
16
|
+
} from "./webhook-signatures";
|
|
17
|
+
const OK_DUPLICATE = { status: 200, body: { ok: !0, processed: !1, reason: "duplicate" } };
|
|
18
|
+
function unauthorized(reason) {
|
|
19
|
+
return { status: 401, body: { ok: !1, reason } };
|
|
20
|
+
}
|
|
21
|
+
function badRequest(reason) {
|
|
22
|
+
return { status: 400, body: { ok: !1, reason } };
|
|
23
|
+
}
|
|
24
|
+
async function dispatchClassifiedEvent(classification, payload) {
|
|
25
|
+
const suppressionType = suppressionTypeFor(classification);
|
|
26
|
+
if (suppressionType)
|
|
27
|
+
await suppress(payload.email, suppressionType, payload.reason);
|
|
28
|
+
switch (classification) {
|
|
29
|
+
case "bounce-hard":
|
|
30
|
+
await emitEmailBounceHard(payload);
|
|
31
|
+
break;
|
|
32
|
+
case "bounce-soft":
|
|
33
|
+
await emitEmailBounceSoft(payload);
|
|
34
|
+
break;
|
|
35
|
+
case "complaint":
|
|
36
|
+
await emitEmailComplaint(payload);
|
|
37
|
+
break;
|
|
38
|
+
case "unsubscribe":
|
|
39
|
+
await emitEmailUnsubscribe(payload);
|
|
40
|
+
break;
|
|
41
|
+
case "delivered":
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function handleMailgunWebhook(rawBody, config) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(rawBody);
|
|
49
|
+
} catch {
|
|
50
|
+
return badRequest("invalid-json");
|
|
51
|
+
}
|
|
52
|
+
const sig = parsed.signature, event = parsed["event-data"];
|
|
53
|
+
if (!sig || !event)
|
|
54
|
+
return badRequest("missing-fields");
|
|
55
|
+
const v = verifyMailgunSignature({
|
|
56
|
+
timestamp: sig.timestamp,
|
|
57
|
+
token: sig.token,
|
|
58
|
+
signature: sig.signature,
|
|
59
|
+
signingKey: config.signingKey,
|
|
60
|
+
toleranceSeconds: config.toleranceSeconds
|
|
61
|
+
});
|
|
62
|
+
if (!v.ok)
|
|
63
|
+
return unauthorized(v.reason);
|
|
64
|
+
if (!await recordWebhookEventOrSkip("mailgun", event.id))
|
|
65
|
+
return OK_DUPLICATE;
|
|
66
|
+
const classification = classifyMailgunEvent(event.event, event.severity);
|
|
67
|
+
if (!classification)
|
|
68
|
+
return { status: 200, body: { ok: !0, processed: !1, reason: "unhandled-event" } };
|
|
69
|
+
await dispatchClassifiedEvent(classification, {
|
|
70
|
+
email: event.recipient,
|
|
71
|
+
provider: "mailgun",
|
|
72
|
+
reason: event.reason,
|
|
73
|
+
raw: event
|
|
74
|
+
});
|
|
75
|
+
return { status: 200, body: { ok: !0, processed: !0, classification } };
|
|
76
|
+
}
|
|
77
|
+
function classifyMailgunEvent(event, severity) {
|
|
78
|
+
switch (event) {
|
|
79
|
+
case "failed":
|
|
80
|
+
return severity === "temporary" ? "bounce-soft" : "bounce-hard";
|
|
81
|
+
case "complained":
|
|
82
|
+
return "complaint";
|
|
83
|
+
case "unsubscribed":
|
|
84
|
+
return "unsubscribe";
|
|
85
|
+
case "delivered":
|
|
86
|
+
return "delivered";
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function handlePostmarkWebhook(rawBody, authorizationHeader, sourceIp, config) {
|
|
92
|
+
const v = verifyPostmarkAuth({
|
|
93
|
+
authorizationHeader,
|
|
94
|
+
expectedUsername: config.username,
|
|
95
|
+
expectedPassword: config.password,
|
|
96
|
+
sourceIp,
|
|
97
|
+
ipAllowlist: config.ipAllowlist
|
|
98
|
+
});
|
|
99
|
+
if (!v.ok)
|
|
100
|
+
return unauthorized(v.reason);
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(rawBody);
|
|
104
|
+
} catch {
|
|
105
|
+
return badRequest("invalid-json");
|
|
106
|
+
}
|
|
107
|
+
const eventId = String(parsed.ID ?? parsed.MessageID ?? ""), email = String(parsed.Email ?? parsed.Recipient ?? "");
|
|
108
|
+
if (!email)
|
|
109
|
+
return badRequest("missing-recipient");
|
|
110
|
+
if (!await recordWebhookEventOrSkip("postmark", eventId))
|
|
111
|
+
return OK_DUPLICATE;
|
|
112
|
+
const classification = classifyPostmarkEvent(parsed);
|
|
113
|
+
if (!classification)
|
|
114
|
+
return { status: 200, body: { ok: !0, processed: !1, reason: "unhandled-event" } };
|
|
115
|
+
await dispatchClassifiedEvent(classification, {
|
|
116
|
+
email,
|
|
117
|
+
provider: "postmark",
|
|
118
|
+
reason: parsed.Description,
|
|
119
|
+
raw: parsed
|
|
120
|
+
});
|
|
121
|
+
return { status: 200, body: { ok: !0, processed: !0, classification } };
|
|
122
|
+
}
|
|
123
|
+
function classifyPostmarkEvent(msg) {
|
|
124
|
+
switch (msg.RecordType) {
|
|
125
|
+
case "Bounce":
|
|
126
|
+
return msg.TypeCode === 1 ? "bounce-hard" : "bounce-soft";
|
|
127
|
+
case "SpamComplaint":
|
|
128
|
+
return "complaint";
|
|
129
|
+
case "SubscriptionChange":
|
|
130
|
+
return msg.SuppressSending ? "unsubscribe" : null;
|
|
131
|
+
case "Delivery":
|
|
132
|
+
return "delivered";
|
|
133
|
+
default:
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export async function handleSesWebhook(rawBody, config = {}) {
|
|
138
|
+
let snsMessage;
|
|
139
|
+
try {
|
|
140
|
+
snsMessage = JSON.parse(rawBody);
|
|
141
|
+
} catch {
|
|
142
|
+
return badRequest("invalid-json");
|
|
143
|
+
}
|
|
144
|
+
const v = await verifySesSnsSignature({
|
|
145
|
+
message: snsMessage,
|
|
146
|
+
certUrlHostAllowlist: config.certUrlHostAllowlist,
|
|
147
|
+
fetchCert: config.fetchCert
|
|
148
|
+
});
|
|
149
|
+
if (!v.ok)
|
|
150
|
+
return unauthorized(v.reason);
|
|
151
|
+
if (snsMessage.Type === "SubscriptionConfirmation") {
|
|
152
|
+
if (config.autoConfirmSubscriptions !== !1 && snsMessage.SubscribeURL)
|
|
153
|
+
try {
|
|
154
|
+
await fetch(snsMessage.SubscribeURL);
|
|
155
|
+
} catch {}
|
|
156
|
+
return { status: 200, body: { ok: !0, processed: !0, reason: "subscription-confirmed" } };
|
|
157
|
+
}
|
|
158
|
+
if (snsMessage.Type === "UnsubscribeConfirmation")
|
|
159
|
+
return { status: 200, body: { ok: !0, processed: !0, reason: "subscription-removed" } };
|
|
160
|
+
let innerMessage;
|
|
161
|
+
try {
|
|
162
|
+
innerMessage = JSON.parse(snsMessage.Message);
|
|
163
|
+
} catch {
|
|
164
|
+
return badRequest("invalid-inner-json");
|
|
165
|
+
}
|
|
166
|
+
if (!await recordWebhookEventOrSkip("ses", snsMessage.MessageId))
|
|
167
|
+
return OK_DUPLICATE;
|
|
168
|
+
const dispatched = [];
|
|
169
|
+
if (innerMessage.notificationType === "Bounce" && innerMessage.bounce) {
|
|
170
|
+
const classification = innerMessage.bounce.bounceType === "Permanent" ? "bounce-hard" : "bounce-soft";
|
|
171
|
+
for (const r of innerMessage.bounce.bouncedRecipients) {
|
|
172
|
+
await dispatchClassifiedEvent(classification, {
|
|
173
|
+
email: r.emailAddress,
|
|
174
|
+
provider: "ses",
|
|
175
|
+
reason: r.diagnosticCode,
|
|
176
|
+
raw: innerMessage
|
|
177
|
+
});
|
|
178
|
+
dispatched.push(classification);
|
|
179
|
+
}
|
|
180
|
+
} else if (innerMessage.notificationType === "Complaint" && innerMessage.complaint)
|
|
181
|
+
for (const r of innerMessage.complaint.complainedRecipients) {
|
|
182
|
+
await dispatchClassifiedEvent("complaint", {
|
|
183
|
+
email: r.emailAddress,
|
|
184
|
+
provider: "ses",
|
|
185
|
+
reason: innerMessage.complaint.complaintFeedbackType,
|
|
186
|
+
raw: innerMessage
|
|
187
|
+
});
|
|
188
|
+
dispatched.push("complaint");
|
|
189
|
+
}
|
|
190
|
+
else if (innerMessage.notificationType === "Delivery" && innerMessage.delivery)
|
|
191
|
+
for (const r of innerMessage.delivery.recipients) {
|
|
192
|
+
await dispatchClassifiedEvent("delivered", { email: r, provider: "ses", raw: innerMessage });
|
|
193
|
+
dispatched.push("delivered");
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
status: 200,
|
|
197
|
+
body: {
|
|
198
|
+
ok: !0,
|
|
199
|
+
processed: dispatched.length > 0,
|
|
200
|
+
classification: dispatched[0]
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export async function handleSendgridWebhook(rawBody, signatureHeader, timestampHeader, config) {
|
|
205
|
+
const v = verifySendgridSignature({
|
|
206
|
+
body: rawBody,
|
|
207
|
+
signature: signatureHeader,
|
|
208
|
+
timestamp: timestampHeader,
|
|
209
|
+
publicKeyPem: config.publicKeyPem,
|
|
210
|
+
toleranceSeconds: config.toleranceSeconds
|
|
211
|
+
});
|
|
212
|
+
if (!v.ok)
|
|
213
|
+
return unauthorized(v.reason);
|
|
214
|
+
let events;
|
|
215
|
+
try {
|
|
216
|
+
events = JSON.parse(rawBody);
|
|
217
|
+
} catch {
|
|
218
|
+
return badRequest("invalid-json");
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(events))
|
|
221
|
+
return badRequest("expected-array");
|
|
222
|
+
let processed = 0;
|
|
223
|
+
const classifications = [];
|
|
224
|
+
for (const ev of events) {
|
|
225
|
+
if (!ev.email)
|
|
226
|
+
continue;
|
|
227
|
+
const id = ev.sg_event_id ?? `${ev.event}:${ev.email}:${Date.now()}`;
|
|
228
|
+
if (!await recordWebhookEventOrSkip("sendgrid", id))
|
|
229
|
+
continue;
|
|
230
|
+
const classification = classifySendgridEvent(ev.event, ev.type);
|
|
231
|
+
if (!classification)
|
|
232
|
+
continue;
|
|
233
|
+
await dispatchClassifiedEvent(classification, {
|
|
234
|
+
email: ev.email,
|
|
235
|
+
provider: "sendgrid",
|
|
236
|
+
reason: ev.reason,
|
|
237
|
+
raw: ev
|
|
238
|
+
});
|
|
239
|
+
processed++;
|
|
240
|
+
classifications.push(classification);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
status: 200,
|
|
244
|
+
body: { ok: !0, processed: processed > 0, classification: classifications[0] }
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function classifySendgridEvent(event, type) {
|
|
248
|
+
switch (event) {
|
|
249
|
+
case "bounce":
|
|
250
|
+
return type === "blocked" ? "bounce-soft" : "bounce-hard";
|
|
251
|
+
case "dropped":
|
|
252
|
+
return "bounce-hard";
|
|
253
|
+
case "spamreport":
|
|
254
|
+
return "complaint";
|
|
255
|
+
case "unsubscribe":
|
|
256
|
+
return "unsubscribe";
|
|
257
|
+
case "group_unsubscribe":
|
|
258
|
+
return "unsubscribe";
|
|
259
|
+
case "delivered":
|
|
260
|
+
return "delivered";
|
|
261
|
+
default:
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify a Mailgun webhook signature. Mailgun signs
|
|
3
|
+
* `${timestamp}${token}` with HMAC-SHA256; the hex digest is
|
|
4
|
+
* compared in constant time against the `signature` field.
|
|
5
|
+
*
|
|
6
|
+
* The timestamp is checked against wall clock with a configurable
|
|
7
|
+
* tolerance (default 5 minutes) to reject replays. Constant-time
|
|
8
|
+
* compare prevents signature-timing oracles.
|
|
9
|
+
*/
|
|
10
|
+
export declare function verifyMailgunSignature(input: MailgunSignatureInput): SignatureVerification;
|
|
11
|
+
/**
|
|
12
|
+
* Verify a Postmark webhook. Postmark uses HTTP Basic Auth (the app
|
|
13
|
+
* configures the username + password when registering the webhook in
|
|
14
|
+
* the Postmark dashboard); they don't sign the body. The auth check
|
|
15
|
+
* is constant-time. Optional IP-allowlist check rejects requests
|
|
16
|
+
* from outside Postmark's published source IPs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyPostmarkAuth(input: PostmarkAuthInput): SignatureVerification;
|
|
19
|
+
/**
|
|
20
|
+
* Verify an SNS message signature (SES uses SNS for delivery). The
|
|
21
|
+
* cert URL host MUST match `sns.<region>.amazonaws.com` — any other
|
|
22
|
+
* host is treated as untrusted (defense against SSRF via crafted
|
|
23
|
+
* SigningCertURL).
|
|
24
|
+
*
|
|
25
|
+
* Note: this function intentionally only does the structural +
|
|
26
|
+
* cert-URL check + signature verify. SubscriptionConfirmation
|
|
27
|
+
* handling (responding to `SubscribeURL` to complete the topic
|
|
28
|
+
* binding) is the route handler's job since it's a one-time setup
|
|
29
|
+
* action distinct from per-event verification.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verifySesSnsSignature(input: SesSnsSignatureInput): Promise<SignatureVerification>;
|
|
32
|
+
/**
|
|
33
|
+
* Verify a SendGrid Event Webhook signature. SendGrid signs
|
|
34
|
+
* `${timestamp}${body}` with ECDSA-SHA256 using the public key from
|
|
35
|
+
* their signed-webhook setup page. Signature is base64 in the
|
|
36
|
+
* `X-Twilio-Email-Event-Webhook-Signature` header.
|
|
37
|
+
*/
|
|
38
|
+
export declare function verifySendgridSignature(input: SendgridSignatureInput): SignatureVerification;
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Mailgun
|
|
41
|
+
// =============================================================================
|
|
42
|
+
export declare interface MailgunSignatureInput {
|
|
43
|
+
timestamp: string
|
|
44
|
+
token: string
|
|
45
|
+
signature: string
|
|
46
|
+
signingKey: string
|
|
47
|
+
toleranceSeconds?: number
|
|
48
|
+
}
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// Postmark
|
|
51
|
+
// =============================================================================
|
|
52
|
+
export declare interface PostmarkAuthInput {
|
|
53
|
+
authorizationHeader: string | null | undefined
|
|
54
|
+
expectedUsername: string
|
|
55
|
+
expectedPassword: string
|
|
56
|
+
sourceIp?: string
|
|
57
|
+
ipAllowlist?: ReadonlyArray<string>
|
|
58
|
+
}
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// SES (via SNS)
|
|
61
|
+
// =============================================================================
|
|
62
|
+
export declare interface SesSnsSignatureInput {
|
|
63
|
+
message: {
|
|
64
|
+
Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation'
|
|
65
|
+
MessageId: string
|
|
66
|
+
TopicArn: string
|
|
67
|
+
Subject?: string
|
|
68
|
+
Message: string
|
|
69
|
+
Timestamp: string
|
|
70
|
+
SignatureVersion: string
|
|
71
|
+
Signature: string
|
|
72
|
+
SigningCertURL: string
|
|
73
|
+
Token?: string
|
|
74
|
+
SubscribeURL?: string
|
|
75
|
+
[key: string]: unknown
|
|
76
|
+
}
|
|
77
|
+
fetchCert?: (url: string) => Promise<string>
|
|
78
|
+
certUrlHostAllowlist?: RegExp
|
|
79
|
+
}
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// SendGrid
|
|
82
|
+
// =============================================================================
|
|
83
|
+
export declare interface SendgridSignatureInput {
|
|
84
|
+
body: string
|
|
85
|
+
signature: string | null | undefined
|
|
86
|
+
timestamp: string | null | undefined
|
|
87
|
+
publicKeyPem: string
|
|
88
|
+
toleranceSeconds?: number
|
|
89
|
+
}
|
|
90
|
+
export type SignatureVerification = | { ok: true }
|
|
91
|
+
| { ok: false, reason: 'missing-config' | 'missing-signature' | 'bad-signature' | 'expired' | 'untrusted-cert-url' | 'cert-fetch-failed' }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHmac, createVerify, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
export function verifyMailgunSignature(input) {
|
|
4
|
+
if (!input.signingKey)
|
|
5
|
+
return { ok: !1, reason: "missing-config" };
|
|
6
|
+
if (!input.timestamp || !input.token || !input.signature)
|
|
7
|
+
return { ok: !1, reason: "missing-signature" };
|
|
8
|
+
const tolerance = input.toleranceSeconds ?? 300, ts = Number(input.timestamp);
|
|
9
|
+
if (!Number.isFinite(ts))
|
|
10
|
+
return { ok: !1, reason: "bad-signature" };
|
|
11
|
+
const now = Math.floor(Date.now() / 1000);
|
|
12
|
+
if (Math.abs(now - ts) > tolerance)
|
|
13
|
+
return { ok: !1, reason: "expired" };
|
|
14
|
+
const expected = createHmac("sha256", input.signingKey).update(`${input.timestamp}${input.token}`).digest("hex");
|
|
15
|
+
let provided;
|
|
16
|
+
try {
|
|
17
|
+
provided = Buffer.from(input.signature, "hex");
|
|
18
|
+
} catch {
|
|
19
|
+
return { ok: !1, reason: "bad-signature" };
|
|
20
|
+
}
|
|
21
|
+
const expectedBuf = Buffer.from(expected, "hex");
|
|
22
|
+
if (provided.length !== expectedBuf.length)
|
|
23
|
+
return { ok: !1, reason: "bad-signature" };
|
|
24
|
+
if (!timingSafeEqual(provided, expectedBuf))
|
|
25
|
+
return { ok: !1, reason: "bad-signature" };
|
|
26
|
+
return { ok: !0 };
|
|
27
|
+
}
|
|
28
|
+
export function verifyPostmarkAuth(input) {
|
|
29
|
+
if (!input.expectedUsername || !input.expectedPassword)
|
|
30
|
+
return { ok: !1, reason: "missing-config" };
|
|
31
|
+
if (!input.authorizationHeader || !input.authorizationHeader.startsWith("Basic "))
|
|
32
|
+
return { ok: !1, reason: "missing-signature" };
|
|
33
|
+
let decoded;
|
|
34
|
+
try {
|
|
35
|
+
decoded = Buffer.from(input.authorizationHeader.slice(6), "base64").toString("utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: !1, reason: "bad-signature" };
|
|
38
|
+
}
|
|
39
|
+
const [user, pass] = decoded.split(":");
|
|
40
|
+
if (!user || pass === void 0)
|
|
41
|
+
return { ok: !1, reason: "bad-signature" };
|
|
42
|
+
const userMatch = safeStringEquals(user, input.expectedUsername), passMatch = safeStringEquals(pass, input.expectedPassword);
|
|
43
|
+
if (!userMatch || !passMatch)
|
|
44
|
+
return { ok: !1, reason: "bad-signature" };
|
|
45
|
+
if (input.ipAllowlist && input.ipAllowlist.length > 0) {
|
|
46
|
+
if (!input.sourceIp || !input.ipAllowlist.includes(input.sourceIp))
|
|
47
|
+
return { ok: !1, reason: "bad-signature" };
|
|
48
|
+
}
|
|
49
|
+
return { ok: !0 };
|
|
50
|
+
}
|
|
51
|
+
function safeStringEquals(a, b) {
|
|
52
|
+
const aBuf = Buffer.from(a, "utf8"), bBuf = Buffer.from(b, "utf8");
|
|
53
|
+
if (aBuf.length !== bBuf.length) {
|
|
54
|
+
const dummy = Buffer.alloc(Math.max(aBuf.length, bBuf.length));
|
|
55
|
+
timingSafeEqual(dummy, dummy);
|
|
56
|
+
return !1;
|
|
57
|
+
}
|
|
58
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
59
|
+
}
|
|
60
|
+
const DEFAULT_SNS_HOST_ALLOWLIST = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
|
61
|
+
async function defaultCertFetcher(url) {
|
|
62
|
+
const res = await fetch(url, { redirect: "error" });
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
throw Error(`SNS cert fetch returned ${res.status}`);
|
|
65
|
+
return await res.text();
|
|
66
|
+
}
|
|
67
|
+
export async function verifySesSnsSignature(input) {
|
|
68
|
+
const msg = input.message;
|
|
69
|
+
if (!msg || !msg.Signature || !msg.SigningCertURL)
|
|
70
|
+
return { ok: !1, reason: "missing-signature" };
|
|
71
|
+
const allowlist = input.certUrlHostAllowlist ?? DEFAULT_SNS_HOST_ALLOWLIST;
|
|
72
|
+
let certUrl;
|
|
73
|
+
try {
|
|
74
|
+
certUrl = new URL(msg.SigningCertURL);
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: !1, reason: "untrusted-cert-url" };
|
|
77
|
+
}
|
|
78
|
+
if (certUrl.protocol !== "https:")
|
|
79
|
+
return { ok: !1, reason: "untrusted-cert-url" };
|
|
80
|
+
if (!allowlist.test(certUrl.host))
|
|
81
|
+
return { ok: !1, reason: "untrusted-cert-url" };
|
|
82
|
+
let certPem;
|
|
83
|
+
try {
|
|
84
|
+
certPem = await (input.fetchCert ?? defaultCertFetcher)(msg.SigningCertURL);
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: !1, reason: "cert-fetch-failed" };
|
|
87
|
+
}
|
|
88
|
+
const canonical = canonicalSnsString(msg);
|
|
89
|
+
if (!canonical)
|
|
90
|
+
return { ok: !1, reason: "bad-signature" };
|
|
91
|
+
let sigBuf;
|
|
92
|
+
try {
|
|
93
|
+
sigBuf = Buffer.from(msg.Signature, "base64");
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: !1, reason: "bad-signature" };
|
|
96
|
+
}
|
|
97
|
+
const algo = msg.SignatureVersion === "2" ? "SHA256" : "SHA1", verifier = createVerify(`RSA-${algo}`);
|
|
98
|
+
verifier.update(canonical, "utf8");
|
|
99
|
+
return verifier.verify(certPem, sigBuf) ? { ok: !0 } : { ok: !1, reason: "bad-signature" };
|
|
100
|
+
}
|
|
101
|
+
function canonicalSnsString(msg) {
|
|
102
|
+
const fields = [];
|
|
103
|
+
if (msg.Type === "Notification") {
|
|
104
|
+
fields.push("Message", msg.Message);
|
|
105
|
+
fields.push("MessageId", msg.MessageId);
|
|
106
|
+
if (msg.Subject !== void 0)
|
|
107
|
+
fields.push("Subject", msg.Subject);
|
|
108
|
+
fields.push("Timestamp", msg.Timestamp);
|
|
109
|
+
fields.push("TopicArn", msg.TopicArn);
|
|
110
|
+
fields.push("Type", msg.Type);
|
|
111
|
+
} else if (msg.Type === "SubscriptionConfirmation" || msg.Type === "UnsubscribeConfirmation") {
|
|
112
|
+
fields.push("Message", msg.Message);
|
|
113
|
+
fields.push("MessageId", msg.MessageId);
|
|
114
|
+
if (!msg.SubscribeURL || !msg.Token)
|
|
115
|
+
return null;
|
|
116
|
+
fields.push("SubscribeURL", msg.SubscribeURL);
|
|
117
|
+
fields.push("Timestamp", msg.Timestamp);
|
|
118
|
+
fields.push("Token", msg.Token);
|
|
119
|
+
fields.push("TopicArn", msg.TopicArn);
|
|
120
|
+
fields.push("Type", msg.Type);
|
|
121
|
+
} else
|
|
122
|
+
return null;
|
|
123
|
+
let out = "";
|
|
124
|
+
for (const f of fields)
|
|
125
|
+
out += `${f}
|
|
126
|
+
`;
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
export function verifySendgridSignature(input) {
|
|
130
|
+
if (!input.publicKeyPem)
|
|
131
|
+
return { ok: !1, reason: "missing-config" };
|
|
132
|
+
if (!input.signature || !input.timestamp)
|
|
133
|
+
return { ok: !1, reason: "missing-signature" };
|
|
134
|
+
const tolerance = input.toleranceSeconds ?? 300, ts = Number(input.timestamp);
|
|
135
|
+
if (!Number.isFinite(ts))
|
|
136
|
+
return { ok: !1, reason: "bad-signature" };
|
|
137
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance)
|
|
138
|
+
return { ok: !1, reason: "expired" };
|
|
139
|
+
let sigBuf;
|
|
140
|
+
try {
|
|
141
|
+
sigBuf = Buffer.from(input.signature, "base64");
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: !1, reason: "bad-signature" };
|
|
144
|
+
}
|
|
145
|
+
const verifier = createVerify("SHA256");
|
|
146
|
+
verifier.update(`${input.timestamp}${input.body}`, "utf8");
|
|
147
|
+
return verifier.verify(input.publicKeyPem, sigBuf) ? { ok: !0 } : { ok: !1, reason: "bad-signature" };
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/email",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.91",
|
|
6
6
|
"description": "The Stacks Email integration. Painlessly create & manage your inboxes, templates, and send emails.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"@stacksjs/ts-cloud": "^0.7.17"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@stacksjs/cli": "0.70.
|
|
62
|
-
"@stacksjs/config": "0.70.
|
|
61
|
+
"@stacksjs/cli": "0.70.91",
|
|
62
|
+
"@stacksjs/config": "0.70.91",
|
|
63
63
|
"better-dx": "^0.2.16",
|
|
64
|
-
"@stacksjs/error-handling": "0.70.
|
|
65
|
-
"@stacksjs/types": "0.70.
|
|
64
|
+
"@stacksjs/error-handling": "0.70.91",
|
|
65
|
+
"@stacksjs/types": "0.70.91"
|
|
66
66
|
}
|
|
67
67
|
}
|