@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.
Files changed (65) hide show
  1. package/dist/css-inliner.d.ts +26 -0
  2. package/dist/css-inliner.js +172 -0
  3. package/dist/drivers/base.d.ts +14 -0
  4. package/dist/drivers/base.js +128 -0
  5. package/dist/drivers/capture.d.ts +55 -0
  6. package/dist/drivers/capture.js +30 -0
  7. package/dist/drivers/index.d.ts +10 -0
  8. package/dist/drivers/index.js +6 -0
  9. package/dist/drivers/log.d.ts +30 -0
  10. package/dist/drivers/log.js +81 -0
  11. package/dist/drivers/mailgun.d.ts +8 -0
  12. package/dist/drivers/mailgun.js +127 -0
  13. package/dist/drivers/mailtrap.d.ts +8 -0
  14. package/dist/drivers/mailtrap.js +129 -0
  15. package/dist/drivers/sendgrid.d.ts +8 -0
  16. package/dist/drivers/sendgrid.js +136 -0
  17. package/dist/drivers/ses.d.ts +8 -0
  18. package/dist/drivers/ses.js +113 -0
  19. package/dist/drivers/smtp.d.ts +14 -0
  20. package/dist/drivers/smtp.js +225 -0
  21. package/dist/email.d.ts +39 -0
  22. package/dist/email.js +198 -0
  23. package/dist/idempotency.d.ts +20 -0
  24. package/dist/idempotency.js +56 -0
  25. package/dist/index.d.ts +22 -0
  26. package/dist/index.js +19 -0
  27. package/dist/mailable.d.ts +116 -0
  28. package/dist/mailable.js +145 -0
  29. package/dist/mime.d.ts +37 -0
  30. package/dist/mime.js +89 -0
  31. package/dist/preview-ui.d.ts +11 -0
  32. package/dist/preview-ui.js +132 -0
  33. package/dist/preview.d.ts +50 -0
  34. package/dist/preview.js +97 -0
  35. package/dist/sdk/index.d.ts +81 -0
  36. package/dist/sdk/index.js +219 -0
  37. package/dist/send.d.ts +1 -0
  38. package/dist/send.js +0 -0
  39. package/dist/server/converter.d.ts +1 -0
  40. package/dist/server/converter.js +0 -0
  41. package/dist/server/inbound.d.ts +1 -0
  42. package/dist/server/inbound.js +0 -0
  43. package/dist/server/outbound.d.ts +1 -0
  44. package/dist/server/outbound.js +0 -0
  45. package/dist/suppression.d.ts +82 -0
  46. package/dist/suppression.js +104 -0
  47. package/dist/template.d.ts +99 -0
  48. package/dist/template.js +170 -0
  49. package/dist/types.d.ts +37 -0
  50. package/dist/types.js +0 -0
  51. package/dist/unsubscribe.d.ts +39 -0
  52. package/dist/unsubscribe.js +65 -0
  53. package/dist/utils/config.d.ts +3 -0
  54. package/dist/utils/config.js +3 -0
  55. package/dist/validation.d.ts +48 -0
  56. package/dist/validation.js +22 -0
  57. package/dist/webhook-dedup.d.ts +10 -0
  58. package/dist/webhook-dedup.js +33 -0
  59. package/dist/webhook-events.d.ts +34 -0
  60. package/dist/webhook-events.js +37 -0
  61. package/dist/webhook-handlers.d.ts +27 -0
  62. package/dist/webhook-handlers.js +264 -0
  63. package/dist/webhook-signatures.d.ts +91 -0
  64. package/dist/webhook-signatures.js +148 -0
  65. package/package.json +5 -5
@@ -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, "&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
+ }
@@ -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,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,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
+ }