@stacksjs/email 0.70.87 → 0.70.90

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/types.js ADDED
File without changes
@@ -0,0 +1,65 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import process from "node:process";
3
+ import { Buffer } from "node:buffer";
4
+ const DEFAULT_TTL_SECONDS = 2592000, DEFAULT_ROUTE = "/_stacks/email/unsubscribe";
5
+ function getAppKey() {
6
+ const k = process.env.APP_KEY;
7
+ if (!k || k.length < 16) {
8
+ if (process.env.APP_ENV === "production" || process.env.NODE_ENV === "production")
9
+ throw Error("[email/unsubscribe] APP_KEY is missing or too short (need \u226516 chars). Cannot sign unsubscribe URL.");
10
+ }
11
+ return k || "stacks-default-key-dev-only-do-not-use-prod";
12
+ }
13
+ function b64UrlEncode(buf) {
14
+ return buf.toString("base64url");
15
+ }
16
+ function b64UrlDecode(s) {
17
+ return Buffer.from(s, "base64url");
18
+ }
19
+ export function createUnsubscribeToken(email, ttlSeconds = DEFAULT_TTL_SECONDS) {
20
+ if (!email)
21
+ throw Error("[email/unsubscribe] email is required");
22
+ const exp = Math.floor(Date.now() / 1000) + Math.floor(ttlSeconds), claims = {
23
+ email: String(email).trim().toLowerCase(),
24
+ exp,
25
+ iss: "stacks"
26
+ }, payload = b64UrlEncode(Buffer.from(JSON.stringify(claims))), sig = b64UrlEncode(createHmac("sha256", getAppKey()).update(payload).digest());
27
+ return `${payload}.${sig}`;
28
+ }
29
+ export function verifyUnsubscribeToken(token) {
30
+ if (typeof token !== "string")
31
+ return { valid: !1, reason: "malformed" };
32
+ const parts = token.split(".");
33
+ if (parts.length !== 2)
34
+ return { valid: !1, reason: "malformed" };
35
+ const [payload, sig] = parts, expectedSig = createHmac("sha256", getAppKey()).update(payload).digest();
36
+ let provided;
37
+ try {
38
+ provided = b64UrlDecode(sig);
39
+ } catch {
40
+ return { valid: !1, reason: "malformed" };
41
+ }
42
+ if (provided.length !== expectedSig.length || !timingSafeEqual(provided, expectedSig))
43
+ return { valid: !1, reason: "bad_signature" };
44
+ let claims;
45
+ try {
46
+ claims = JSON.parse(b64UrlDecode(payload).toString("utf8"));
47
+ } catch {
48
+ return { valid: !1, reason: "malformed" };
49
+ }
50
+ if (typeof claims.exp !== "number" || Math.floor(Date.now() / 1000) >= claims.exp)
51
+ return { valid: !1, reason: "expired" };
52
+ if (!claims.email || typeof claims.email !== "string")
53
+ return { valid: !1, reason: "malformed" };
54
+ return { valid: !0, email: claims.email };
55
+ }
56
+ export function buildUnsubscribeUrl(email, ttlSeconds, options = {}) {
57
+ const token = createUnsubscribeToken(email, ttlSeconds), base = (options.baseUrl || process.env.APP_URL || "http://localhost").replace(/\/$/, ""), route = (options.routePrefix || DEFAULT_ROUTE).replace(/\/$/, "");
58
+ return `${base}${route}/${token}`;
59
+ }
60
+ export function buildListUnsubscribeHeaders(email, ttlSeconds, options) {
61
+ return {
62
+ "List-Unsubscribe": `<${buildUnsubscribeUrl(email, ttlSeconds, options)}>`,
63
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
64
+ };
65
+ }
@@ -0,0 +1,3 @@
1
+ import { notification } from '@stacksjs/config';
2
+ export declare const email: typeof notification.email;
3
+ export default email;
@@ -0,0 +1,3 @@
1
+ import { notification } from "@stacksjs/config";
2
+ export const email = notification.email;
3
+ export default email;
@@ -0,0 +1,22 @@
1
+ export const ENVELOPE_ADDRESS = /^[^\s<>"\\\r\n\t]+@[^\s<>"\\\r\n\t]+$/;
2
+ export function assertEnvelopeAddress(addr, role) {
3
+ if (typeof addr !== "string" || !ENVELOPE_ADDRESS.test(addr))
4
+ throw Error(`Email ${role} address is malformed or contains forbidden characters: ${JSON.stringify(addr)}`);
5
+ }
6
+ export function assertHeaderSafeSubject(subject) {
7
+ if (/[\r\n]/.test(subject))
8
+ throw Error("Email subject contains forbidden line break characters (CR/LF)");
9
+ }
10
+ export function filterStringHeaders(headers) {
11
+ if (!headers)
12
+ return;
13
+ const out = {};
14
+ for (const [k, v] of Object.entries(headers)) {
15
+ if (typeof v !== "string")
16
+ continue;
17
+ if (/[\r\n]/.test(k) || /[\r\n]/.test(v))
18
+ continue;
19
+ out[k] = v;
20
+ }
21
+ return Object.keys(out).length > 0 ? out : void 0;
22
+ }
@@ -0,0 +1,33 @@
1
+ import { db } from "@stacksjs/database";
2
+ let warnedAboutMissingTable = !1;
3
+ function warnOnceAboutMissingTable() {
4
+ if (warnedAboutMissingTable)
5
+ return;
6
+ warnedAboutMissingTable = !0;
7
+ console.warn("[email/webhook-dedup] email_webhook_events table missing \u2014 webhook idempotency NOT enforced. " + "Providers may double-deliver retries; run migrations to enable dedup.");
8
+ }
9
+ function isMissingTableError(err) {
10
+ const msg = err?.message ?? "";
11
+ return msg.includes("no such table") || msg.includes("doesn't exist");
12
+ }
13
+ export async function recordWebhookEventOrSkip(provider, eventId) {
14
+ if (!eventId)
15
+ return !0;
16
+ try {
17
+ await db.insertInto("email_webhook_events").values({
18
+ provider,
19
+ event_id: eventId,
20
+ processed_at: new Date().toISOString().slice(0, 19).replace("T", " ")
21
+ }).execute();
22
+ return !0;
23
+ } catch (err) {
24
+ if (isMissingTableError(err)) {
25
+ warnOnceAboutMissingTable();
26
+ return !0;
27
+ }
28
+ const msg = err?.message ?? "";
29
+ if (msg.includes("UNIQUE constraint") || msg.includes("Duplicate entry"))
30
+ return !1;
31
+ throw err;
32
+ }
33
+ }
@@ -0,0 +1,37 @@
1
+ async function emit(event, payload) {
2
+ try {
3
+ const mod = await import("@stacksjs/events").catch(() => null);
4
+ if (!mod)
5
+ return;
6
+ const dispatch = mod.dispatch;
7
+ if (typeof dispatch !== "function")
8
+ return;
9
+ dispatch(event, payload);
10
+ } catch {}
11
+ }
12
+ export async function emitEmailBounceHard(payload) {
13
+ await emit("email:bounce-hard", payload);
14
+ await emit("email:bounce", payload);
15
+ }
16
+ export async function emitEmailBounceSoft(payload) {
17
+ await emit("email:bounce-soft", payload);
18
+ await emit("email:bounce", payload);
19
+ }
20
+ export async function emitEmailComplaint(payload) {
21
+ await emit("email:complaint", payload);
22
+ }
23
+ export async function emitEmailUnsubscribe(payload) {
24
+ await emit("email:unsubscribe", payload);
25
+ }
26
+ export function suppressionTypeFor(classification) {
27
+ switch (classification) {
28
+ case "bounce-hard":
29
+ return "bounce";
30
+ case "complaint":
31
+ return "complaint";
32
+ case "unsubscribe":
33
+ return "unsubscribe";
34
+ default:
35
+ return null;
36
+ }
37
+ }
@@ -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,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.87",
5
+ "version": "0.70.90",
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.87",
62
- "@stacksjs/config": "0.70.87",
61
+ "@stacksjs/cli": "0.70.90",
62
+ "@stacksjs/config": "0.70.90",
63
63
  "better-dx": "^0.2.16",
64
- "@stacksjs/error-handling": "0.70.87",
65
- "@stacksjs/types": "0.70.87"
64
+ "@stacksjs/error-handling": "0.70.90",
65
+ "@stacksjs/types": "0.70.90"
66
66
  }
67
67
  }