@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
package/dist/template.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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";
|
|
7
|
+
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/©/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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare interface Message {
|
|
2
|
+
name: string
|
|
3
|
+
subject: string
|
|
4
|
+
to: string | string[]
|
|
5
|
+
from?: {
|
|
6
|
+
name: string
|
|
7
|
+
address: string
|
|
8
|
+
}
|
|
9
|
+
template: string
|
|
10
|
+
handle?: () => Promise<{ message: string }>
|
|
11
|
+
onError?: (error: Error) => Promise<{ message: string }>
|
|
12
|
+
onSuccess?: () => void
|
|
13
|
+
}
|
|
14
|
+
export declare interface SendEmailParams {
|
|
15
|
+
Source: string
|
|
16
|
+
Destination: {
|
|
17
|
+
ToAddresses: string[]
|
|
18
|
+
}
|
|
19
|
+
Message: {
|
|
20
|
+
Body: {
|
|
21
|
+
Html: {
|
|
22
|
+
Charset: 'UTF-8'
|
|
23
|
+
Data: string
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
Subject: {
|
|
27
|
+
Charset: 'UTF-8'
|
|
28
|
+
Data: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export declare interface EmailParams {
|
|
33
|
+
to: string
|
|
34
|
+
from: string
|
|
35
|
+
subject: string
|
|
36
|
+
html: string
|
|
37
|
+
}
|
package/dist/types.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a signed unsubscribe token for `email`. Default expiry is
|
|
3
|
+
* 30 days — long enough that the link in an archived email still
|
|
4
|
+
* works months later, short enough that a leaked URL doesn't grant
|
|
5
|
+
* indefinite control. Tighter caps available via `ttlSeconds`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createUnsubscribeToken(email: string, ttlSeconds?: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Verify a signed unsubscribe token. Returns the email + a
|
|
10
|
+
* discriminated outcome — callers map invalid results to a 400/410
|
|
11
|
+
* response and the success case writes the suppression record.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verifyUnsubscribeToken(token: string): UnsubscribeVerification;
|
|
14
|
+
/**
|
|
15
|
+
* Build the full opt-out URL for `email`. Combines the configured
|
|
16
|
+
* route prefix (`email.unsubscribeRoute`, defaults to
|
|
17
|
+
* `/_stacks/email/unsubscribe`) with the app's public URL (`APP_URL`
|
|
18
|
+
* env var) and the signed token.
|
|
19
|
+
*
|
|
20
|
+
* Pass the result into email bodies / `List-Unsubscribe` headers —
|
|
21
|
+
* see {@link buildListUnsubscribeHeaders} for RFC 8058
|
|
22
|
+
* (one-click) compatibility.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildUnsubscribeUrl(email: string, ttlSeconds?: number, options?: { baseUrl?: string, routePrefix?: string }): string;
|
|
25
|
+
/**
|
|
26
|
+
* Build a `List-Unsubscribe` / `List-Unsubscribe-Post` header
|
|
27
|
+
* pair (RFC 8058). Gmail/Apple Mail use these for the native
|
|
28
|
+
* "Unsubscribe" button — without them the user has to find your
|
|
29
|
+
* footer link.
|
|
30
|
+
*
|
|
31
|
+
* Returns a map suitable for passing into `EmailMessage.headers`
|
|
32
|
+
* (or merging with existing headers).
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildListUnsubscribeHeaders(email: string, ttlSeconds?: number, options?: { baseUrl?: string, routePrefix?: string }): Record<string, string>;
|
|
35
|
+
export declare interface UnsubscribeVerification {
|
|
36
|
+
valid: boolean
|
|
37
|
+
reason?: 'malformed' | 'bad_signature' | 'expired'
|
|
38
|
+
email?: string
|
|
39
|
+
}
|
|
@@ -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,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw if `addr` isn't a clean envelope address. The error message
|
|
3
|
+
* includes the role (`to` / `cc` / etc.) so log scrapers can grep for
|
|
4
|
+
* the offending slot without parsing the rest.
|
|
5
|
+
*/
|
|
6
|
+
export declare function assertEnvelopeAddress(addr: unknown, role: string): void;
|
|
7
|
+
/**
|
|
8
|
+
* Reject subject lines containing CR or LF — they become header
|
|
9
|
+
* fields on the wire, and a newline in the value lets an attacker
|
|
10
|
+
* inject additional headers (BCC leak, Reply-To override).
|
|
11
|
+
*
|
|
12
|
+
* Centralized here so the check applies uniformly across drivers
|
|
13
|
+
* (stacksjs/stacks#1871 M-6).
|
|
14
|
+
*/
|
|
15
|
+
export declare function assertHeaderSafeSubject(subject: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Filter a `message.headers` map down to entries whose value is a
|
|
18
|
+
* string and whose name/value contain no CR/LF (header injection
|
|
19
|
+
* vector). Returns undefined when nothing usable remains so caller-
|
|
20
|
+
* sites can spread the result without sending an empty `headers: {}`
|
|
21
|
+
* payload field.
|
|
22
|
+
*
|
|
23
|
+
* Used by every driver that consumes {@link EmailMessage.headers}
|
|
24
|
+
* (stacksjs/stacks#1871 M-5). Centralizing the CR/LF guard ensures
|
|
25
|
+
* the SES `Headers` slot, the SendGrid `headers` field, and the
|
|
26
|
+
* Mailtrap `headers` map all reject the same injection-shaped values.
|
|
27
|
+
*/
|
|
28
|
+
export declare function filterStringHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Email validation primitives shared by every driver + the base
|
|
31
|
+
* driver class. Pulled into a module so the `ENVELOPE_ADDRESS` regex
|
|
32
|
+
* lives in exactly one place — the audit (stacksjs/stacks#1871 M-6)
|
|
33
|
+
* called out a copy in `drivers/base.ts` and a copy in `drivers/smtp.ts`,
|
|
34
|
+
* and they were already starting to drift.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* RFC 5321-ish envelope-address shape. Intentionally tighter than the
|
|
38
|
+
* full RFC because the broader form (display names, comments, source
|
|
39
|
+
* routes) has no business in the envelope slots that hit the wire as
|
|
40
|
+
* raw header values: `to` / `cc` / `bcc` / `from` / `replyTo`.
|
|
41
|
+
*
|
|
42
|
+
* Rejects:
|
|
43
|
+
* - whitespace, including CR / LF / tab (header-injection vectors)
|
|
44
|
+
* - angle brackets / quotes / backslashes (header parser confusion)
|
|
45
|
+
*
|
|
46
|
+
* Requires a single `@` separating local part and domain.
|
|
47
|
+
*/
|
|
48
|
+
export declare const ENVELOPE_ADDRESS: unknown;
|
|
@@ -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,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record an event-id as processed. Returns `true` if this is the
|
|
3
|
+
* first time we've seen the id (caller should process), `false`
|
|
4
|
+
* if it's a duplicate (caller should ack-and-skip).
|
|
5
|
+
*
|
|
6
|
+
* When the table doesn't exist, falls through to "always first" +
|
|
7
|
+
* warn-once — apps without the migration still work; they just
|
|
8
|
+
* double-process if the provider retries.
|
|
9
|
+
*/
|
|
10
|
+
export declare function recordWebhookEventOrSkip(provider: 'mailgun' | 'postmark' | 'ses' | 'sendgrid', eventId: string): Promise<boolean>;
|
|
@@ -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,34 @@
|
|
|
1
|
+
import type { SuppressionType } from './suppression';
|
|
2
|
+
/** Fired when a provider reports a hard bounce. */
|
|
3
|
+
export declare function emitEmailBounceHard(payload: EmailEventPayload): Promise<void>;
|
|
4
|
+
/** Fired when a provider reports a soft bounce (transient). */
|
|
5
|
+
export declare function emitEmailBounceSoft(payload: EmailEventPayload): Promise<void>;
|
|
6
|
+
/** Fired when a provider reports a complaint (user marked spam). */
|
|
7
|
+
export declare function emitEmailComplaint(payload: EmailEventPayload): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Fired when the user clicks the framework's signed unsubscribe URL
|
|
10
|
+
* AND when a provider's "unsubscribed" webhook event arrives. Both
|
|
11
|
+
* sources land in the same listener so apps only have to wire up
|
|
12
|
+
* one code path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function emitEmailUnsubscribe(payload: EmailEventPayload): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Map a classified event to the suppression type that should be
|
|
17
|
+
* recorded. Soft bounces are intentionally NOT auto-suppressed —
|
|
18
|
+
* they're transient and the next send is likely to succeed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function suppressionTypeFor(classification: EmailEventClassification): SuppressionType | null;
|
|
21
|
+
/**
|
|
22
|
+
* Payload shape every email-event listener receives. Always
|
|
23
|
+
* includes the recipient + provider + the provider's raw event
|
|
24
|
+
* payload so listeners can branch on provider-specific fields
|
|
25
|
+
* without re-parsing.
|
|
26
|
+
*/
|
|
27
|
+
export declare interface EmailEventPayload {
|
|
28
|
+
email: string
|
|
29
|
+
provider: 'mailgun' | 'postmark' | 'ses' | 'sendgrid'
|
|
30
|
+
reason?: string
|
|
31
|
+
raw: unknown
|
|
32
|
+
}
|
|
33
|
+
export type EmailBounceType = 'hard' | 'soft';
|
|
34
|
+
export type EmailEventClassification = 'bounce-hard' | 'bounce-soft' | 'complaint' | 'unsubscribe' | 'delivered';
|
|
@@ -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,27 @@
|
|
|
1
|
+
import type { EmailEventClassification } from './webhook-events';
|
|
2
|
+
export declare function handleMailgunWebhook(rawBody: string, config: MailgunWebhookConfig): Promise<WebhookResult>;
|
|
3
|
+
export declare function handlePostmarkWebhook(rawBody: string, authorizationHeader: string | null, sourceIp: string | undefined, config: PostmarkWebhookConfig): Promise<WebhookResult>;
|
|
4
|
+
export declare function handleSesWebhook(rawBody: string, config?: SesWebhookConfig): Promise<WebhookResult>;
|
|
5
|
+
export declare function handleSendgridWebhook(rawBody: string, signatureHeader: string | null, timestampHeader: string | null, config: SendgridWebhookConfig): Promise<WebhookResult>;
|
|
6
|
+
export declare interface WebhookResult {
|
|
7
|
+
status: number
|
|
8
|
+
body: { ok: boolean, reason?: string, processed?: boolean, classification?: EmailEventClassification }
|
|
9
|
+
}
|
|
10
|
+
export declare interface MailgunWebhookConfig {
|
|
11
|
+
signingKey: string
|
|
12
|
+
toleranceSeconds?: number
|
|
13
|
+
}
|
|
14
|
+
export declare interface PostmarkWebhookConfig {
|
|
15
|
+
username: string
|
|
16
|
+
password: string
|
|
17
|
+
ipAllowlist?: ReadonlyArray<string>
|
|
18
|
+
}
|
|
19
|
+
export declare interface SesWebhookConfig {
|
|
20
|
+
certUrlHostAllowlist?: RegExp
|
|
21
|
+
fetchCert?: (url: string) => Promise<string>
|
|
22
|
+
autoConfirmSubscriptions?: boolean
|
|
23
|
+
}
|
|
24
|
+
export declare interface SendgridWebhookConfig {
|
|
25
|
+
publicKeyPem: string
|
|
26
|
+
toleranceSeconds?: number
|
|
27
|
+
}
|