@stacksjs/email 0.70.257 → 0.70.259

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/template.js CHANGED
@@ -1,170 +1,5 @@
1
- import { config } from "@stacksjs/config";
2
- import { log } from "@stacksjs/logging";
3
- import { fs } from "@stacksjs/storage";
4
- import { defaultsResourcesPath, resourcesPath } from "@stacksjs/path";
5
- import { join } from "node:path";
6
- import { inlineCss, shouldInlineByDefault } from "./css-inliner";
1
+ import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";import{fs}from"@stacksjs/storage";import{defaultsResourcesPath,resourcesPath}from"@stacksjs/path";import{join}from"node:path";import{inlineCss,shouldInlineByDefault}from"./css-inliner";export class SafeHtml{value;__safeHtml=!0;constructor(value){this.value=value}}export function safe(html){return new SafeHtml(html)}function getDefaultVariables(){const primaryColor=config.app.primaryColor||"#3b82f6";return{appName:config.app.name||"Stacks",appUrl:config.app.url||"https://localhost",primaryColor,primaryColorDark:darkenColor(primaryColor,15),year:new Date().getFullYear()}}function darkenColor(hex,percent){const num=Number.parseInt(hex.replace("#",""),16),amt=Math.round(2.55*percent),R=Math.max(0,(num>>16)-amt),G=Math.max(0,(num>>8&255)-amt),B=Math.max(0,(num&255)-amt);return`#${(16777216+R*65536+G*256+B).toString(16).slice(1)}`}function escapeHtml(input){return input.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function replaceVariables(html,variables){let result=html;for(const[key,value]of Object.entries(variables)){const escapedKey=key.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),regex=new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`,"g");result=result.replace(regex,renderTemplateValue(value))}return result}function renderTemplateValue(value){if(value===null||value===void 0)return"";if(value instanceof SafeHtml)return value.value;return escapeHtml(String(value))}const templateRoots=[(relativePath)=>resourcesPath(join("emails",relativePath)),(relativePath)=>defaultsResourcesPath(join("emails",relativePath))];function resolveTemplatePath(templateName){for(const root of templateRoots){if(templateName.endsWith(".stx")){const fullPath=root(templateName);if(fs.existsSync(fullPath))return{path:fullPath,type:"stx"};continue}if(templateName.endsWith(".html")){const fullPath=root(templateName);if(fs.existsSync(fullPath))return{path:fullPath,type:"html"};continue}const stxPath=root(`${templateName}.stx`);if(fs.existsSync(stxPath))return{path:stxPath,type:"stx"};const htmlPath=root(`${templateName}.html`);if(fs.existsSync(htmlPath))return{path:htmlPath,type:"html"}}return null}function loadHtmlTemplate(templatePath){const path=templatePath.endsWith(".html")?templatePath:`${templatePath}.html`;for(const root of templateRoots){const fullPath=root(path);if(fs.existsSync(fullPath))return fs.readFileSync(fullPath,"utf-8")}return null}function loadLayout(layoutName){return loadHtmlTemplate(`layouts/${layoutName}`)}function htmlToText(html){return html.replace(/<br\s*\/?>/gi,`
2
+ `).replace(/<\/(p|div|h[1-6]|li|tr)>/gi,`
3
+ `).replace(/<\/td>/gi,"\t").replace(/<[^>]*>/g,"").replace(/&nbsp;/g," ").replace(/&amp;/g,"&").replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&copy;/g,"(c)").replace(/\n\s*\n\s*\n/g,`
7
4
 
8
- export class SafeHtml {
9
- value;
10
- __safeHtml = !0;
11
- constructor(value) {
12
- this.value = value;
13
- }
14
- }
15
- export function safe(html) {
16
- return new SafeHtml(html);
17
- }
18
- function getDefaultVariables() {
19
- const primaryColor = config.app.primaryColor || "#3b82f6";
20
- return {
21
- appName: config.app.name || "Stacks",
22
- appUrl: config.app.url || "https://localhost",
23
- primaryColor,
24
- primaryColorDark: darkenColor(primaryColor, 15),
25
- year: new Date().getFullYear()
26
- };
27
- }
28
- function darkenColor(hex, percent) {
29
- const num = Number.parseInt(hex.replace("#", ""), 16), amt = Math.round(2.55 * percent), R = Math.max(0, (num >> 16) - amt), G = Math.max(0, (num >> 8 & 255) - amt), B = Math.max(0, (num & 255) - amt);
30
- return `#${(16777216 + R * 65536 + G * 256 + B).toString(16).slice(1)}`;
31
- }
32
- function escapeHtml(input) {
33
- return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
- }
35
- function replaceVariables(html, variables) {
36
- let result = html;
37
- for (const [key, value] of Object.entries(variables)) {
38
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), regex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, "g");
39
- result = result.replace(regex, renderTemplateValue(value));
40
- }
41
- return result;
42
- }
43
- function renderTemplateValue(value) {
44
- if (value === null || value === void 0)
45
- return "";
46
- if (value instanceof SafeHtml)
47
- return value.value;
48
- return escapeHtml(String(value));
49
- }
50
- const templateRoots = [
51
- (relativePath) => resourcesPath(join("emails", relativePath)),
52
- (relativePath) => defaultsResourcesPath(join("emails", relativePath))
53
- ];
54
- function resolveTemplatePath(templateName) {
55
- for (const root of templateRoots) {
56
- if (templateName.endsWith(".stx")) {
57
- const fullPath = root(templateName);
58
- if (fs.existsSync(fullPath))
59
- return { path: fullPath, type: "stx" };
60
- continue;
61
- }
62
- if (templateName.endsWith(".html")) {
63
- const fullPath = root(templateName);
64
- if (fs.existsSync(fullPath))
65
- return { path: fullPath, type: "html" };
66
- continue;
67
- }
68
- const stxPath = root(`${templateName}.stx`);
69
- if (fs.existsSync(stxPath))
70
- return { path: stxPath, type: "stx" };
71
- const htmlPath = root(`${templateName}.html`);
72
- if (fs.existsSync(htmlPath))
73
- return { path: htmlPath, type: "html" };
74
- }
75
- return null;
76
- }
77
- function loadHtmlTemplate(templatePath) {
78
- const path = templatePath.endsWith(".html") ? templatePath : `${templatePath}.html`;
79
- for (const root of templateRoots) {
80
- const fullPath = root(path);
81
- if (fs.existsSync(fullPath))
82
- return fs.readFileSync(fullPath, "utf-8");
83
- }
84
- return null;
85
- }
86
- function loadLayout(layoutName) {
87
- return loadHtmlTemplate(`layouts/${layoutName}`);
88
- }
89
- function htmlToText(html) {
90
- return html.replace(/<br\s*\/?>/gi, `
91
- `).replace(/<\/(p|div|h[1-6]|li|tr)>/gi, `
92
- `).replace(/<\/td>/gi, "\t").replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&copy;/g, "(c)").replace(/\n\s*\n\s*\n/g, `
93
-
94
- `).trim();
95
- }
96
- export async function template(templateName, options = {}) {
97
- const {
98
- variables = {},
99
- layout = "base",
100
- subject = "",
101
- inline = shouldInlineByDefault()
102
- } = options, allVariables = {
103
- ...getDefaultVariables(),
104
- subject,
105
- ...variables
106
- }, resolved = resolveTemplatePath(templateName);
107
- if (!resolved) {
108
- console.warn(`[Email Template] Template "${templateName}" not found`);
109
- return { html: "", text: "" };
110
- }
111
- if (resolved.type === "stx")
112
- try {
113
- const { renderEmail } = await import("@stacksjs/stx"), result = await renderEmail(resolved.path, allVariables, {
114
- componentsDir: defaultsResourcesPath("components/Email")
115
- });
116
- return {
117
- ...result,
118
- html: inlineCss(result.html, { inline })
119
- };
120
- } catch (error) {
121
- log.warn(`[email] STX template rendering failed for ${templateName}: ${error instanceof Error ? error.message : String(error)}`);
122
- return { html: "", text: "" };
123
- }
124
- let content = fs.readFileSync(resolved.path, "utf-8");
125
- content = replaceVariables(content, allVariables);
126
- let html;
127
- if (layout !== !1) {
128
- const layoutHtml = loadLayout(layout);
129
- if (!layoutHtml) {
130
- console.warn(`[Email Template] Layout "${layout}" not found, using content only`);
131
- html = content;
132
- } else {
133
- allVariables.content = safe(content);
134
- html = replaceVariables(layoutHtml, allVariables);
135
- }
136
- } else
137
- html = content;
138
- html = inlineCss(html, { inline });
139
- const text = htmlToText(html);
140
- return { html, text };
141
- }
142
- export function renderHtml(htmlContent, variables = {}) {
143
- const allVariables = {
144
- ...getDefaultVariables(),
145
- ...variables
146
- }, html = replaceVariables(htmlContent, allVariables), text = htmlToText(html);
147
- return { html, text };
148
- }
149
- export function templateExists(templateName) {
150
- return resolveTemplatePath(templateName) !== null;
151
- }
152
- export function listTemplates() {
153
- const emailsPath = resourcesPath("emails");
154
- if (!fs.existsSync(emailsPath))
155
- return [];
156
- const templates = [];
157
- function scanDir(dir, prefix = "") {
158
- const entries = fs.readdirSync(dir, { withFileTypes: !0 });
159
- for (const entry of entries)
160
- if (entry.isDirectory() && entry.name !== "layouts")
161
- scanDir(join(dir, entry.name), `${prefix}${entry.name}/`);
162
- else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".stx"))) {
163
- const name = entry.name.replace(/\.(html|stx)$/, "");
164
- if (!templates.includes(`${prefix}${name}`))
165
- templates.push(`${prefix}${name}`);
166
- }
167
- }
168
- scanDir(emailsPath);
169
- return templates;
170
- }
5
+ `).trim()}export async function template(templateName,options={}){const{variables={},layout="base",subject="",inline=shouldInlineByDefault()}=options,allVariables={...getDefaultVariables(),subject,...variables},resolved=resolveTemplatePath(templateName);if(!resolved){console.warn(`[Email Template] Template "${templateName}" not found`);return{html:"",text:""}}if(resolved.type==="stx")try{const{renderEmail}=await import("@stacksjs/stx"),result=await renderEmail(resolved.path,allVariables,{componentsDir:defaultsResourcesPath("components/Email")});return{...result,html:inlineCss(result.html,{inline})}}catch(error){log.warn(`[email] STX template rendering failed for ${templateName}: ${error instanceof Error?error.message:String(error)}`);return{html:"",text:""}}let content=fs.readFileSync(resolved.path,"utf-8");content=replaceVariables(content,allVariables);let html;if(layout!==!1){const layoutHtml=loadLayout(layout);if(!layoutHtml){console.warn(`[Email Template] Layout "${layout}" not found, using content only`);html=content}else{allVariables.content=safe(content);html=replaceVariables(layoutHtml,allVariables)}}else html=content;html=inlineCss(html,{inline});const text=htmlToText(html);return{html,text}}export function renderHtml(htmlContent,variables={}){const allVariables={...getDefaultVariables(),...variables},html=replaceVariables(htmlContent,allVariables),text=htmlToText(html);return{html,text}}export function templateExists(templateName){return resolveTemplatePath(templateName)!==null}export function listTemplates(){const emailsPath=resourcesPath("emails");if(!fs.existsSync(emailsPath))return[];const templates=[];function scanDir(dir,prefix=""){const entries=fs.readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory()&&entry.name!=="layouts")scanDir(join(dir,entry.name),`${prefix}${entry.name}/`);else if(entry.isFile()&&(entry.name.endsWith(".html")||entry.name.endsWith(".stx"))){const name=entry.name.replace(/\.(html|stx)$/,"");if(!templates.includes(`${prefix}${name}`))templates.push(`${prefix}${name}`)}}scanDir(emailsPath);return templates}
@@ -1,65 +1 @@
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
- }
1
+ import{createHmac,timingSafeEqual}from"node:crypto";import process from"node:process";import{Buffer}from"node:buffer";const DEFAULT_TTL_SECONDS=2592000,DEFAULT_ROUTE="/_stacks/email/unsubscribe";function getAppKey(){const k=process.env.APP_KEY;if(!k||k.length<16){if(process.env.APP_ENV==="production"||process.env.NODE_ENV==="production")throw Error("[email/unsubscribe] APP_KEY is missing or too short (need \u226516 chars). Cannot sign unsubscribe URL.")}return k||"stacks-default-key-dev-only-do-not-use-prod"}function b64UrlEncode(buf){return buf.toString("base64url")}function b64UrlDecode(s){return Buffer.from(s,"base64url")}export function createUnsubscribeToken(email,ttlSeconds=DEFAULT_TTL_SECONDS){if(!email)throw Error("[email/unsubscribe] email is required");const exp=Math.floor(Date.now()/1000)+Math.floor(ttlSeconds),claims={email:String(email).trim().toLowerCase(),exp,iss:"stacks"},payload=b64UrlEncode(Buffer.from(JSON.stringify(claims))),sig=b64UrlEncode(createHmac("sha256",getAppKey()).update(payload).digest());return`${payload}.${sig}`}export function verifyUnsubscribeToken(token){if(typeof token!=="string")return{valid:!1,reason:"malformed"};const parts=token.split(".");if(parts.length!==2)return{valid:!1,reason:"malformed"};const[payload,sig]=parts,expectedSig=createHmac("sha256",getAppKey()).update(payload).digest();let provided;try{provided=b64UrlDecode(sig)}catch{return{valid:!1,reason:"malformed"}}if(provided.length!==expectedSig.length||!timingSafeEqual(provided,expectedSig))return{valid:!1,reason:"bad_signature"};let claims;try{claims=JSON.parse(b64UrlDecode(payload).toString("utf8"))}catch{return{valid:!1,reason:"malformed"}}if(typeof claims.exp!=="number"||Math.floor(Date.now()/1000)>=claims.exp)return{valid:!1,reason:"expired"};if(!claims.email||typeof claims.email!=="string")return{valid:!1,reason:"malformed"};return{valid:!0,email:claims.email}}export function buildUnsubscribeUrl(email,ttlSeconds,options={}){const token=createUnsubscribeToken(email,ttlSeconds),base=(options.baseUrl||process.env.APP_URL||"http://localhost").replace(/\/$/,""),route=(options.routePrefix||DEFAULT_ROUTE).replace(/\/$/,"");return`${base}${route}/${token}`}export function buildListUnsubscribeHeaders(email,ttlSeconds,options){return{"List-Unsubscribe":`<${buildUnsubscribeUrl(email,ttlSeconds,options)}>`,"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}
@@ -1,3 +1 @@
1
- import { notification } from "@stacksjs/config";
2
- export const email = notification.email;
3
- export default email;
1
+ import{notification}from"@stacksjs/config";export const email=notification.email;export default email;
@@ -1,22 +1 @@
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
- }
1
+ export const ENVELOPE_ADDRESS=/^[^\s<>"\\\r\n\t]+@[^\s<>"\\\r\n\t]+$/;export function assertEnvelopeAddress(addr,role){if(typeof addr!=="string"||!ENVELOPE_ADDRESS.test(addr))throw Error(`Email ${role} address is malformed or contains forbidden characters: ${JSON.stringify(addr)}`)}export function assertHeaderSafeSubject(subject){if(/[\r\n]/.test(subject))throw Error("Email subject contains forbidden line break characters (CR/LF)")}export function filterStringHeaders(headers){if(!headers)return;const out={};for(const[k,v]of Object.entries(headers)){if(typeof v!=="string")continue;if(/[\r\n]/.test(k)||/[\r\n]/.test(v))continue;out[k]=v}return Object.keys(out).length>0?out:void 0}
@@ -1,33 +1 @@
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
- }
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;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.")}function isMissingTableError(err){const msg=err?.message??"";return msg.includes("no such table")||msg.includes("doesn't exist")}export async function recordWebhookEventOrSkip(provider,eventId){if(!eventId)return!0;try{await db.insertInto("email_webhook_events").values({provider,event_id:eventId,processed_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!0}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return!1;throw err}}
@@ -1,37 +1 @@
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
- }
1
+ async function emit(event,payload){try{const mod=await import("@stacksjs/events").catch(()=>null);if(!mod)return;const dispatch=mod.dispatch;if(typeof dispatch!=="function")return;dispatch(event,payload)}catch{}}export async function emitEmailBounceHard(payload){await emit("email:bounce-hard",payload);await emit("email:bounce",payload)}export async function emitEmailBounceSoft(payload){await emit("email:bounce-soft",payload);await emit("email:bounce",payload)}export async function emitEmailComplaint(payload){await emit("email:complaint",payload)}export async function emitEmailUnsubscribe(payload){await emit("email:unsubscribe",payload)}export function suppressionTypeFor(classification){switch(classification){case"bounce-hard":return"bounce";case"complaint":return"complaint";case"unsubscribe":return"unsubscribe";default:return null}}
@@ -1,264 +1 @@
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
- }
1
+ import{Buffer}from"node:buffer";import{suppress}from"./suppression";import{recordWebhookEventOrSkip}from"./webhook-dedup";import{emitEmailBounceHard,emitEmailBounceSoft,emitEmailComplaint,emitEmailUnsubscribe,suppressionTypeFor}from"./webhook-events";import{verifyMailgunSignature,verifyPostmarkAuth,verifySendgridSignature,verifySesSnsSignature}from"./webhook-signatures";const OK_DUPLICATE={status:200,body:{ok:!0,processed:!1,reason:"duplicate"}};function unauthorized(reason){return{status:401,body:{ok:!1,reason}}}function badRequest(reason){return{status:400,body:{ok:!1,reason}}}async function dispatchClassifiedEvent(classification,payload){const suppressionType=suppressionTypeFor(classification);if(suppressionType)await suppress(payload.email,suppressionType,payload.reason);switch(classification){case"bounce-hard":await emitEmailBounceHard(payload);break;case"bounce-soft":await emitEmailBounceSoft(payload);break;case"complaint":await emitEmailComplaint(payload);break;case"unsubscribe":await emitEmailUnsubscribe(payload);break;case"delivered":break}}export async function handleMailgunWebhook(rawBody,config){let parsed;try{parsed=JSON.parse(rawBody)}catch{return badRequest("invalid-json")}const sig=parsed.signature,event=parsed["event-data"];if(!sig||!event)return badRequest("missing-fields");const v=verifyMailgunSignature({timestamp:sig.timestamp,token:sig.token,signature:sig.signature,signingKey:config.signingKey,toleranceSeconds:config.toleranceSeconds});if(!v.ok)return unauthorized(v.reason);if(!await recordWebhookEventOrSkip("mailgun",event.id))return OK_DUPLICATE;const classification=classifyMailgunEvent(event.event,event.severity);if(!classification)return{status:200,body:{ok:!0,processed:!1,reason:"unhandled-event"}};await dispatchClassifiedEvent(classification,{email:event.recipient,provider:"mailgun",reason:event.reason,raw:event});return{status:200,body:{ok:!0,processed:!0,classification}}}function classifyMailgunEvent(event,severity){switch(event){case"failed":return severity==="temporary"?"bounce-soft":"bounce-hard";case"complained":return"complaint";case"unsubscribed":return"unsubscribe";case"delivered":return"delivered";default:return null}}export async function handlePostmarkWebhook(rawBody,authorizationHeader,sourceIp,config){const v=verifyPostmarkAuth({authorizationHeader,expectedUsername:config.username,expectedPassword:config.password,sourceIp,ipAllowlist:config.ipAllowlist});if(!v.ok)return unauthorized(v.reason);let parsed;try{parsed=JSON.parse(rawBody)}catch{return badRequest("invalid-json")}const eventId=String(parsed.ID??parsed.MessageID??""),email=String(parsed.Email??parsed.Recipient??"");if(!email)return badRequest("missing-recipient");if(!await recordWebhookEventOrSkip("postmark",eventId))return OK_DUPLICATE;const classification=classifyPostmarkEvent(parsed);if(!classification)return{status:200,body:{ok:!0,processed:!1,reason:"unhandled-event"}};await dispatchClassifiedEvent(classification,{email,provider:"postmark",reason:parsed.Description,raw:parsed});return{status:200,body:{ok:!0,processed:!0,classification}}}function classifyPostmarkEvent(msg){switch(msg.RecordType){case"Bounce":return msg.TypeCode===1?"bounce-hard":"bounce-soft";case"SpamComplaint":return"complaint";case"SubscriptionChange":return msg.SuppressSending?"unsubscribe":null;case"Delivery":return"delivered";default:return null}}export async function handleSesWebhook(rawBody,config={}){let snsMessage;try{snsMessage=JSON.parse(rawBody)}catch{return badRequest("invalid-json")}const v=await verifySesSnsSignature({message:snsMessage,certUrlHostAllowlist:config.certUrlHostAllowlist,fetchCert:config.fetchCert});if(!v.ok)return unauthorized(v.reason);if(snsMessage.Type==="SubscriptionConfirmation"){if(config.autoConfirmSubscriptions!==!1&&snsMessage.SubscribeURL)try{await fetch(snsMessage.SubscribeURL)}catch{}return{status:200,body:{ok:!0,processed:!0,reason:"subscription-confirmed"}}}if(snsMessage.Type==="UnsubscribeConfirmation")return{status:200,body:{ok:!0,processed:!0,reason:"subscription-removed"}};let innerMessage;try{innerMessage=JSON.parse(snsMessage.Message)}catch{return badRequest("invalid-inner-json")}if(!await recordWebhookEventOrSkip("ses",snsMessage.MessageId))return OK_DUPLICATE;const dispatched=[];if(innerMessage.notificationType==="Bounce"&&innerMessage.bounce){const classification=innerMessage.bounce.bounceType==="Permanent"?"bounce-hard":"bounce-soft";for(const r of innerMessage.bounce.bouncedRecipients){await dispatchClassifiedEvent(classification,{email:r.emailAddress,provider:"ses",reason:r.diagnosticCode,raw:innerMessage});dispatched.push(classification)}}else if(innerMessage.notificationType==="Complaint"&&innerMessage.complaint)for(const r of innerMessage.complaint.complainedRecipients){await dispatchClassifiedEvent("complaint",{email:r.emailAddress,provider:"ses",reason:innerMessage.complaint.complaintFeedbackType,raw:innerMessage});dispatched.push("complaint")}else if(innerMessage.notificationType==="Delivery"&&innerMessage.delivery)for(const r of innerMessage.delivery.recipients){await dispatchClassifiedEvent("delivered",{email:r,provider:"ses",raw:innerMessage});dispatched.push("delivered")}return{status:200,body:{ok:!0,processed:dispatched.length>0,classification:dispatched[0]}}}export async function handleSendgridWebhook(rawBody,signatureHeader,timestampHeader,config){const v=verifySendgridSignature({body:rawBody,signature:signatureHeader,timestamp:timestampHeader,publicKeyPem:config.publicKeyPem,toleranceSeconds:config.toleranceSeconds});if(!v.ok)return unauthorized(v.reason);let events;try{events=JSON.parse(rawBody)}catch{return badRequest("invalid-json")}if(!Array.isArray(events))return badRequest("expected-array");let processed=0;const classifications=[];for(const ev of events){if(!ev.email)continue;const id=ev.sg_event_id??`${ev.event}:${ev.email}:${Date.now()}`;if(!await recordWebhookEventOrSkip("sendgrid",id))continue;const classification=classifySendgridEvent(ev.event,ev.type);if(!classification)continue;await dispatchClassifiedEvent(classification,{email:ev.email,provider:"sendgrid",reason:ev.reason,raw:ev});processed++;classifications.push(classification)}return{status:200,body:{ok:!0,processed:processed>0,classification:classifications[0]}}}function classifySendgridEvent(event,type){switch(event){case"bounce":return type==="blocked"?"bounce-soft":"bounce-hard";case"dropped":return"bounce-hard";case"spamreport":return"complaint";case"unsubscribe":return"unsubscribe";case"group_unsubscribe":return"unsubscribe";case"delivered":return"delivered";default:return null}}