@stacksjs/email 0.70.87 → 0.70.88

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/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.88",
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.88",
62
+ "@stacksjs/config": "0.70.88",
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.88",
65
+ "@stacksjs/types": "0.70.88"
66
66
  }
67
67
  }
@@ -1,26 +0,0 @@
1
- /**
2
- * Inline `<style>` blocks into per-element `style=""` attributes.
3
- *
4
- * Simple selectors (class `.x`, id `#x`, tag `p`, and chains like
5
- * `p.foo` or `.a.b`) are inlined and removed from the source block.
6
- * Rules that can't be safely inlined (`@media`, pseudo-classes,
7
- * descendant / sibling combinators) stay in a slimmed-down `<style>`
8
- * block so clients that DO honour styles can still apply them.
9
- *
10
- * Returns the HTML unchanged when `inline: false`. `<style
11
- * data-inline="false">` blocks are passed through verbatim regardless
12
- * of `inline`.
13
- */
14
- export declare function inlineCss(html: string, options?: InlineCssOptions): string;
15
- /**
16
- * Honour the global production-default. Apps that want explicit
17
- * control should pass `inline` through {@link inlineCss} directly.
18
- */
19
- export declare function shouldInlineByDefault(): boolean;
20
- /**
21
- * Options accepted by {@link inlineCss}.
22
- */
23
- export declare interface InlineCssOptions {
24
- inline?: boolean
25
- important?: boolean
26
- }
@@ -1,14 +0,0 @@
1
- import type { EmailAddress, EmailDriver, EmailDriverConfig, EmailMessage, EmailResult } from '@stacksjs/types';
2
- import type { TemplateOptions } from '../template';
3
- export declare abstract class BaseEmailDriver implements EmailDriver {
4
- abstract name: string;
5
- protected config: Required<EmailDriverConfig>;
6
- constructor(config?: EmailDriverConfig);
7
- configure(config: EmailDriverConfig): void;
8
- abstract send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
9
- protected validateMessage(message: EmailMessage): boolean;
10
- protected formatAddresses(addresses: string | string[] | EmailAddress[] | undefined): string[];
11
- protected formatAddressList(value: string | string[] | EmailAddress | EmailAddress[] | undefined): string[];
12
- protected handleError(error: unknown, message: EmailMessage): Promise<EmailResult>;
13
- protected handleSuccess(message: EmailMessage, messageId?: string): Promise<EmailResult>;
14
- }
@@ -1,55 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- /**
5
- * Test-only capture driver (stacksjs/stacks#1871 M-12).
6
- *
7
- * Records every `mail.send(...)` payload in memory without opening
8
- * a network socket or writing to disk. Lighter-weight than the `log`
9
- * driver (which writes inspection files to `storage/logs/mail/` and
10
- * does template rendering) — chosen by tests that just need to
11
- * assert "this flow sent that email" without any I/O side effect.
12
- *
13
- * Pick this driver when:
14
- * - running fast unit tests that shouldn't touch the filesystem
15
- * - asserting on the exact message shape (subject/body/headers)
16
- * before any driver-specific transformation
17
- * - writing tests that mutate captured state and need
18
- * `CaptureEmailDriver.clear()` between cases
19
- *
20
- * The `log` driver remains the better fit for local dev (because
21
- * the disk dump makes inspection easy) and for CI smoke tests that
22
- * want to surface a render failure visibly. Capture is for unit
23
- * tests.
24
- *
25
- * @example
26
- * ```ts
27
- * // bunfig.toml or test setup
28
- * config.email.default = 'capture'
29
- *
30
- * // in tests
31
- * import { CaptureEmailDriver } from '@stacksjs/email/drivers/capture'
32
- *
33
- * beforeEach(() => CaptureEmailDriver.clear())
34
- *
35
- * test('signup sends welcome', async () => {
36
- * await POST('/api/signup', { email: 'a@b.com' })
37
- * const sent = CaptureEmailDriver.all()
38
- * expect(sent).toHaveLength(1)
39
- * expect(sent[0].subject).toContain('Welcome')
40
- * expect(CaptureEmailDriver.last()?.to).toBe('a@b.com')
41
- * })
42
- * ```
43
- */
44
- export declare interface CapturedMessage extends EmailMessage {
45
- sentAt: Date
46
- messageId: string
47
- }
48
- export declare class CaptureEmailDriver extends BaseEmailDriver {
49
- name: string;
50
- send(message: EmailMessage, _options?: TemplateOptions): Promise<EmailResult>;
51
- static all(): readonly CapturedMessage[];
52
- static last(): CapturedMessage | undefined;
53
- static count(): number;
54
- static clear(): void;
55
- }
@@ -1,10 +0,0 @@
1
- export * as capture from './capture';
2
- export * as log from './log';
3
- export * as mailgun from './mailgun';
4
- export * as mailtrap from './mailtrap';
5
- // `nodemailer` driver removed — it was a throwing stub that surfaced as a
6
- // runtime crash on `mail.send()` only after a user had already wired it
7
- // into config. Use the SMTP driver (`smtp` in MAIL_MAILER) for SMTP-based
8
- // providers; see stacksjs/stacks#1871 M-7.
9
- export * as sendgrid from './sendgrid';
10
- export * as ses from './ses';
@@ -1,30 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- declare const captured: CapturedEmail[];
5
- /**
6
- * Local-only email driver that never opens a network socket. Renders
7
- * the message to disk so devs can inspect it (and tests can read it),
8
- * and remembers the last N sends in-memory so tests can assert against
9
- * them without scraping log output.
10
- *
11
- * Pick this driver when:
12
- * - running tests (no SMTP credentials, deterministic output)
13
- * - local development (no AWS/SendGrid setup, no mailbox spam)
14
- * - CI smoke tests where we want to assert "an email was sent"
15
- *
16
- * In production, use `ses` / `sendgrid` / `mailgun` / `smtp` instead.
17
- */
18
- declare interface CapturedEmail extends EmailMessage {
19
- sentAt: Date
20
- rendered?: { html?: string, text?: string }
21
- }
22
- export declare class LogEmailDriver extends BaseEmailDriver {
23
- name: string;
24
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
25
- static captured(): readonly CapturedEmail[];
26
- static reset(): void;
27
- }
28
- // Convenience export to mirror the other drivers' module shape — the
29
- // drivers/index.ts re-exports each driver namespace (`export * as log`).
30
- export default LogEmailDriver;
@@ -1,8 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- export declare class MailgunDriver extends BaseEmailDriver {
5
- name: string;
6
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
7
- }
8
- export default MailgunDriver;
@@ -1,8 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- export declare class MailtrapDriver extends BaseEmailDriver {
5
- name: string;
6
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
7
- }
8
- export default MailtrapDriver;
@@ -1,8 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- export declare class SendGridDriver extends BaseEmailDriver {
5
- name: string;
6
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
7
- }
8
- export default SendGridDriver;
@@ -1,8 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- export declare class SESDriver extends BaseEmailDriver {
5
- name: string;
6
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
7
- }
8
- export default SESDriver;
@@ -1,14 +0,0 @@
1
- import { BaseEmailDriver } from './base';
2
- import type { EmailAddress, EmailMessage, EmailResult } from '@stacksjs/types';
3
- import type { TemplateOptions } from '../template';
4
- /**
5
- * SMTP Driver for email sending
6
- * Works with any SMTP server: Mailtrap, Mailgun, SendGrid, SES, etc.
7
- * Supports STARTTLS (port 587) and direct TLS (port 465)
8
- */
9
- export declare class SMTPDriver extends BaseEmailDriver {
10
- name: string;
11
- send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
12
- protected formatAddresses(addresses: string | string[] | EmailAddress[] | undefined): string[];
13
- }
14
- export default SMTPDriver;
package/dist/email.d.ts DELETED
@@ -1,39 +0,0 @@
1
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
2
- import type { Message } from './types';
3
- export declare const mail: Mail;
4
- /** Result returned by email handler callbacks */
5
- declare interface EmailHandlerResult {
6
- message: string
7
- }
8
- /** Configuration for the sender address */
9
- declare interface EmailFromAddress {
10
- name: string
11
- address: string
12
- }
13
- /** Configuration for the Mail singleton */
14
- declare interface MailConfig {
15
- defaultDriver?: string
16
- }
17
- /**
18
- * Email notification class for defining email notifications
19
- */
20
- export declare class Email {
21
- name: string;
22
- subject: string;
23
- to: string | string[];
24
- from?: EmailFromAddress;
25
- template: string;
26
- handle?: () => Promise<EmailHandlerResult>;
27
- onError?: (error: Error) => Promise<EmailHandlerResult>;
28
- onSuccess?: () => void;
29
- constructor(options: Message);
30
- send(to?: string | string[]): Promise<EmailHandlerResult>;
31
- }
32
- export declare class Mail {
33
- constructor(options?: MailConfig);
34
- send(message: EmailMessage): Promise<EmailResult>;
35
- use(driver: string): Mail;
36
- queue(message: EmailMessage): Promise<void>;
37
- later(delaySeconds: number, message: EmailMessage): Promise<void>;
38
- queueOn(queueName: string, message: EmailMessage): Promise<void>;
39
- }
@@ -1,20 +0,0 @@
1
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
2
- /**
3
- * Look up a cached EmailResult by idempotency key. Returns the
4
- * reconstructed result when this key has been seen before, `null`
5
- * when it hasn't. Degrades to "always null" with a startup warn
6
- * when the `email_idempotency` dedup table isn't migrated yet.
7
- */
8
- export declare function findEmailByIdempotencyKey(key: string): Promise<EmailResult | null>;
9
- /**
10
- * Record a successful send under its idempotency key. No-op when
11
- * the table doesn't exist (warn-once already fired from the lookup
12
- * path) and when the result reports failure (failed sends shouldn't
13
- * lock out retries).
14
- *
15
- * Collision (same key inserted concurrently) is intentionally
16
- * swallowed: the row already exists, which is exactly what the
17
- * lookup will return next time. Throwing here would mask an
18
- * otherwise successful send.
19
- */
20
- export declare function recordEmailIdempotency(key: string, message: EmailMessage, result: EmailResult): Promise<void>;
package/dist/index.d.ts DELETED
@@ -1,22 +0,0 @@
1
- export type { InlineCssOptions } from './css-inliner';
2
- export type { DiscoveredMailable, MailablePreview } from './preview';
3
- export * from './drivers/index';
4
- export * from './email';
5
- export * from './idempotency';
6
- export * from './suppression';
7
- export * from './unsubscribe';
8
- export * from './webhook-dedup';
9
- export * from './webhook-events';
10
- export * from './webhook-handlers';
11
- export * from './webhook-signatures';
12
- export * from './mailable';
13
- export * from './template';
14
- export * from './types';
15
- export { inlineCss, shouldInlineByDefault } from './css-inliner';
16
- // Dev-only Mailable preview server (stacksjs/stacks#1900 A3).
17
- export {
18
- discoverMailables,
19
- loadSampleProps,
20
- renderMailablePreview,
21
- } from './preview';
22
- export { renderIndexHtml, renderPreviewHtml } from './preview-ui';
package/dist/index.js DELETED
@@ -1,28 +0,0 @@
1
- // @bun
2
- var MY=Object.defineProperty;var wY=(J)=>J;function EY(J,Y){this[J]=wY.bind(null,Y)}var y=(J,Y)=>{for(var Z in Y)MY(J,Z,{get:Y[Z],enumerable:!0,configurable:!0,set:EY.bind(Y,Z)})};var S=import.meta.require;var xJ={};y(xJ,{CaptureEmailDriver:()=>a});import{config as CY}from"@stacksjs/config";import{log as SY}from"@stacksjs/logging";var GJ=/^[^\s<>"\\\r\n\t]+@[^\s<>"\\\r\n\t]+$/;function AJ(J,Y){if(typeof J!=="string"||!GJ.test(J))throw Error(`Email ${Y} address is malformed or contains forbidden characters: ${JSON.stringify(J)}`)}function TJ(J){if(/[\r\n]/.test(J))throw Error("Email subject contains forbidden line break characters (CR/LF)")}function A(J){if(!J)return;let Y={};for(let[Z,X]of Object.entries(J)){if(typeof X!=="string")continue;if(/[\r\n]/.test(Z)||/[\r\n]/.test(X))continue;Y[Z]=X}return Object.keys(Y).length>0?Y:void 0}class N{config;constructor(J){this.config={maxRetries:J?.maxRetries||3,retryTimeout:J?.retryTimeout||1000,...J}}configure(J){this.config={...this.config,...J}}validateMessage(J){if(!J.from?.address&&!CY.email.from?.address)throw Error("Email sender address is required either in message or config");if(!J.to||Array.isArray(J.to)&&J.to.length===0)throw Error("At least one recipient is required");if(!J.subject)throw Error("Email subject is required");TJ(J.subject);let Y=(X,$)=>{if(!X)return;AJ(X,$)},Z=(X)=>{if(!X)return[];if(typeof X==="string")return[X];if(Array.isArray(X))return X.flatMap((G)=>typeof G==="string"?[G]:G?.address?[G.address]:[]);let $=X;return $.address?[$.address]:[]};if(J.from)Y(J.from.address,"from");for(let X of Z(J.to))Y(X,"to");for(let X of Z(J.cc))Y(X,"cc");for(let X of Z(J.bcc))Y(X,"bcc");return!0}formatAddresses(J){if(!J)return[];if(typeof J==="string")return[J];return J.map((Y)=>{if(typeof Y==="string")return Y;if(!Y.name)return Y.address;return`${/[",()<>[\]:;@\\]/.test(Y.name)?`"${Y.name.replace(/\\/g,"\\\\").replace(/"/g,"\\\"")}"`:Y.name} <${Y.address}>`})}formatAddressList(J){if(!J)return[];if(Array.isArray(J))return this.formatAddresses(J);if(typeof J==="string")return this.formatAddresses(J);return this.formatAddresses([J])}async handleError(J,Y){let Z=J instanceof Error?J:Error(String(J));SY.error(`[${this.name}] Email sending failed`,{error:Z.message,stack:Z.stack,to:Y.to,subject:Y.subject});let X={message:`Email sending failed: ${Z.message}`,success:!1,provider:this.name};if(Y.onError){let $=Y.onError(Z),G=$ instanceof Promise?await $:$;X={...X,...G,success:!1,provider:this.name}}return X}async handleSuccess(J,Y){let Z={message:"Email sent successfully",success:!0,provider:this.name,messageId:Y};try{if(J.handle){let X=J.handle(),$=X instanceof Promise?await X:X;Z={...Z,...$,success:!0,provider:this.name,messageId:Y}}if(J.onSuccess){let X=J.onSuccess(),$=X instanceof Promise?await X:X;Z={...Z,...$,success:!0,provider:this.name,messageId:Y}}}catch(X){return this.handleError(X,J)}return Z}}var v=[],DJ=1;class a extends N{name="capture";async send(J,Y){try{this.validateMessage(J);let Z=new Date,X=`capture-${Z.getTime()}-${DJ++}`;return v.push({...J,sentAt:Z,messageId:X}),this.handleSuccess(J,X)}catch(Z){return this.handleError(Z,J)}}static all(){return v}static last(){return v[v.length-1]}static count(){return v.length}static clear(){v.length=0,DJ=1}}var gJ={};y(gJ,{default:()=>tY,LogEmailDriver:()=>c});import{mkdir as nY,writeFile as oY}from"fs/promises";import{join as fJ,resolve as uJ}from"path";import{log as pJ}from"@stacksjs/logging";import{config as WJ}from"@stacksjs/config";import{log as uY}from"@stacksjs/logging";import{fs as P}from"@stacksjs/storage";import{defaultsResourcesPath as SJ,resourcesPath as kJ}from"@stacksjs/path";import{join as QJ}from"path";function KJ(J,Y={}){let{inline:Z=!0,important:X=!0}=Y;if(!Z)return J;let $=J,G=[];$=$.replace(/<style\b[^>]*\bdata-inline=["']false["'][^>]*>[\s\S]*?<\/style>/gi,(W)=>{return G.push(W),`\x00STX_PASSTHROUGH_${G.length-1}\x00`});let K=[];if($=$.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi,(W,z)=>{return K.push(z),""}),K.length===0)return MJ($,G);let Q=[],F=[];for(let W of K)for(let z of kY(W)){let{selector:V,declarations:q}=z;if(!V||!q)continue;if(wJ(V))Q.push({selector:V,decls:q});else F.push(`${V} { ${q.map((_)=>`${_.prop}: ${_.value};`).join(" ")} }`)}for(let{selector:W,decls:z}of Q)$=EJ($,W,z,X);if(F.length>0){let W=`<style>
3
- ${F.join(`
4
- `)}
5
- </style>`;$=/<\/head>/i.test($)?$.replace(/<\/head>/i,`${W}
6
- </head>`):`${W}
7
- ${$}`}return MJ($,G)}function MJ(J,Y){if(Y.length===0)return J;return J.replace(/\u0000STX_PASSTHROUGH_(\d+)\u0000/g,(Z,X)=>{return Y[Number.parseInt(X,10)]??""})}function kY(J){let Y=J.replace(/\/\*[\s\S]*?\*\//g,""),Z=[],X=0;while(X<Y.length){let $=Y.indexOf("{",X);if($===-1)break;let G=Y.slice(X,$).trim(),K=1,Q=$+1;while(Q<Y.length&&K>0){let W=Y[Q];if(W==="{")K++;else if(W==="}")K--;Q++}let F=Y.slice($+1,Q-1);if(G.startsWith("@")||F.includes("{"))Z.push({selector:G||"@unknown",declarations:yY(F)});else Z.push({selector:G,declarations:bY(F)});X=Q}return Z}function bY(J){return J.split(";").map((Y)=>Y.trim()).filter(Boolean).map((Y)=>{let Z=Y.indexOf(":");if(Z===-1)return null;return{prop:Y.slice(0,Z).trim(),value:Y.slice(Z+1).trim()}}).filter((Y)=>Y!==null)}function yY(J){return[{prop:"",value:J.trim()}]}function wJ(J){if(!J)return!1;if(J.includes(","))return J.split(",").every((Y)=>wJ(Y.trim()));if(J.startsWith("@"))return!1;if(/[\s>+~:[]/.test(J))return!1;return/^[a-z][a-z0-9-]*?$|^([a-z][a-z0-9-]*)?([.#][a-z][\w-]*)+$/i.test(J)}function EJ(J,Y,Z,X){if(Y.includes(",")){let z=J;for(let V of Y.split(","))z=EJ(z,V.trim(),Z,X);return z}let{tag:$,classes:G,ids:K}=vY(Y),F=new RegExp(`<(${$??"[a-z][a-z0-9-]*"})\\b([^>]*?)(/?)>`,"gi"),W=Z.map((z)=>`${z.prop}:${z.value}${X&&!/!important\b/i.test(z.value)?" !important":""};`).join("");return J.replace(F,(z,V,q)=>{if(!hY(q,G,K))return z;return fY(z,q,W)})}function vY(J){let Y=null,Z=[],X=[],$=0;while($<J.length&&/[a-z0-9-]/i.test(J[$]))Y=(Y??"")+J[$],$++;if(Y==="")Y=null;while($<J.length){let G=J[$];if(G!=="."&&G!=="#")break;let K=$+1;while(K<J.length&&/[\w-]/.test(J[K]))K++;let Q=J.slice($+1,K);if(G===".")Z.push(Q);else X.push(Q);$=K}return{tag:Y,classes:Z,ids:X}}function hY(J,Y,Z){if(Y.length>0){let X=J.match(/\bclass\s*=\s*["']([^"']*)["']/i);if(!X)return!1;let $=X[1].split(/\s+/).filter(Boolean);if(!Y.every((G)=>$.includes(G)))return!1}if(Z.length>0){let X=J.match(/\bid\s*=\s*["']([^"']+)["']/i);if(!X)return!1;if(!Z.every(($)=>X[1]===$))return!1}return!0}function fY(J,Y,Z){let X=Y.match(/\bstyle\s*=\s*["']([^"']*)["']/i);if(X){let G=X[1].trim(),K=`${Z}${G}${G.endsWith(";")||G===""?"":";"}`,Q=Y.replace(X[0],`style="${K}"`);return J.replace(Y,Q)}let $=` style="${Z}"`;return J.replace(/(\s*\/?)>$/,`${$}$1>`)}function CJ(){return(globalThis.process?.env?.APP_ENV??globalThis.process?.env?.NODE_ENV??"").toLowerCase()==="production"}class zJ{value;__safeHtml=!0;constructor(J){this.value=J}}function pY(J){return new zJ(J)}function bJ(){let J=WJ.app.primaryColor||"#3b82f6";return{appName:WJ.app.name||"Stacks",appUrl:WJ.app.url||"https://localhost",primaryColor:J,primaryColorDark:cY(J,15),year:new Date().getFullYear()}}function cY(J,Y){let Z=Number.parseInt(J.replace("#",""),16),X=Math.round(2.55*Y),$=Math.max(0,(Z>>16)-X),G=Math.max(0,(Z>>8&255)-X),K=Math.max(0,(Z&255)-X);return`#${(16777216+$*65536+G*256+K).toString(16).slice(1)}`}function gY(J){return J.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function FJ(J,Y){let Z=J;for(let[X,$]of Object.entries(Y)){let G=X.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),K=new RegExp(`\\{\\{\\s*${G}\\s*\\}\\}`,"g");Z=Z.replace(K,dY($))}return Z}function dY(J){if(J===null||J===void 0)return"";if(J instanceof zJ)return J.value;return gY(String(J))}var yJ=[(J)=>kJ(QJ("emails",J)),(J)=>SJ(QJ("emails",J))];function vJ(J){for(let Y of yJ){if(J.endsWith(".stx")){let $=Y(J);if(P.existsSync($))return{path:$,type:"stx"};continue}if(J.endsWith(".html")){let $=Y(J);if(P.existsSync($))return{path:$,type:"html"};continue}let Z=Y(`${J}.stx`);if(P.existsSync(Z))return{path:Z,type:"stx"};let X=Y(`${J}.html`);if(P.existsSync(X))return{path:X,type:"html"}}return null}function lY(J){let Y=J.endsWith(".html")?J:`${J}.html`;for(let Z of yJ){let X=Z(Y);if(P.existsSync(X))return P.readFileSync(X,"utf-8")}return null}function mY(J){return lY(`layouts/${J}`)}function hJ(J){return J.replace(/<br\s*\/?>/gi,`
8
- `).replace(/<\/(p|div|h[1-6]|li|tr)>/gi,`
9
- `).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,`
10
-
11
- `).trim()}async function I(J,Y={}){let{variables:Z={},layout:X="base",subject:$="",inline:G=CJ()}=Y,K={...bJ(),subject:$,...Z},Q=vJ(J);if(!Q)return console.warn(`[Email Template] Template "${J}" not found`),{html:"",text:""};if(Q.type==="stx")try{let{renderEmail:V}=await import("@stacksjs/stx"),q=await V(Q.path,K,{componentsDir:SJ("components/Email")});return{...q,html:KJ(q.html,{inline:G})}}catch(V){return uY.warn(`[email] STX template rendering failed for ${J}: ${V instanceof Error?V.message:String(V)}`),{html:"",text:""}}let F=P.readFileSync(Q.path,"utf-8");F=FJ(F,K);let W;if(X!==!1){let V=mY(X);if(!V)console.warn(`[Email Template] Layout "${X}" not found, using content only`),W=F;else K.content=pY(F),W=FJ(V,K)}else W=F;W=KJ(W,{inline:G});let z=hJ(W);return{html:W,text:z}}function oZ(J,Y={}){let Z={...bJ(),...Y},X=FJ(J,Z),$=hJ(X);return{html:X,text:$}}function iZ(J){return vJ(J)!==null}function rZ(){let J=kJ("emails");if(!P.existsSync(J))return[];let Y=[];function Z(X,$=""){let G=P.readdirSync(X,{withFileTypes:!0});for(let K of G)if(K.isDirectory()&&K.name!=="layouts")Z(QJ(X,K.name),`${$}${K.name}/`);else if(K.isFile()&&(K.name.endsWith(".html")||K.name.endsWith(".stx"))){let Q=K.name.replace(/\.(html|stx)$/,"");if(!Y.includes(`${$}${Q}`))Y.push(`${$}${Q}`)}}return Z(J),Y}var cJ=100,h=[];class c extends N{name="log";resolveDir(){let J=process.env.LOG_MAIL_DIR;if(J)return uJ(J);return uJ(fJ(import.meta.dir,"..","..","..","..","..","logs","mail"))}async send(J,Y){try{this.validateMessage(J);let Z;if(J.template){let z=await I(J.template,Y);if(z)Z={html:z.html,text:z.text}}let X=Z?.html??J.html,$=Z?.text??J.text,G=new Date,K=(J.subject||"no-subject").replace(/[^\w.-]+/g,"-").slice(0,60),Q=`${G.toISOString().replace(/[:.]/g,"-")}-${K}.html`,F=this.resolveDir();try{await nY(F,{recursive:!0});let z=fJ(F,Q),V=X?X:$?`<pre>${rY($)}</pre>`:"<em>(empty body)</em>",q=iY({stamp:G,message:J});await oY(z,`${q}
12
- ${V}
13
- `)}catch(z){pJ.warn(`[email:log] could not write inspection file: ${z.message}`)}let W=Array.isArray(J.to)?J.to.map((z)=>typeof z==="string"?z:z.address).join(", "):typeof J.to==="string"?J.to:J.to.address;if(pJ.info(`[email:log] would send \u2192 ${W} :: ${J.subject}`),h.push({...J,sentAt:G,rendered:Z}),h.length>cJ)h.splice(0,h.length-cJ);return this.handleSuccess(J,`log-${G.getTime()}`)}catch(Z){return this.handleError(Z,J)}}static captured(){return h}static reset(){h.length=0}}function iY({stamp:J,message:Y}){return["<!--",` Captured by @stacksjs/email log driver at ${J.toISOString()}`,` From: ${qJ(Y.from)}`,` To: ${VJ(Y.to)}`,Y.cc?` Cc: ${VJ(Y.cc)}`:null,Y.bcc?` Bcc: ${VJ(Y.bcc)}`:null,` Subject: ${Y.subject}`,"-->"].filter(Boolean).join(`
14
- `)}function qJ(J){if(!J)return"";if(typeof J==="string")return J;let Y=J;return Y.name?`${Y.name} <${Y.address??""}>`:Y.address??""}function VJ(J){if(Array.isArray(J))return J.map(qJ).join(", ");return qJ(J)}function rY(J){return J.replace(/[&<>"']/g,(Y)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[Y])}var tY=c;var lJ={};y(lJ,{default:()=>aY,MailgunDriver:()=>g});import{Buffer as dJ}from"buffer";import{config as T}from"@stacksjs/config";import{log as LJ}from"@stacksjs/logging";class g extends N{name="mailgun";apiKey=null;domain=null;endpoint=null;getConfig(){if(!this.apiKey||!this.domain||!this.endpoint)this.apiKey=T.services.mailgun?.apiKey??"",this.domain=T.services.mailgun?.domain??"",this.endpoint=T.services.mailgun?.endpoint??"api.mailgun.net";return{apiKey:this.apiKey,domain:this.domain,endpoint:this.endpoint}}async send(J,Y){let{domain:Z}=this.getConfig(),X={provider:this.name,to:J.to,subject:J.subject,domain:Z};LJ.info("Sending email via Mailgun...",X);try{this.validateMessage(J);let $;if(J.template){let W=await I(J.template,Y);if(W&&"html"in W)$=W.html}let G=$||J.html,K=new FormData,Q={address:J.from?.address||T.email.from?.address||"",name:J.from?.name||T.email.from?.name};if(K.append("from",this.formatMailgunAddress(Q)),this.formatMailgunAddresses(J.to).forEach((W)=>K.append("to",W)),J.cc)this.formatMailgunAddresses(J.cc).forEach((W)=>K.append("cc",W));if(J.bcc)this.formatMailgunAddresses(J.bcc).forEach((W)=>K.append("bcc",W));if(K.append("subject",J.subject),J.replyTo){let W=this.formatMailgunAddresses(Array.isArray(J.replyTo)||typeof J.replyTo==="string"?J.replyTo:[J.replyTo]);if(W.length>0)K.append("h:Reply-To",W.join(", "))}if(J.headers){for(let[W,z]of Object.entries(J.headers))if(typeof z==="string")K.append(`h:${W}`,z)}if(G)K.append("html",G);if(J.text)K.append("text",J.text);if(J.attachments)J.attachments.forEach((W)=>{let z=typeof W.content==="string"?W.content:this.arrayBufferToBase64(W.content);K.append("attachment",new Blob([z],{type:W.contentType}),W.filename)});let F=await this.sendWithRetry(K);return this.handleSuccess(J,F.id)}catch($){return this.handleError($,J)}}formatMailgunAddress(J){return J.name?`${J.name} <${J.address}>`:J.address}formatMailgunAddresses(J){if(!J)return[];if(typeof J==="string")return[J];return J.map((Y)=>{if(typeof Y==="string")return Y;if(!Y.name)return Y.address;return`${/[",()<>[\]:;@\\]/.test(Y.name)?`"${Y.name.replace(/\\/g,"\\\\").replace(/"/g,"\\\"")}"`:Y.name} <${Y.address}>`})}arrayBufferToBase64(J){let Y="",Z=new Uint8Array(J),X=Z.byteLength;for(let $=0;$<X;$++)Y+=String.fromCharCode(Z[$]??0);return typeof btoa==="function"?btoa(Y):dJ.from(Y).toString("base64")}async sendWithRetry(J,Y=1){let{apiKey:Z,domain:X,endpoint:$}=this.getConfig(),G=`https://${$}/v3/${X}/messages`,K=dJ.from(`api:${Z}`).toString("base64");try{let Q=await fetch(G,{method:"POST",headers:{Authorization:`Basic ${K}`},body:J});if(!Q.ok){let W=await Q.json();throw Error(`Mailgun API error: ${Q.status} - ${JSON.stringify(W)}`)}let F=await Q.json();return LJ.info(`[${this.name}] Email sent successfully`,{attempt:Y,messageId:F.id}),F}catch(Q){if(Y<(T.services.mailgun?.maxRetries??3)){let F=T.services.mailgun?.retryTimeout??1000;return LJ.warn(`[${this.name}] Email send failed, retrying (${Y}/${T.services.mailgun?.maxRetries??3})`),await new Promise((W)=>setTimeout(W,F)),this.sendWithRetry(J,Y+1)}throw Q}}}var aY=g;var mJ={};y(mJ,{default:()=>eY,MailtrapDriver:()=>d});import{Buffer as sY}from"buffer";import{config as B}from"@stacksjs/config";import{log as OJ}from"@stacksjs/logging";class d extends N{name="mailtrap";host=null;token=null;inboxId=null;getConfig(){if(this.host===null||this.token===null||this.inboxId===null)this.host=B.services.mailtrap?.host??"https://sandbox.api.mailtrap.io/api/send",this.token=B.services.mailtrap?.token??"",this.inboxId=B.services.mailtrap?.inboxId?Number(B.services.mailtrap.inboxId):void 0;return{host:this.host,token:this.token,inboxId:this.inboxId}}async send(J,Y){let{inboxId:Z}=this.getConfig(),X={provider:this.name,to:J.to,subject:J.subject,inboxId:Z};OJ.info("Sending email via Mailtrap...",X);try{this.validateMessage(J);let $;if(J.template)$=await I(J.template,Y);let G=$?.html||J.html,K=this.firstMailtrapAddress(J.replyTo),Q=A(J.headers),F={from:{email:J.from?.address||B.email.from?.address||"",name:J.from?.name||B.email.from?.name},to:this.formatMailtrapAddresses(J.to),...J.cc&&{cc:this.formatMailtrapAddresses(J.cc)},...J.bcc&&{bcc:this.formatMailtrapAddresses(J.bcc)},...K?{reply_to:K}:{},...Q?{headers:Q}:{},subject:J.subject,...G&&{html:G},...J.text&&{text:J.text},...J.attachments&&{attachments:J.attachments.map((z)=>({filename:z.filename,content:typeof z.content==="string"?z.content:this.arrayBufferToBase64(z.content),type:z.contentType||"application/octet-stream"}))}},W=await this.sendWithRetry(F);return this.handleSuccess(J,W.message_ids?.[0])}catch($){return this.handleError($,J)}}formatMailtrapAddresses(J){if(!J)return[];if(typeof J==="string")return[{email:J}];return J.map((Y)=>{if(typeof Y==="string")return{email:Y};return{email:Y.address,...Y.name&&{name:Y.name}}})}firstMailtrapAddress(J){if(!J)return;if(typeof J==="string")return{email:J};if(Array.isArray(J)){let Y=J[0];if(Y===void 0)return;if(typeof Y==="string")return{email:Y};return{email:Y.address,...Y.name&&{name:Y.name}}}return{email:J.address,...J.name&&{name:J.name}}}arrayBufferToBase64(J){let Y="",Z=new Uint8Array(J),X=Z.byteLength;for(let $=0;$<X;$++)Y+=String.fromCharCode(Z[$]??0);return typeof btoa==="function"?btoa(Y):sY.from(Y).toString("base64")}async sendWithRetry(J,Y=1){let{host:Z,token:X,inboxId:$}=this.getConfig();if(!$)throw Error("Mailtrap inbox ID is required but not provided. Please set MAILTRAP_INBOX_ID in your environment variables.");let G=`${Z}/${$}`;try{let K=await fetch(G,{method:"POST",headers:{Authorization:`Bearer ${X}`,"Content-Type":"application/json"},body:JSON.stringify(J)});if(!K.ok){let F=await K.json();throw Error(`Mailtrap API error: ${K.status} - ${JSON.stringify(F)}`)}let Q=await K.json();return OJ.info(`[${this.name}] Email sent successfully`,{attempt:Y,messageId:Q.message_ids?.[0]}),Q}catch(K){if(Y<(B.services.mailtrap?.maxRetries??3)){let Q=B.services.mailtrap?.retryTimeout??1000;return OJ.warn(`[${this.name}] Email send failed, retrying (${Y}/${B.services.mailtrap?.maxRetries??3})`),await new Promise((F)=>setTimeout(F,Q)),this.sendWithRetry(J,Y+1)}throw K}}}var eY=d;var nJ={};y(nJ,{default:()=>YZ,SendGridDriver:()=>l});import{Buffer as JZ}from"buffer";import{config as k}from"@stacksjs/config";import{log as UJ}from"@stacksjs/logging";class l extends N{name="sendgrid";apiKey=null;getApiKey(){if(!this.apiKey)this.apiKey=k.services.sendgrid?.apiKey??"";return this.apiKey}async send(J,Y){let Z={provider:this.name,to:J.to,subject:J.subject};UJ.info("Sending email via SendGrid...",Z);try{this.validateMessage(J);let X;if(J.template){let z=await I(J.template,Y);if(z&&"html"in z)X=z.html}let $=X||J.html,G=[];if($)G.push({type:"text/html",value:$});if(J.text)G.push({type:"text/plain",value:J.text});if(G.length===0)throw Error("Email must have either HTML or text content");let K=this.firstSendGridAddress(J.replyTo),Q=A(J.headers),F={personalizations:[{to:this.formatSendGridAddresses(J.to),...J.cc&&{cc:this.formatSendGridAddresses(J.cc)},...J.bcc&&{bcc:this.formatSendGridAddresses(J.bcc)},subject:J.subject}],from:{email:J.from?.address||k.email.from?.address||"",name:J.from?.name||k.email.from?.name},...K?{reply_to:K}:{},...Q?{headers:Q}:{},content:G,...J.attachments&&{attachments:J.attachments.map((z)=>({filename:z.filename,content:typeof z.content==="string"?z.content:this.arrayBufferToBase64(z.content),type:z.contentType,disposition:"attachment"}))}},W=await this.sendWithRetry(F);return this.handleSuccess(J,W.headers?.get("x-message-id")??void 0)}catch(X){return this.handleError(X,J)}}formatSendGridAddresses(J){if(!J)return[];if(typeof J==="string")return[{email:J}];return J.map((Y)=>{if(typeof Y==="string")return{email:Y};return{email:Y.address,...Y.name&&{name:Y.name}}})}firstSendGridAddress(J){if(!J)return;if(typeof J==="string")return{email:J};if(Array.isArray(J)){let Y=J[0];if(Y===void 0)return;if(typeof Y==="string")return{email:Y};return{email:Y.address,...Y.name&&{name:Y.name}}}return{email:J.address,...J.name&&{name:J.name}}}arrayBufferToBase64(J){let Y="",Z=new Uint8Array(J),X=Z.byteLength;for(let $=0;$<X;$++)Y+=String.fromCharCode(Z[$]??0);return typeof btoa==="function"?btoa(Y):JZ.from(Y).toString("base64")}async sendWithRetry(J,Y=1){try{let Z=await fetch("https://api.sendgrid.com/v3/mail/send",{method:"POST",headers:{Authorization:`Bearer ${this.getApiKey()}`,"Content-Type":"application/json"},body:JSON.stringify(J)});if(!Z.ok){let X=await Z.json(),$=Error(`SendGrid API error: ${Z.status} - ${JSON.stringify(X)}`);throw $.status=Z.status,$}return UJ.info(`[${this.name}] Email sent successfully`,{attempt:Y}),Z}catch(Z){let X=Z?.status;if(!(typeof X==="number"&&X>=400&&X<500&&X!==429)&&Y<(k.services.sendgrid?.maxRetries??3)){let G=k.services.sendgrid?.retryTimeout??1000;return UJ.warn(`[${this.name}] Email send failed, retrying (${Y}/${k.services.sendgrid?.maxRetries??3})`),await new Promise((K)=>setTimeout(K,G)),this.sendWithRetry(J,Y+1)}throw Z}}}var YZ=l;var rJ={};y(rJ,{default:()=>$Z,SESDriver:()=>m});import{config as D}from"@stacksjs/config";import{SESClient as XZ}from"@stacksjs/ts-cloud";import{Buffer as NJ}from"buffer";function iJ(J){if(/^[\x00-\x7F]*$/.test(J))return J;return`=?UTF-8?B?${NJ.from(J,"utf-8").toString("base64")}?=`}function s(J){let{from:Y,to:Z,cc:X,replyTo:$,subject:G,text:K,html:Q,attachments:F,messageIdDomain:W,customHeaders:z}=J,V=[],q=!!(F&&F.length>0),_=`----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;if(V.push(`From: ${Y}`),V.push(`To: ${Z}`),X)V.push(`Cc: ${X}`);if($)V.push(`Reply-To: ${$}`);if(V.push(`Subject: ${iJ(G)}`),V.push("MIME-Version: 1.0"),V.push(`Date: ${new Date().toUTCString()}`),V.push(`Message-ID: <${Date.now()}.${Math.random().toString(36).substring(2)}@${W||"localhost"}>`),z)for(let[U,E]of Object.entries(z))V.push(`${U}: ${E}`);if(q){let U=_,E=`${_}_alt`;V.push(`Content-Type: multipart/mixed; boundary="${U}"`),V.push(""),V.push(`--${U}`),oJ(V,{text:K,html:Q,altBoundary:E});for(let H of F)V.push(`--${U}`),ZZ(V,H);V.push(`--${U}--`)}else oJ(V,{text:K,html:Q,altBoundary:_});return V.join(`\r
15
- `)}function oJ(J,Y){let{text:Z,html:X,altBoundary:$}=Y;if(X&&Z)J.push(`Content-Type: multipart/alternative; boundary="${$}"`),J.push(""),J.push(`--${$}`),J.push("Content-Type: text/plain; charset=UTF-8"),J.push("Content-Transfer-Encoding: 7bit"),J.push(""),J.push(Z),J.push(""),J.push(`--${$}`),J.push("Content-Type: text/html; charset=UTF-8"),J.push("Content-Transfer-Encoding: 7bit"),J.push(""),J.push(X),J.push(""),J.push(`--${$}--`);else if(X)J.push("Content-Type: text/html; charset=UTF-8"),J.push("Content-Transfer-Encoding: 7bit"),J.push(""),J.push(X);else if(Z)J.push("Content-Type: text/plain; charset=UTF-8"),J.push("Content-Transfer-Encoding: 7bit"),J.push(""),J.push(Z)}function ZZ(J,Y){let Z=Y.contentType||"application/octet-stream",X=iJ(Y.filename.replace(/\\/g,"\\\\").replace(/"/g,"\\\""));J.push(`Content-Type: ${Z}; name="${X}"`),J.push(`Content-Disposition: attachment; filename="${X}"`),J.push("Content-Transfer-Encoding: base64"),J.push("");let $=typeof Y.content==="string"?NJ.from(Y.content,"utf-8").toString("base64"):NJ.from(Y.content).toString("base64"),G=$.match(/.{1,76}/g)?.join(`\r
16
- `)??$;J.push(G),J.push("")}class m extends N{name="ses";client=null;getClient(){if(!this.client){let J=D?.services?.ses,Y=J?.credentials,Z=!!(Y?.accessKeyId&&Y?.secretAccessKey);this.client=new XZ(J?.region||"us-east-1",Z?{accessKeyId:Y.accessKeyId,secretAccessKey:Y.secretAccessKey,sessionToken:Y.sessionToken}:void 0)}return this.client}async send(J,Y){try{this.validateMessage(J);let Z;if(J.template){let U=await I(J.template,Y);if(U&&"html"in U)Z=U.html}let X=Z||J.html;if(!X&&!J.text)throw Error("Email must have either HTML or text content");let $=this.formatSourceAddress({address:J.from?.address||D.email.from?.address||"",name:J.from?.name||D.email.from?.name}),G=this.formatAddresses(J.to),K=this.formatAddresses(J.cc),Q=this.formatAddresses(J.bcc),F=J.replyTo?this.formatAddressList(J.replyTo):[],W=A(J.headers);if(!!(J.attachments&&J.attachments.length>0)||!!W){let U=s({from:$,to:G.join(", "),cc:K.length>0?K.join(", "):void 0,replyTo:F.length>0?F.join(", "):void 0,customHeaders:W,subject:J.subject,text:J.text,html:X,attachments:J.attachments,messageIdDomain:D.email.domain}),E=await this.getClient().sendRawEmail({source:$,destinations:[...G,...K,...Q],rawMessage:U});return this.handleSuccess(J,E.MessageId)}let q={};if(X)q.Html={Charset:D.email.charset||"UTF-8",Data:X};if(J.text)q.Text={Charset:D.email.charset||"UTF-8",Data:J.text};let _=await this.getClient().sendEmail({FromEmailAddress:$,Destination:{ToAddresses:G,CcAddresses:K,BccAddresses:Q},...F.length>0?{ReplyToAddresses:F}:{},Content:{Simple:{Subject:{Charset:D.email.charset||"UTF-8",Data:J.subject},Body:q}}});return this.handleSuccess(J,_.MessageId)}catch(Z){return this.handleError(this.enrichSesError(Z),J)}}formatSourceAddress(J){if(!J.name)return J.address;return`${/[",()<>[\]:;@\\]/.test(J.name)?`"${J.name.replace(/\\/g,"\\\\").replace(/"/g,"\\\"")}"`:J.name} <${J.address}>`}enrichSesError(J){let Y=J instanceof Error?J:Error(String(J)),Z=`${Y.message} ${Y.name??""}`.toLowerCase(),X=D?.services?.ses?.region||"us-east-1";if(Z.includes("email address is not verified")||Z.includes("not authorized to send")||Z.includes("messagerejected")&&Z.includes("verified"))return Y.message=`${Y.message}
17
-
18
- SES sandbox restriction: the From and (in sandbox) every To address must be verified. Verify identities in the SES console under "Verified identities" (region: ${X}), or request production access to lift the recipient restriction.`,Y;if(Z.includes("signaturedoesnotmatch")||Z.includes("invalidclienttokenid")||Z.includes("unable to locate credentials")||Z.includes("the security token included in the request is invalid"))return Y.message=`${Y.message}
19
-
20
- SES authentication failed. Check AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or services.ses.credentials), and confirm the IAM principal has \`ses:SendEmail\` permission for the From identity.`,Y;if(Z.includes("could not be reached")||Z.includes("econnrefused")||Z.includes("enotfound"))return Y.message=`${Y.message}
21
-
22
- SES endpoint unreachable. The configured region (\`${X}\`) must match the region where the From identity is verified.`,Y;return Y}}var $Z=m;import{config as f}from"@stacksjs/config";import{log as FZ}from"@stacksjs/logging";import{Buffer as tJ}from"buffer";import GZ from"process";import*as n from"tls";import*as sJ from"net";import{config as x}from"@stacksjs/config";import{log as R}from"@stacksjs/logging";function aJ(J,Y){if(typeof J!=="string"||!GJ.test(J))throw Error(`[smtp] Refusing to send: ${Y} envelope address contains forbidden characters or is malformed: ${JSON.stringify(J)}`)}class b extends N{static SMTP_TIMEOUT=30000;name="smtp";getConfig(){let J=x.services?.smtp,Y=GZ.env,Z=J?.host||Y.MAIL_HOST||"127.0.0.1",X=J?.port||(Y.MAIL_PORT?Number(Y.MAIL_PORT):void 0)||587,$=typeof x.email?.from?.address==="string"?x.email.from.address:"",G=J?.username||Y.MAIL_USERNAME||$||"",K=(G.includes("@")?G.split("@")[0]:G).toUpperCase().replace(/[^A-Z0-9]/g,"_"),Q=J?.password||Y.MAIL_PASSWORD||(K?Y[`MAIL_PASSWORD_${K}`]:void 0)||"",F=J?.encryption??Y.MAIL_ENCRYPTION??null;return{host:Z,port:X,username:G,password:Q,encryption:F==="tls"?"starttls":F||null}}async send(J,Y){let Z=this.getConfig();if(!Z.host||Z.host==="")throw Error("[SMTP] Host is not configured. Set MAIL_HOST in your .env file.");let X={provider:this.name,to:J.to,subject:J.subject,host:Z.host,port:Z.port};R.info("Sending email via SMTP...",X);try{this.validateMessage(J);let $;if(J.template){let q=await I(J.template,Y);if(q&&"html"in q)$=q.html}let G=$||J.html,K=J.from?.address||x.email.from?.address||"",Q=J.from?.name||x.email.from?.name||"",F=this.formatAddresses(J.to),W=this.formatAddressList(J.replyTo),z=s({from:Q?`${Q} <${K}>`:K,to:F.join(", "),cc:J.cc?this.formatAddresses(J.cc).join(", "):void 0,replyTo:W.length>0?W.join(", "):void 0,customHeaders:A(J.headers),subject:J.subject,text:J.text,html:G,attachments:J.attachments,messageIdDomain:x.email.domain}),V=await this.sendViaSMTP(Z,K,F,z);return this.handleSuccess(J,V)}catch($){return this.handleError($,J)}}async sendViaSMTP(J,Y,Z,X){return new Promise(($,G)=>{let K=setTimeout(()=>{G(Error(`SMTP connection timed out after ${b.SMTP_TIMEOUT}ms`))},b.SMTP_TIMEOUT),Q=$,F=G;$=(L)=>{clearTimeout(K),Q(L)},G=(L)=>{clearTimeout(K),F(L)};let W,z="",V="",q=[],_=!1,U=!1,E=(L)=>{if(R.debug(`[SMTP] Server: ${L.trim()}`),parseInt(L.substring(0,3),10)>=400){let O=Error(`SMTP Error: ${L.trim()}`);if(q.length>0)q.shift()?.reject(O);return}if(q.length>0)q.shift()?.resolve(L)},H=(L)=>{return new Promise((j,O)=>{q.push({cmd:L,resolve:j,reject:O}),R.debug(`[SMTP] Client: ${L}`),W.write(`${L}\r
23
- `)})},BJ=(L)=>{z+=L.toString();let j=z.split(`\r
24
- `);z=j.pop()||"";for(let O of j)if(O.length>=3){if(O.length===3||O[3]===" ")E(O)}},jJ=async()=>{try{await new Promise((O,C)=>{q.push({cmd:"GREETING",resolve:O,reject:C})});let L=await H(`EHLO ${x.email.domain||"localhost"}`);if(J.encryption==="starttls"&&!(W instanceof n.TLSSocket)){await H("STARTTLS");let O=W;O.removeAllListeners("data"),W=await new Promise((C,xY)=>{let r=n.connect({socket:O,host:J.host,servername:J.host},()=>{R.debug("[SMTP] TLS connection established"),C(r)});r.on("error",(t)=>{R.error("[SMTP] TLS socket error:",t),xY(t)}),r.on("data",BJ),r.on("close",(t)=>{R.debug(`[SMTP] TLS socket closed (hadError: ${t})`);while(q.length>0)q.shift()?.reject(Error("TLS connection closed unexpectedly"))})}),await H(`EHLO ${x.email.domain||"localhost"}`)}if(J.username&&J.password)await H("AUTH LOGIN"),await H(tJ.from(J.username).toString("base64")),await H(tJ.from(J.password).toString("base64"));aJ(Y,"MAIL FROM");for(let O of Z)aJ(O,"RCPT TO");await H(`MAIL FROM:<${Y}>`);for(let O of Z)await H(`RCPT TO:<${O}>`);await H("DATA"),W.write(`${X}\r
25
- .\r
26
- `),await new Promise((O,C)=>{q.push({cmd:"DATA_END",resolve:O,reject:C})}),U=!0;let j=`${Date.now()}.${Math.random().toString(36).substring(2)}@${J.host}`;try{W.write(`QUIT\r
27
- `)}catch{}W.end(),$(j)}catch(L){if(U){W.end();return}W.end(),G(L)}};if(J.encryption==="ssl")W=n.connect({host:J.host,port:J.port,servername:J.host},()=>{R.debug(`[SMTP] TLS connected to ${J.host}:${J.port}`),jJ()});else W=sJ.connect({host:J.host,port:J.port},()=>{R.debug(`[SMTP] Connected to ${J.host}:${J.port}`),jJ()});W.on("data",BJ),W.setTimeout(b.SMTP_TIMEOUT),W.on("timeout",()=>{W.destroy(Error(`SMTP socket timed out after ${b.SMTP_TIMEOUT}ms`))}),W.on("error",(L)=>{if(U)return;R.error(`[SMTP] Connection error to ${J.host}:${J.port}:`,L),G(L)}),W.on("close",(L)=>{if(R.debug(`[SMTP] Connection closed (hadError: ${L})`),U)return;while(q.length>0)q.shift()?.reject(Error("Connection closed unexpectedly"))})})}formatAddresses(J){if(!J)return[];if(typeof J==="string")return[J];return J.map((Y)=>{if(typeof Y==="string")return Y;return Y.address})}}import{db as JY}from"@stacksjs/database";var eJ=!1;function YY(){if(eJ)return;eJ=!0,console.warn("[email/idempotency] email_idempotency table missing \u2014 idempotency keys are accepted but NOT enforced. "+"Run migrations to enable dedup.")}function ZY(J){let Y=(J?.message??"").toLowerCase();return Y.includes("no such table")||Y.includes("doesn't exist")||Y.includes("connection closed")||Y.includes("unable to open database")||Y.includes("econnrefused")||Y.includes("connection terminated")||Y.includes("no database")||Y.includes("database connection")}async function XY(J){try{let Y=await JY.selectFrom("email_idempotency").where("idempotency_key","=",J).selectAll().executeTakeFirst();if(!Y)return null;return{success:Boolean(Y.success),message:`Idempotent replay \u2014 original send recorded ${Y.created_at}`,provider:String(Y.provider??"cache"),messageId:Y.message_id??void 0}}catch(Y){if(ZY(Y))return YY(),null;throw Y}}async function $Y(J,Y,Z){if(!Z.success)return;let X=Array.isArray(Y.to)?Y.to.map(($)=>typeof $==="string"?$:$.address).join(", "):typeof Y.to==="string"?Y.to:Y.to.address;try{await JY.insertInto("email_idempotency").values({idempotency_key:J,message_id:Z.messageId??null,recipient:X,subject:Y.subject,provider:Z.provider,success:1,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch($){if(ZY($)){YY();return}let G=$?.message??"";if(G.includes("UNIQUE constraint")||G.includes("Duplicate entry"))return;throw $}}import{db as o}from"@stacksjs/database";var GY=!1;function e(){if(GY)return;GY=!0,console.warn("[email/suppression] email_suppressions table missing \u2014 suppression checks accepted but NOT enforced. "+"Run migrations to enable enforcement.")}function KZ(J){let Y=J,Z=Y?.message??"";return Y?.code==="42P01"||Z.includes("no such table")||Z.includes("doesn't exist")||/relation "[^"]*" does not exist/i.test(Z)}function JJ(J){if(KZ(J))return!0;let Y=(J?.message??"").toLowerCase();return Y.includes("connection closed")||Y.includes("unable to open database")||Y.includes("econnrefused")||Y.includes("connection terminated")||Y.includes("no database")||Y.includes("database connection")}function YJ(J){return String(J).trim().toLowerCase()}async function cX(J,Y){let Z=YJ(J);try{let X=o.selectFrom("email_suppressions").where("email","=",Z).select(["email"]);if(Y)X=X.where("type","=",Y);let $=await X.executeTakeFirst();return Boolean($)}catch(X){if(JJ(X))return e(),!1;throw X}}async function WZ(J){let Y=YJ(J);try{return await o.selectFrom("email_suppressions").where("email","=",Y).selectAll().execute()??[]}catch(Z){if(JJ(Z))return e(),[];throw Z}}async function KY(J,Y,Z){let X=YJ(J),$=new Date().toISOString().slice(0,19).replace("T"," ");try{await o.insertInto("email_suppressions").values({email:X,type:Y,reason:Z??null,created_at:$}).execute()}catch(G){if(JJ(G)){e();return}let K=G?.message??"";if(K.includes("UNIQUE constraint")||K.includes("Duplicate entry"))return;throw G}}async function gX(J,Y){let Z=YJ(J);try{await o.deleteFrom("email_suppressions").where("email","=",Z).where("type","=",Y).execute()}catch(X){if(JJ(X)){e();return}throw X}}async function QZ(){try{let{config:J}=await import("@stacksjs/config"),Y=J?.email?.suppressionPolicy;if(Y==="strict"||Y==="transactional-allowed"||Y==="off")return Y}catch{}return"strict"}async function WY(J,Y){let Z=await QZ();if(Z==="off")return null;if(Z==="transactional-allowed"&&Y==="transactional")return null;let X=await WZ(J);if(X.length===0)return null;let $=["unsubscribe","complaint","bounce","manual"];for(let G of $)if(X.some((K)=>K.type===G))return G;return X[0].type}class zZ{name;subject;to;from;template;handle;onError;onSuccess;constructor(J){this.name=J.name,this.subject=J.subject,this.to=J.to,this.from=J.from,this.template=J.template,this.handle=J.handle,this.onError=J.onError,this.onSuccess=J.onSuccess}async renderTemplate(){if(!this.template)return"";try{let{path:J}=await import("@stacksjs/path"),Y=J.resourcesPath(`views/emails/${this.template}.html`),Z=Bun.file(Y);if(await Z.exists())return await Z.text()}catch{}if(this.template.includes("<"))return this.template;return`<p>${this.template}</p>`}async send(J){let Y=J??this.to,Z=Array.isArray(Y)?Y:Y?[Y]:[];if(Z.length===0)throw Error("No recipient specified for email");try{if(await ZJ.send({to:Z,from:this.from||{name:f.email.from?.name||"Stacks",address:f.email.from?.address||"no-reply@stacksjs.com"},subject:this.subject,html:await this.renderTemplate()}),this.onSuccess)this.onSuccess();if(this.handle)return this.handle();return{message:"Email sent"}}catch(X){if(this.onError)return this.onError(X instanceof Error?X:Error(String(X)));throw X}}}class HJ{drivers=new Map;defaultDriver;constructor(J={}){this.defaultDriver=J.defaultDriver||f.email.default||"ses",this.registerDefaultDrivers()}registerDefaultDrivers(){this.drivers.set("log",new c),this.drivers.set("ses",new m),this.drivers.set("sendgrid",new l),this.drivers.set("mailgun",new g),this.drivers.set("mailtrap",new d),this.drivers.set("smtp",new b),this.drivers.set("capture",new a)}async send(J){let Y=this.drivers.get(this.defaultDriver);if(!Y){let G=[...this.drivers.keys()].sort().join(", ");throw Error(`Email driver '${this.defaultDriver}' is not registered. Available drivers: [${G}]. Check config.email.default or the MAIL_MAILER environment variable.`)}if(J.idempotencyKey){let G=await XY(J.idempotencyKey);if(G)return G}let Z=await VZ(J);if(Z)return{success:!1,message:`suppressed:${Z}`,provider:"suppression"};let X={name:f.email.from?.name||"Stacks",address:f.email.from?.address||"no-reply@stacksjs.com"},$=await Y.send({...J,from:J.from||X});if(J.idempotencyKey)await $Y(J.idempotencyKey,J,$);return $}use(J){if(!this.drivers.has(J))throw Error(`Email driver '${J}' is not available`);return new HJ({defaultDriver:J})}async queue(J){await this.dispatchOrFallback(J,async()=>{let{job:Y}=await import("@stacksjs/queue");await Y("SendEmail",{message:J,driver:this.defaultDriver}).onQueue("emails").dispatch()},{context:"queue"})}async later(J,Y){await this.dispatchOrFallback(Y,async()=>{let{job:Z}=await import("@stacksjs/queue");await Z("SendEmail",{message:Y,driver:this.defaultDriver}).onQueue("emails").delay(J).dispatch()},{context:"later",delaySeconds:J})}async queueOn(J,Y){await this.dispatchOrFallback(Y,async()=>{let{job:Z}=await import("@stacksjs/queue");await Z("SendEmail",{message:Y,driver:this.defaultDriver}).onQueue(J).dispatch()},{context:"queueOn",queueName:J})}async dispatchOrFallback(J,Y,Z){try{await Y();return}catch(X){let $=X instanceof Error?X.message:String(X);FZ.warn("[email] Queue dispatch failed; falling back to synchronous send. "+"Background email pipeline is degraded \u2014 check the queue worker / broker.",{...Z,reason:$})}await this.send(J)}}async function VZ(J){let Y=qZ(J);if(Y.length===0)return null;for(let Z of Y){let X=await WY(Z,J.tag);if(X)return X}return null}function qZ(J){let Y=[],Z=(X)=>{if(typeof X==="string"){Y.push(X);return}if(X&&typeof X==="object"&&"address"in X&&typeof X.address==="string")Y.push(X.address)};for(let X of["to","cc","bcc"]){let $=J[X];if(!$)continue;if(Array.isArray($))for(let G of $)Z(G);else Z($)}return Y}var IJ;function QY(){if(!IJ){let J=f?.email?.default||process.env.MAIL_MAILER||"ses";IJ=new HJ({defaultDriver:J})}return IJ}var ZJ=new Proxy({},{get(J,Y){return QY()[Y]},set(J,Y,Z){return QY()[Y]=Z,!0}});import{createHmac as VY,timingSafeEqual as LZ}from"crypto";import XJ from"process";import{Buffer as qY}from"buffer";var OZ=2592000,UZ="/_stacks/email/unsubscribe";function LY(){let J=XJ.env.APP_KEY;if(!J||J.length<16){if(XJ.env.APP_ENV==="production"||XJ.env.NODE_ENV==="production")throw Error("[email/unsubscribe] APP_KEY is missing or too short (need \u226516 chars). Cannot sign unsubscribe URL.")}return J||"stacks-default-key-dev-only-do-not-use-prod"}function FY(J){return J.toString("base64url")}function zY(J){return qY.from(J,"base64url")}function NZ(J,Y=OZ){if(!J)throw Error("[email/unsubscribe] email is required");let Z=Math.floor(Date.now()/1000)+Math.floor(Y),X={email:String(J).trim().toLowerCase(),exp:Z,iss:"stacks"},$=FY(qY.from(JSON.stringify(X))),G=FY(VY("sha256",LY()).update($).digest());return`${$}.${G}`}function G9(J){if(typeof J!=="string")return{valid:!1,reason:"malformed"};let Y=J.split(".");if(Y.length!==2)return{valid:!1,reason:"malformed"};let[Z,X]=Y,$=VY("sha256",LY()).update(Z).digest(),G;try{G=zY(X)}catch{return{valid:!1,reason:"malformed"}}if(G.length!==$.length||!LZ(G,$))return{valid:!1,reason:"bad_signature"};let K;try{K=JSON.parse(zY(Z).toString("utf8"))}catch{return{valid:!1,reason:"malformed"}}if(typeof K.exp!=="number"||Math.floor(Date.now()/1000)>=K.exp)return{valid:!1,reason:"expired"};if(!K.email||typeof K.email!=="string")return{valid:!1,reason:"malformed"};return{valid:!0,email:K.email}}function IZ(J,Y,Z={}){let X=NZ(J,Y),$=(Z.baseUrl||XJ.env.APP_URL||"http://localhost").replace(/\/$/,""),G=(Z.routePrefix||UZ).replace(/\/$/,"");return`${$}${G}/${X}`}function K9(J,Y,Z){return{"List-Unsubscribe":`<${IZ(J,Y,Z)}>`,"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}import{db as HZ}from"@stacksjs/database";var OY=!1;function RZ(){if(OY)return;OY=!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 _Z(J){let Y=J?.message??"";return Y.includes("no such table")||Y.includes("doesn't exist")}async function i(J,Y){if(!Y)return!0;try{return await HZ.insertInto("email_webhook_events").values({provider:J,event_id:Y,processed_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute(),!0}catch(Z){if(_Z(Z))return RZ(),!0;let X=Z?.message??"";if(X.includes("UNIQUE constraint")||X.includes("Duplicate entry"))return!1;throw Z}}async function u(J,Y){try{let Z=await import("@stacksjs/events").catch(()=>null);if(!Z)return;let X=Z.dispatch;if(typeof X!=="function")return;X(J,Y)}catch{}}async function UY(J){await u("email:bounce-hard",J),await u("email:bounce",J)}async function NY(J){await u("email:bounce-soft",J),await u("email:bounce",J)}async function IY(J){await u("email:complaint",J)}async function HY(J){await u("email:unsubscribe",J)}function RY(J){switch(J){case"bounce-hard":return"bounce";case"complaint":return"complaint";case"unsubscribe":return"unsubscribe";default:return null}}import{createHmac as PZ,createVerify as PY,timingSafeEqual as RJ}from"crypto";import{Buffer as M}from"buffer";function BY(J){if(!J.signingKey)return{ok:!1,reason:"missing-config"};if(!J.timestamp||!J.token||!J.signature)return{ok:!1,reason:"missing-signature"};let Y=J.toleranceSeconds??300,Z=Number(J.timestamp);if(!Number.isFinite(Z))return{ok:!1,reason:"bad-signature"};let X=Math.floor(Date.now()/1000);if(Math.abs(X-Z)>Y)return{ok:!1,reason:"expired"};let $=PZ("sha256",J.signingKey).update(`${J.timestamp}${J.token}`).digest("hex"),G;try{G=M.from(J.signature,"hex")}catch{return{ok:!1,reason:"bad-signature"}}let K=M.from($,"hex");if(G.length!==K.length)return{ok:!1,reason:"bad-signature"};if(!RJ(G,K))return{ok:!1,reason:"bad-signature"};return{ok:!0}}function jY(J){if(!J.expectedUsername||!J.expectedPassword)return{ok:!1,reason:"missing-config"};if(!J.authorizationHeader||!J.authorizationHeader.startsWith("Basic "))return{ok:!1,reason:"missing-signature"};let Y;try{Y=M.from(J.authorizationHeader.slice(6),"base64").toString("utf8")}catch{return{ok:!1,reason:"bad-signature"}}let[Z,X]=Y.split(":");if(!Z||X===void 0)return{ok:!1,reason:"bad-signature"};let $=_Y(Z,J.expectedUsername),G=_Y(X,J.expectedPassword);if(!$||!G)return{ok:!1,reason:"bad-signature"};if(J.ipAllowlist&&J.ipAllowlist.length>0){if(!J.sourceIp||!J.ipAllowlist.includes(J.sourceIp))return{ok:!1,reason:"bad-signature"}}return{ok:!0}}function _Y(J,Y){let Z=M.from(J,"utf8"),X=M.from(Y,"utf8");if(Z.length!==X.length){let $=M.alloc(Math.max(Z.length,X.length));return RJ($,$),!1}return RJ(Z,X)}var BZ=/^sns\.[a-z0-9-]+\.amazonaws\.com$/;async function jZ(J){let Y=await fetch(J,{redirect:"error"});if(!Y.ok)throw Error(`SNS cert fetch returned ${Y.status}`);return await Y.text()}async function AY(J){let Y=J.message;if(!Y||!Y.Signature||!Y.SigningCertURL)return{ok:!1,reason:"missing-signature"};let Z=J.certUrlHostAllowlist??BZ,X;try{X=new URL(Y.SigningCertURL)}catch{return{ok:!1,reason:"untrusted-cert-url"}}if(X.protocol!=="https:")return{ok:!1,reason:"untrusted-cert-url"};if(!Z.test(X.host))return{ok:!1,reason:"untrusted-cert-url"};let $;try{$=await(J.fetchCert??jZ)(Y.SigningCertURL)}catch{return{ok:!1,reason:"cert-fetch-failed"}}let G=AZ(Y);if(!G)return{ok:!1,reason:"bad-signature"};let K;try{K=M.from(Y.Signature,"base64")}catch{return{ok:!1,reason:"bad-signature"}}let Q=Y.SignatureVersion==="2"?"SHA256":"SHA1",F=PY(`RSA-${Q}`);return F.update(G,"utf8"),F.verify($,K)?{ok:!0}:{ok:!1,reason:"bad-signature"}}function AZ(J){let Y=[];if(J.Type==="Notification"){if(Y.push("Message",J.Message),Y.push("MessageId",J.MessageId),J.Subject!==void 0)Y.push("Subject",J.Subject);Y.push("Timestamp",J.Timestamp),Y.push("TopicArn",J.TopicArn),Y.push("Type",J.Type)}else if(J.Type==="SubscriptionConfirmation"||J.Type==="UnsubscribeConfirmation"){if(Y.push("Message",J.Message),Y.push("MessageId",J.MessageId),!J.SubscribeURL||!J.Token)return null;Y.push("SubscribeURL",J.SubscribeURL),Y.push("Timestamp",J.Timestamp),Y.push("Token",J.Token),Y.push("TopicArn",J.TopicArn),Y.push("Type",J.Type)}else return null;let Z="";for(let X of Y)Z+=`${X}
28
- `;return Z}function TY(J){if(!J.publicKeyPem)return{ok:!1,reason:"missing-config"};if(!J.signature||!J.timestamp)return{ok:!1,reason:"missing-signature"};let Y=J.toleranceSeconds??300,Z=Number(J.timestamp);if(!Number.isFinite(Z))return{ok:!1,reason:"bad-signature"};if(Math.abs(Math.floor(Date.now()/1000)-Z)>Y)return{ok:!1,reason:"expired"};let X;try{X=M.from(J.signature,"base64")}catch{return{ok:!1,reason:"bad-signature"}}let $=PY("SHA256");return $.update(`${J.timestamp}${J.body}`,"utf8"),$.verify(J.publicKeyPem,X)?{ok:!0}:{ok:!1,reason:"bad-signature"}}var _J={status:200,body:{ok:!0,processed:!1,reason:"duplicate"}};function $J(J){return{status:401,body:{ok:!1,reason:J}}}function w(J){return{status:400,body:{ok:!1,reason:J}}}async function p(J,Y){let Z=RY(J);if(Z)await KY(Y.email,Z,Y.reason);switch(J){case"bounce-hard":await UY(Y);break;case"bounce-soft":await NY(Y);break;case"complaint":await IY(Y);break;case"unsubscribe":await HY(Y);break;case"delivered":break}}async function H9(J,Y){let Z;try{Z=JSON.parse(J)}catch{return w("invalid-json")}let X=Z.signature,$=Z["event-data"];if(!X||!$)return w("missing-fields");let G=BY({timestamp:X.timestamp,token:X.token,signature:X.signature,signingKey:Y.signingKey,toleranceSeconds:Y.toleranceSeconds});if(!G.ok)return $J(G.reason);if(!await i("mailgun",$.id))return _J;let Q=TZ($.event,$.severity);if(!Q)return{status:200,body:{ok:!0,processed:!1,reason:"unhandled-event"}};return await p(Q,{email:$.recipient,provider:"mailgun",reason:$.reason,raw:$}),{status:200,body:{ok:!0,processed:!0,classification:Q}}}function TZ(J,Y){switch(J){case"failed":return Y==="temporary"?"bounce-soft":"bounce-hard";case"complained":return"complaint";case"unsubscribed":return"unsubscribe";case"delivered":return"delivered";default:return null}}async function R9(J,Y,Z,X){let $=jY({authorizationHeader:Y,expectedUsername:X.username,expectedPassword:X.password,sourceIp:Z,ipAllowlist:X.ipAllowlist});if(!$.ok)return $J($.reason);let G;try{G=JSON.parse(J)}catch{return w("invalid-json")}let K=String(G.ID??G.MessageID??""),Q=String(G.Email??G.Recipient??"");if(!Q)return w("missing-recipient");if(!await i("postmark",K))return _J;let W=DZ(G);if(!W)return{status:200,body:{ok:!0,processed:!1,reason:"unhandled-event"}};return await p(W,{email:Q,provider:"postmark",reason:G.Description,raw:G}),{status:200,body:{ok:!0,processed:!0,classification:W}}}function DZ(J){switch(J.RecordType){case"Bounce":return J.TypeCode===1?"bounce-hard":"bounce-soft";case"SpamComplaint":return"complaint";case"SubscriptionChange":return J.SuppressSending?"unsubscribe":null;case"Delivery":return"delivered";default:return null}}async function _9(J,Y={}){let Z;try{Z=JSON.parse(J)}catch{return w("invalid-json")}let X=await AY({message:Z,certUrlHostAllowlist:Y.certUrlHostAllowlist,fetchCert:Y.fetchCert});if(!X.ok)return $J(X.reason);if(Z.Type==="SubscriptionConfirmation"){if(Y.autoConfirmSubscriptions!==!1&&Z.SubscribeURL)try{await fetch(Z.SubscribeURL)}catch{}return{status:200,body:{ok:!0,processed:!0,reason:"subscription-confirmed"}}}if(Z.Type==="UnsubscribeConfirmation")return{status:200,body:{ok:!0,processed:!0,reason:"subscription-removed"}};let $;try{$=JSON.parse(Z.Message)}catch{return w("invalid-inner-json")}if(!await i("ses",Z.MessageId))return _J;let K=[];if($.notificationType==="Bounce"&&$.bounce){let Q=$.bounce.bounceType==="Permanent"?"bounce-hard":"bounce-soft";for(let F of $.bounce.bouncedRecipients)await p(Q,{email:F.emailAddress,provider:"ses",reason:F.diagnosticCode,raw:$}),K.push(Q)}else if($.notificationType==="Complaint"&&$.complaint)for(let Q of $.complaint.complainedRecipients)await p("complaint",{email:Q.emailAddress,provider:"ses",reason:$.complaint.complaintFeedbackType,raw:$}),K.push("complaint");else if($.notificationType==="Delivery"&&$.delivery)for(let Q of $.delivery.recipients)await p("delivered",{email:Q,provider:"ses",raw:$}),K.push("delivered");return{status:200,body:{ok:!0,processed:K.length>0,classification:K[0]}}}async function P9(J,Y,Z,X){let $=TY({body:J,signature:Y,timestamp:Z,publicKeyPem:X.publicKeyPem,toleranceSeconds:X.toleranceSeconds});if(!$.ok)return $J($.reason);let G;try{G=JSON.parse(J)}catch{return w("invalid-json")}if(!Array.isArray(G))return w("expected-array");let K=0,Q=[];for(let F of G){if(!F.email)continue;let W=F.sg_event_id??`${F.event}:${F.email}:${Date.now()}`;if(!await i("sendgrid",W))continue;let V=xZ(F.event,F.type);if(!V)continue;await p(V,{email:F.email,provider:"sendgrid",reason:F.reason,raw:F}),K++,Q.push(V)}return{status:200,body:{ok:!0,processed:K>0,classification:Q[0]}}}function xZ(J,Y){switch(J){case"bounce":return Y==="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}}import{config as DY}from"@stacksjs/config";class MZ{_to=[];_cc=[];_bcc=[];_replyTo;_from;_subject;_text;_html;_template;_attachments=[];to(J){return this._to=PJ(J),this}cc(J){return this._cc=PJ(J),this}bcc(J){return this._bcc=PJ(J),this}replyTo(J){return this._replyTo=typeof J==="string"?{address:J}:J,this}from(J){return this._from=J,this}subject(J){return this._subject=J,this}text(J){return this._text=J,this}html(J){return this._html=J,this}template(J,...Y){let Z=Y[0]??{};return this._template={name:J,props:Z},this}inspect(){return{to:this._to,cc:this._cc,bcc:this._bcc,from:this._from,replyTo:this._replyTo,subject:this._subject,text:this._text,html:this._html,template:this._template,attachments:this._attachments}}attach(J,Y){return this._attachments.push({filename:Y||EZ(J),content:`__file__:${J}`,encoding:"binary"}),this}attachData(J,Y,Z){return this._attachments.push({filename:Y,content:J,contentType:Z,encoding:typeof J==="string"?"utf8":"binary"}),this}async send(J={}){if(await Promise.resolve(this.build()),this._to.length===0)throw Error("[Mailable] no recipients \u2014 call this.to(...) inside build()");if(!this._subject)throw Error("[Mailable] no subject \u2014 call this.subject(...) inside build()");let Y=this._html,Z=this._text;if(this._template){let G=await I(this._template.name,{variables:this._template.props,subject:this._subject});if(!Y)Y=G.html;if(!Z)Z=G.text}let X={to:this._to,subject:this._subject,from:this._from||wZ(),...this._cc.length?{cc:this._cc}:{},...this._bcc.length?{bcc:this._bcc}:{},...Y?{html:Y}:{},...Z?{text:Z}:{},...this._attachments.length?{attachments:await CZ(this._attachments)}:{}};if(this._replyTo)X.replyTo=this._replyTo;return(J.driver?ZJ.use(J.driver):ZJ).send(X)}}function PJ(J){let Y=Array.isArray(J)?J:[J];if(Y.some((X)=>typeof X==="object"&&X!==null))return Y.map((X)=>typeof X==="string"?{address:X}:X);return Y}function wZ(){return{name:DY.email.from?.name||"Stacks",address:DY.email.from?.address||"no-reply@stacksjs.com"}}function EZ(J){let Y=Math.max(J.lastIndexOf("/"),J.lastIndexOf("\\"));return Y===-1?J:J.slice(Y+1)}async function CZ(J){return Promise.all(J.map(async(Y)=>{if(typeof Y.content==="string"&&Y.content.startsWith("__file__:")){let Z=Y.content.slice(9),X=Bun.file(Z),$=new Uint8Array(await X.arrayBuffer());return{...Y,content:$,contentType:Y.contentType||X.type||void 0,encoding:"binary"}}return Y}))}export{G9 as verifyUnsubscribeToken,AY as verifySesSnsSignature,TY as verifySendgridSignature,jY as verifyPostmarkAuth,BY as verifyMailgunSignature,gX as unsuppress,iZ as templateExists,I as template,RY as suppressionTypeFor,KY as suppress,g9 as shouldInlineByDefault,rJ as ses,nJ as sendgrid,pY as safe,r9 as renderPreviewHtml,n9 as renderMailablePreview,i9 as renderIndexHtml,oZ as renderHtml,i as recordWebhookEventOrSkip,$Y as recordEmailIdempotency,mJ as mailtrap,lJ as mailgun,ZJ as mail,gJ as log,m9 as loadSampleProps,rZ as listTemplates,cX as isSuppressed,KZ as isMissingTableError,c9 as inlineCss,_9 as handleSesWebhook,P9 as handleSendgridWebhook,R9 as handlePostmarkWebhook,H9 as handleMailgunWebhook,WZ as getSuppressions,QZ as getSuppressionPolicy,XY as findEmailByIdempotencyKey,HY as emitEmailUnsubscribe,IY as emitEmailComplaint,NY as emitEmailBounceSoft,UY as emitEmailBounceHard,l9 as discoverMailables,NZ as createUnsubscribeToken,WY as checkSuppressionFor,xJ as capture,IZ as buildUnsubscribeUrl,K9 as buildListUnsubscribeHeaders,zJ as SafeHtml,MZ as Mailable,HJ as Mail,zZ as Email};
@@ -1,116 +0,0 @@
1
- import type { EmailAddress, EmailAttachment, EmailResult } from '@stacksjs/types';
2
- /**
3
- * Options accepted by {@link Mailable.send} — currently allows scoping
4
- * the send to a specific driver registered on the Mail singleton (e.g.
5
- * `'log'` to swallow the email in a test, `'ses'` to force production
6
- * delivery during a one-off backfill).
7
- */
8
- export declare interface MailableSendOptions {
9
- driver?: string
10
- }
11
- /**
12
- * Internal stash for template rendering — populated by {@link Mailable.template}
13
- * and consumed in {@link Mailable.send} after `build()` resolves.
14
- */
15
- /**
16
- * Read-only snapshot of a Mailable's build state — produced by
17
- * {@link Mailable.inspect}. Used by the preview server (#1900) so the
18
- * UI can render what the email WOULD look like without dispatching.
19
- *
20
- * Generic over `TProps` so typed Mailables expose typed template
21
- * props (stacksjs/stacks#1903). Defaults to `Record<string, unknown>`
22
- * so untyped usages keep working without changes.
23
- */
24
- export declare interface MailableInspection<TProps extends Record<string, unknown> = Record<string, unknown>> {
25
- to: string[] | EmailAddress[]
26
- cc: string[] | EmailAddress[]
27
- bcc: string[] | EmailAddress[]
28
- from?: EmailAddress
29
- replyTo?: EmailAddress
30
- subject?: string
31
- text?: string
32
- html?: string
33
- template?: { name: string, props: TProps }
34
- attachments: EmailAttachment[]
35
- }
36
- declare interface TemplateRef<TProps extends Record<string, unknown> = Record<string, unknown>> {
37
- name: string
38
- props: TProps
39
- }
40
- /**
41
- * Allowed recipient input — accepts a single address, an array of addresses,
42
- * or already-shaped {@link EmailAddress} objects. Strings are treated as
43
- * `address` only (no display name).
44
- */
45
- export type MailableAddressInput = string | string[] | EmailAddress | EmailAddress[];
46
- /**
47
- * `true` iff `T` is the loose default (`Record<string, unknown>`),
48
- * i.e. the caller didn't specialize the generic. Used to make
49
- * `Mailable#template(name)` props-optional in the loose case and
50
- * props-required when a concrete `TProps` is supplied.
51
- */
52
- declare type IsLooseProps<T> = Record<string, unknown> extends T ? true : false;
53
- /**
54
- * Builds the variadic tail of {@link Mailable.template}'s parameter list.
55
- * Untyped Mailable accepts `.template('name')`; typed `Mailable<P>` must
56
- * pass props matching `P`.
57
- */
58
- declare type TemplateArgs<T extends Record<string, unknown>> = IsLooseProps<T> extends true ? [props?: T] : [props: T];
59
- /**
60
- * Laravel-style class-based email definition. Subclass `Mailable`,
61
- * implement `build()`, and call `.send()` to dispatch.
62
- *
63
- * Compared to the existing function-form `Email` / direct `mail.send()`
64
- * APIs this gives you:
65
- * - encapsulation of recipient/subject/body building per email type
66
- * - chainable, immutable-feeling fluent setters
67
- * - a single hook (`build`) where view-model -> message translation happens
68
- * - automatic STX template rendering via the existing `template()` helper
69
- *
70
- * The class still ultimately routes through the same `mail` singleton, so
71
- * configured drivers, queueing, and `from` defaults all behave identically.
72
- *
73
- * @example
74
- * ```ts
75
- * import { Mailable } from '@stacksjs/email'
76
- *
77
- * export default class WelcomeMail extends Mailable {
78
- * constructor(private user: { name: string, email: string }) { super() }
79
- *
80
- * build() {
81
- * return this
82
- * .to(this.user.email)
83
- * .subject('Welcome!')
84
- * .template('welcome', { name: this.user.name })
85
- * }
86
- * }
87
- *
88
- * await new WelcomeMail(user).send()
89
- * ```
90
- */
91
- export declare abstract class Mailable<TProps extends Record<string, unknown> = Record<string, unknown>> {
92
- protected _to: string[] | EmailAddress[];
93
- protected _cc: string[] | EmailAddress[];
94
- protected _bcc: string[] | EmailAddress[];
95
- protected _replyTo?: EmailAddress;
96
- protected _from?: EmailAddress;
97
- protected _subject?: string;
98
- protected _text?: string;
99
- protected _html?: string;
100
- protected _template?: TemplateRef<TProps>;
101
- protected _attachments: EmailAttachment[];
102
- abstract build(): this | Promise<this>;
103
- to(address: MailableAddressInput): this;
104
- cc(address: MailableAddressInput): this;
105
- bcc(address: MailableAddressInput): this;
106
- replyTo(address: string | EmailAddress): this;
107
- from(addr: EmailAddress): this;
108
- subject(s: string): this;
109
- text(body: string): this;
110
- html(body: string): this;
111
- template(name: string, ...rest: TemplateArgs<TProps>): this;
112
- inspect(): MailableInspection<TProps>;
113
- attach(path: string, name?: string): this;
114
- attachData(buffer: Uint8Array | string, name: string, mime?: string): this;
115
- send(options?: MailableSendOptions): Promise<EmailResult>;
116
- }
package/dist/mime.d.ts DELETED
@@ -1,37 +0,0 @@
1
- import type { EmailAttachment } from '@stacksjs/types';
2
- /**
3
- * Encode an SMTP header value with RFC 2047 base64 encoding when it
4
- * contains non-ASCII characters. Without this, subjects like
5
- * "Encore d'idées" or "你好" produce headers that violate RFC 5322
6
- * (which mandates 7-bit ASCII for headers) and get mangled or rejected
7
- * by downstream relays.
8
- */
9
- export declare function encodeRfc2047IfNeeded(value: string): string;
10
- /**
11
- * Build the full raw bytes for an RFC 5322 / 2045 email message.
12
- *
13
- * Returns a single string with CRLF line endings — both SMTP DATA and
14
- * SES `SendRawEmail.RawMessage.Data` consume this shape directly.
15
- *
16
- * Shape decision tree:
17
- *
18
- * no attachments:
19
- * - both html+text → multipart/alternative
20
- * - one of either → single-part
21
- * with attachments:
22
- * - multipart/mixed envelope wrapping the body (alternative or
23
- * single-part) + one part per attachment
24
- */
25
- export declare function buildMimeMessage(options: MimeMessageOptions): string;
26
- export declare interface MimeMessageOptions {
27
- from: string
28
- to: string
29
- cc?: string
30
- replyTo?: string
31
- subject: string
32
- text?: string
33
- html?: string
34
- attachments?: EmailAttachment[]
35
- messageIdDomain?: string
36
- customHeaders?: Record<string, string>
37
- }
@@ -1,11 +0,0 @@
1
- import type { DiscoveredMailable, MailablePreview } from './preview';
2
- /**
3
- * Render the index page listing every discovered Mailable.
4
- */
5
- export declare function renderIndexHtml(mailables: DiscoveredMailable[]): string;
6
- /**
7
- * Render the preview page for a single Mailable. `view` controls the
8
- * iframe width: 'desktop' (default) / 'mobile' / 'text' (renders the
9
- * plain-text version instead of HTML).
10
- */
11
- export declare function renderPreviewHtml(mailable: DiscoveredMailable, preview: MailablePreview, view?: 'desktop' | 'mobile' | 'text'): string;
package/dist/preview.d.ts DELETED
@@ -1,50 +0,0 @@
1
- import type { MailableInspection } from './mailable';
2
- /*.ts` and return the discovered Mailables. Non-`.ts`
3
- * files (test files, `.d.ts`, etc.) are skipped.
4
- *
5
- * Filesystem scan is synchronous + cheap (one directory, no recursion)
6
- * — fine for a dev-only preview surface; not a hot path.
7
- */
8
- export declare function discoverMailables(): DiscoveredMailable[];
9
- /**
10
- * Look up sample props for a Mailable. Convention:
11
- *
12
- * resources/emails/_previews/<slug>.ts
13
- * → default export is the props object passed to `new Mailable(props)`
14
- *
15
- * Returns `null` when no sample file is present — the caller falls back
16
- * to instantiating with `{}` so apps without samples still render
17
- * (defaulted props in the Mailable's build() show their fallback values).
18
- */
19
- export declare function loadSampleProps(slug: string): Promise<Record<string, unknown> | null>;
20
- /**
21
- * Render a Mailable to its preview payload. Imports the source file,
22
- * picks the first exported subclass of `Mailable`, instantiates with
23
- * sample props (or `{}` when none exist), runs `build()`, and resolves
24
- * the bound template through `template()` from this same package.
25
- *
26
- * Errors at any step (import failure, no Mailable export, build()
27
- * throws, template not found) are returned as `error` on the preview
28
- * payload rather than thrown — the route layer renders them as part
29
- * of the UI so the user sees what broke.
30
- */
31
- export declare function renderMailablePreview(mailable: DiscoveredMailable): Promise<MailablePreview>;
32
- /**
33
- * One Mailable discovered under `app/Mail/`.
34
- */
35
- export declare interface DiscoveredMailable {
36
- name: string
37
- path: string
38
- slug: string
39
- }
40
- /**
41
- * The fully-rendered preview of one Mailable. Returned by
42
- * {@link renderMailablePreview} and consumed by the preview HTML.
43
- */
44
- export declare interface MailablePreview {
45
- inspection: MailableInspection
46
- html: string
47
- text: string
48
- sampleProps: Record<string, unknown> | null
49
- error?: string
50
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * Does this error mean the backing table simply hasn't been migrated yet?
3
- * Exported for direct unit coverage across dialects.
4
- *
5
- * Each supported database phrases "missing table" differently, and the
6
- * suppression layer is fail-open — so a matcher that only knows sqlite/mysql
7
- * would let Postgres's wording slip through and hard-fail every send on an
8
- * un-migrated Postgres DB (stacksjs/stacks#1976). We scope the Postgres check
9
- * to `undefined_table` specifically (SQLSTATE 42P01 / `relation "..." does not
10
- * exist`) rather than a bare `does not exist`, so a genuine `column ... does
11
- * not exist` schema bug still surfaces instead of being silently swallowed.
12
- */
13
- export declare function isMissingTableError(err: unknown): boolean;
14
- /**
15
- * Is the address suppressed? Pass a specific `type` to check only
16
- * one kind (e.g. unsubscribe-only — bounces from the same address
17
- * don't count). Omit to match any suppression.
18
- *
19
- * Returns `false` when the table doesn't exist yet — apps that
20
- * haven't run the migration aren't broken by the new behavior, and
21
- * a one-shot warn lets the operator know the table is missing.
22
- */
23
- export declare function isSuppressed(email: string, type?: SuppressionType): Promise<boolean>;
24
- /**
25
- * Look up the full suppression record(s) for an address — useful
26
- * for admin tools that want to show the reason/timestamp alongside
27
- * the suppression status. Returns an empty array on missing-table
28
- * (same warn-once degrade).
29
- */
30
- export declare function getSuppressions(email: string): Promise<SuppressionRecord[]>;
31
- /**
32
- * Record a suppression. Idempotent — a duplicate (email, type)
33
- * pair silently no-ops (the unique constraint catches it).
34
- *
35
- * Called by:
36
- * - the framework's bounce/complaint webhook handlers (#1881)
37
- * - the unsubscribe route handler (this PR)
38
- * - admin tooling (`SuppressionType: 'manual'`)
39
- */
40
- export declare function suppress(email: string, type: SuppressionType, reason?: string): Promise<void>;
41
- /**
42
- * Remove a suppression record (admin recovery, user-initiated
43
- * resubscribe). Idempotent — removing something that isn't there
44
- * is a no-op.
45
- */
46
- export declare function unsuppress(email: string, type: SuppressionType): Promise<void>;
47
- export declare function getSuppressionPolicy(): Promise<SuppressionPolicy>;
48
- /**
49
- * Decide whether a `mail.send()` should proceed for a given
50
- * recipient. Returns `null` to indicate "allowed"; otherwise
51
- * returns the matched suppression type so the caller can surface
52
- * it in the error message.
53
- *
54
- * Called from `Mail.send()` after idempotency lookup, before the
55
- * driver dispatch.
56
- */
57
- export declare function checkSuppressionFor(email: string, tag: 'transactional' | 'broadcast' | undefined): Promise<SuppressionType | null>;
58
- export declare interface SuppressionRecord {
59
- email: string
60
- type: SuppressionType
61
- reason: string | null
62
- created_at: string
63
- }
64
- export type SuppressionType = 'bounce' | 'complaint' | 'unsubscribe' | 'manual';
65
- /**
66
- * Suppression-policy resolution for `mail.send()` (stacksjs/stacks#1880).
67
- *
68
- * Reads the policy from `config.email.suppressionPolicy` with a
69
- * sensible default and decides whether the message should be
70
- * allowed through given its tag.
71
- *
72
- * Policy semantics:
73
- * - `'strict'` — block all sends to suppressed addresses
74
- * - `'transactional-allowed'` — block broadcasts; allow `tag: 'transactional'`
75
- * - `'off'` — never block (table is only used for tracking)
76
- *
77
- * Default is `'strict'` — the safest behavior for compliance, and
78
- * apps that don't run the migration are unaffected because the
79
- * lookup falls through to "not suppressed" when the table is
80
- * missing.
81
- */
82
- export type SuppressionPolicy = 'strict' | 'transactional-allowed' | 'off';
@@ -1,99 +0,0 @@
1
- /**
2
- * Mark a string as pre-rendered HTML so {@link replaceVariables} splices
3
- * it in verbatim instead of escaping. Use ONLY for content that you
4
- * authored (or that came from a trusted renderer like the framework's
5
- * own layout-slot resolution) — never for user input.
6
- *
7
- * @example
8
- * ```ts
9
- * mail.send({
10
- * template: 'invoice',
11
- * variables: {
12
- * // User-supplied — escaped automatically (good)
13
- * userName: req.input('name'),
14
- * // Pre-rendered HTML you built yourself — opt out of escaping
15
- * invoiceTable: safe(renderInvoiceTable(rows)),
16
- * },
17
- * })
18
- * ```
19
- */
20
- export declare function safe(html: string): SafeHtml;
21
- /**
22
- * Render an email template with optional layout
23
- *
24
- * Supports both .stx and .html templates. When a .stx template
25
- * is found, it uses the STX engine for rendering (with directives,
26
- * server scripts, etc.). When an .html template is found, it uses
27
- * simple {{ variable }} replacement with layout wrapping.
28
- *
29
- * Templates resolve from userland `resources/emails/` first, then
30
- * fall back to the framework-shipped defaults in
31
- * `storage/framework/defaults/resources/emails/` — so the prebaked
32
- * mailers (password-reset, password-changed, email-verification)
33
- * work out of the box on a default install while any userland file
34
- * with the same name always wins (stacksjs/stacks#1944).
35
- *
36
- * Within each directory, .stx templates are preferred over .html
37
- * when both exist.
38
- *
39
- * @example
40
- * ```typescript
41
- * // STX template (resources/emails/welcome.stx)
42
- * const { html, text } = await template('welcome', {
43
- * variables: { userName: 'John' }
44
- * })
45
- *
46
- * // HTML template with layout
47
- * const { html, text } = await template('notification', {
48
- * layout: 'base',
49
- * variables: { message: 'Hello' }
50
- * })
51
- *
52
- * // Without layout (HTML templates only)
53
- * const { html, text } = await template('simple', {
54
- * layout: false
55
- * })
56
- * ```
57
- */
58
- export declare function template(templateName: string, options?: TemplateOptions): Promise<TemplateResult>;
59
- /**
60
- * Render a raw HTML string with variables (no file loading)
61
- */
62
- export declare function renderHtml(htmlContent: string, variables?: TemplateVariables): TemplateResult;
63
- /**
64
- * Check if a template exists (.stx or .html)
65
- */
66
- export declare function templateExists(templateName: string): boolean;
67
- /**
68
- * List available templates (.stx and .html)
69
- */
70
- export declare function listTemplates(): string[];
71
- export declare interface TemplateResult {
72
- html: string
73
- text: string
74
- }
75
- export declare interface TemplateOptions {
76
- variables?: TemplateVariables
77
- layout?: string | false
78
- subject?: string
79
- inline?: boolean
80
- }
81
- /** Allowed types for email template variable values */
82
- export type TemplateVariableValue = string | number | boolean | undefined | null | SafeHtml;
83
- /** Map of variable names to their values for template replacement */
84
- export type TemplateVariables = Record<string, TemplateVariableValue>;
85
- /**
86
- * Marker wrapper for variable values that contain pre-rendered HTML and
87
- * should NOT be escaped during {@link replaceVariables}. Constructed via
88
- * the {@link safe} helper.
89
- *
90
- * Anything that isn't a `SafeHtml` instance (or `safe`-marked) is treated
91
- * as untrusted text and runs through HTML escaping — this is the M-1
92
- * fix for stacksjs/stacks#1871 (template XSS via unescaped variable
93
- * interpolation).
94
- */
95
- export declare class SafeHtml {
96
- readonly __safeHtml: true;
97
- public readonly value: string;
98
- constructor(value: string);
99
- }
package/dist/types.d.ts DELETED
@@ -1,37 +0,0 @@
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
- }
@@ -1,39 +0,0 @@
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
- }
@@ -1,48 +0,0 @@
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;
@@ -1,10 +0,0 @@
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>;
@@ -1,34 +0,0 @@
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';
@@ -1,27 +0,0 @@
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
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * Verify a Mailgun webhook signature. Mailgun signs
3
- * `${timestamp}${token}` with HMAC-SHA256; the hex digest is
4
- * compared in constant time against the `signature` field.
5
- *
6
- * The timestamp is checked against wall clock with a configurable
7
- * tolerance (default 5 minutes) to reject replays. Constant-time
8
- * compare prevents signature-timing oracles.
9
- */
10
- export declare function verifyMailgunSignature(input: MailgunSignatureInput): SignatureVerification;
11
- /**
12
- * Verify a Postmark webhook. Postmark uses HTTP Basic Auth (the app
13
- * configures the username + password when registering the webhook in
14
- * the Postmark dashboard); they don't sign the body. The auth check
15
- * is constant-time. Optional IP-allowlist check rejects requests
16
- * from outside Postmark's published source IPs.
17
- */
18
- export declare function verifyPostmarkAuth(input: PostmarkAuthInput): SignatureVerification;
19
- /**
20
- * Verify an SNS message signature (SES uses SNS for delivery). The
21
- * cert URL host MUST match `sns.<region>.amazonaws.com` — any other
22
- * host is treated as untrusted (defense against SSRF via crafted
23
- * SigningCertURL).
24
- *
25
- * Note: this function intentionally only does the structural +
26
- * cert-URL check + signature verify. SubscriptionConfirmation
27
- * handling (responding to `SubscribeURL` to complete the topic
28
- * binding) is the route handler's job since it's a one-time setup
29
- * action distinct from per-event verification.
30
- */
31
- export declare function verifySesSnsSignature(input: SesSnsSignatureInput): Promise<SignatureVerification>;
32
- /**
33
- * Verify a SendGrid Event Webhook signature. SendGrid signs
34
- * `${timestamp}${body}` with ECDSA-SHA256 using the public key from
35
- * their signed-webhook setup page. Signature is base64 in the
36
- * `X-Twilio-Email-Event-Webhook-Signature` header.
37
- */
38
- export declare function verifySendgridSignature(input: SendgridSignatureInput): SignatureVerification;
39
- // =============================================================================
40
- // Mailgun
41
- // =============================================================================
42
- export declare interface MailgunSignatureInput {
43
- timestamp: string
44
- token: string
45
- signature: string
46
- signingKey: string
47
- toleranceSeconds?: number
48
- }
49
- // =============================================================================
50
- // Postmark
51
- // =============================================================================
52
- export declare interface PostmarkAuthInput {
53
- authorizationHeader: string | null | undefined
54
- expectedUsername: string
55
- expectedPassword: string
56
- sourceIp?: string
57
- ipAllowlist?: ReadonlyArray<string>
58
- }
59
- // =============================================================================
60
- // SES (via SNS)
61
- // =============================================================================
62
- export declare interface SesSnsSignatureInput {
63
- message: {
64
- Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation'
65
- MessageId: string
66
- TopicArn: string
67
- Subject?: string
68
- Message: string
69
- Timestamp: string
70
- SignatureVersion: string
71
- Signature: string
72
- SigningCertURL: string
73
- Token?: string
74
- SubscribeURL?: string
75
- [key: string]: unknown
76
- }
77
- fetchCert?: (url: string) => Promise<string>
78
- certUrlHostAllowlist?: RegExp
79
- }
80
- // =============================================================================
81
- // SendGrid
82
- // =============================================================================
83
- export declare interface SendgridSignatureInput {
84
- body: string
85
- signature: string | null | undefined
86
- timestamp: string | null | undefined
87
- publicKeyPem: string
88
- toleranceSeconds?: number
89
- }
90
- export type SignatureVerification = | { ok: true }
91
- | { ok: false, reason: 'missing-config' | 'missing-signature' | 'bad-signature' | 'expired' | 'untrusted-cert-url' | 'cert-fetch-failed' }