@stacksjs/email 0.70.88 → 0.70.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/css-inliner.d.ts +26 -0
  2. package/dist/css-inliner.js +172 -0
  3. package/dist/drivers/base.d.ts +14 -0
  4. package/dist/drivers/base.js +128 -0
  5. package/dist/drivers/capture.d.ts +55 -0
  6. package/dist/drivers/capture.js +30 -0
  7. package/dist/drivers/index.d.ts +10 -0
  8. package/dist/drivers/index.js +6 -0
  9. package/dist/drivers/log.d.ts +30 -0
  10. package/dist/drivers/log.js +81 -0
  11. package/dist/drivers/mailgun.d.ts +8 -0
  12. package/dist/drivers/mailgun.js +127 -0
  13. package/dist/drivers/mailtrap.d.ts +8 -0
  14. package/dist/drivers/mailtrap.js +129 -0
  15. package/dist/drivers/sendgrid.d.ts +8 -0
  16. package/dist/drivers/sendgrid.js +136 -0
  17. package/dist/drivers/ses.d.ts +8 -0
  18. package/dist/drivers/ses.js +113 -0
  19. package/dist/drivers/smtp.d.ts +14 -0
  20. package/dist/drivers/smtp.js +225 -0
  21. package/dist/email.d.ts +39 -0
  22. package/dist/email.js +198 -0
  23. package/dist/idempotency.d.ts +20 -0
  24. package/dist/idempotency.js +56 -0
  25. package/dist/index.d.ts +22 -0
  26. package/dist/index.js +19 -0
  27. package/dist/mailable.d.ts +116 -0
  28. package/dist/mailable.js +145 -0
  29. package/dist/mime.d.ts +37 -0
  30. package/dist/mime.js +89 -0
  31. package/dist/preview-ui.d.ts +11 -0
  32. package/dist/preview-ui.js +132 -0
  33. package/dist/preview.d.ts +50 -0
  34. package/dist/preview.js +97 -0
  35. package/dist/sdk/index.d.ts +81 -0
  36. package/dist/sdk/index.js +219 -0
  37. package/dist/send.d.ts +1 -0
  38. package/dist/send.js +0 -0
  39. package/dist/server/converter.d.ts +1 -0
  40. package/dist/server/converter.js +0 -0
  41. package/dist/server/inbound.d.ts +1 -0
  42. package/dist/server/inbound.js +0 -0
  43. package/dist/server/outbound.d.ts +1 -0
  44. package/dist/server/outbound.js +0 -0
  45. package/dist/suppression.d.ts +82 -0
  46. package/dist/suppression.js +104 -0
  47. package/dist/template.d.ts +99 -0
  48. package/dist/template.js +170 -0
  49. package/dist/types.d.ts +37 -0
  50. package/dist/types.js +0 -0
  51. package/dist/unsubscribe.d.ts +39 -0
  52. package/dist/unsubscribe.js +65 -0
  53. package/dist/utils/config.d.ts +3 -0
  54. package/dist/utils/config.js +3 -0
  55. package/dist/validation.d.ts +48 -0
  56. package/dist/validation.js +22 -0
  57. package/dist/webhook-dedup.d.ts +10 -0
  58. package/dist/webhook-dedup.js +33 -0
  59. package/dist/webhook-events.d.ts +34 -0
  60. package/dist/webhook-events.js +37 -0
  61. package/dist/webhook-handlers.d.ts +27 -0
  62. package/dist/webhook-handlers.js +264 -0
  63. package/dist/webhook-signatures.d.ts +91 -0
  64. package/dist/webhook-signatures.js +148 -0
  65. package/package.json +5 -5
@@ -0,0 +1,81 @@
1
+ // Export singleton instance
2
+ export declare const emailSDK: EmailSDK;
3
+ // Export convenience functions
4
+ export declare const sendEmail: (message: EmailMessage) => unknown;
5
+ export declare const getInbox: (mailbox: string, options?: { limit?: number; offset?: number }) => unknown;
6
+ export declare const searchEmails: (mailbox: string, options: EmailSearchOptions) => unknown;
7
+ export declare const deleteEmail: (mailbox: string, messageId: string) => unknown;
8
+ export declare interface EmailAddress {
9
+ name?: string
10
+ address: string
11
+ }
12
+ export declare interface EmailMessage {
13
+ from?: EmailAddress | string
14
+ to: string | string[] | EmailAddress[]
15
+ cc?: string | string[] | EmailAddress[]
16
+ bcc?: string | string[] | EmailAddress[]
17
+ replyTo?: string | EmailAddress
18
+ subject: string
19
+ text?: string
20
+ html?: string
21
+ attachments?: EmailAttachment[]
22
+ }
23
+ export declare interface EmailAttachment {
24
+ filename: string
25
+ content: string | Buffer
26
+ contentType?: string
27
+ encoding?: 'base64' | 'binary'
28
+ }
29
+ export declare interface InboxEmail {
30
+ messageId: string
31
+ from: string
32
+ fromName?: string
33
+ to: string
34
+ subject: string
35
+ date: string
36
+ read: boolean
37
+ preview?: string
38
+ hasAttachments?: boolean
39
+ path: string
40
+ }
41
+ export declare interface EmailSearchOptions {
42
+ from?: string
43
+ to?: string
44
+ subject?: string
45
+ after?: Date
46
+ before?: Date
47
+ hasAttachments?: boolean
48
+ limit?: number
49
+ offset?: number
50
+ }
51
+ export declare interface SendResult {
52
+ success: boolean
53
+ messageId?: string
54
+ error?: string
55
+ }
56
+ /**
57
+ * Email SDK class for Stacks applications
58
+ */
59
+ export declare class EmailSDK {
60
+ constructor(options?: { bucket?: string; region?: string; domain?: string });
61
+ send(message: EmailMessage): Promise<SendResult>;
62
+ sendTemplate(options: {
63
+ to: string | string[]
64
+ template: string
65
+ data: Record<string, any>
66
+ from?: EmailAddress | string
67
+ subject?: string
68
+ }): Promise<SendResult>;
69
+ getInbox(mailbox: string, options?: { limit?: number; offset?: number }): Promise<InboxEmail[]>;
70
+ getEmail(mailbox: string, messageId: string): Promise<{
71
+ metadata: Record<string, any>
72
+ html?: string
73
+ text?: string
74
+ raw?: string
75
+ } | null>;
76
+ search(mailbox: string, options: EmailSearchOptions): Promise<InboxEmail[]>;
77
+ delete(mailbox: string, messageId: string): Promise<boolean>;
78
+ markAsRead(mailbox: string, messageId: string): Promise<boolean>;
79
+ markAsUnread(mailbox: string, messageId: string): Promise<boolean>;
80
+ }
81
+ export default EmailSDK;
@@ -0,0 +1,219 @@
1
+ import { email as emailConfig } from "@stacksjs/config";
2
+ import { getErrorMessage } from "@stacksjs/utils";
3
+
4
+ export class EmailSDK {
5
+ bucket;
6
+ region;
7
+ domain;
8
+ constructor(options) {
9
+ this.bucket = options?.bucket || `${process.env.APP_NAME?.toLowerCase() || "stacks"}-emails`;
10
+ this.region = options?.region || process.env.AWS_REGION || "us-east-1";
11
+ const fromAddress = emailConfig?.from?.address, parsedDomain = fromAddress?.includes("@") ? fromAddress.split("@")[1] : void 0;
12
+ this.domain = options?.domain || parsedDomain || "stacksjs.com";
13
+ }
14
+ async send(message) {
15
+ try {
16
+ const { SESClient } = await import("@stacksjs/ts-cloud"), ses = new SESClient(this.region), from = this.normalizeAddress(message.from || emailConfig?.from || { address: `noreply@${this.domain}` }), toAddresses = this.normalizeAddresses(message.to), ccAddresses = message.cc ? this.normalizeAddresses(message.cc) : void 0, bccAddresses = message.bcc ? this.normalizeAddresses(message.bcc) : void 0;
17
+ return {
18
+ success: !0,
19
+ messageId: (await ses.sendEmail({
20
+ FromEmailAddress: typeof from === "string" ? from : `${from.name} <${from.address}>`,
21
+ Destination: {
22
+ ToAddresses: toAddresses,
23
+ CcAddresses: ccAddresses,
24
+ BccAddresses: bccAddresses
25
+ },
26
+ ReplyToAddresses: message.replyTo ? [typeof message.replyTo === "string" ? message.replyTo : message.replyTo.address] : void 0,
27
+ Content: {
28
+ Simple: {
29
+ Subject: {
30
+ Data: message.subject,
31
+ Charset: "UTF-8"
32
+ },
33
+ Body: {
34
+ ...message.html && {
35
+ Html: {
36
+ Data: message.html,
37
+ Charset: "UTF-8"
38
+ }
39
+ },
40
+ ...message.text && {
41
+ Text: {
42
+ Data: message.text,
43
+ Charset: "UTF-8"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ })).MessageId
50
+ };
51
+ } catch (error) {
52
+ return {
53
+ success: !1,
54
+ error: getErrorMessage(error)
55
+ };
56
+ }
57
+ }
58
+ async sendTemplate(options) {
59
+ const html = this.renderTemplate(options.template, options.data), subject = options.subject || options.template;
60
+ return this.send({
61
+ to: options.to,
62
+ from: options.from,
63
+ subject,
64
+ html
65
+ });
66
+ }
67
+ async getInbox(mailbox, options) {
68
+ try {
69
+ const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], indexKey = `mailboxes/${domain}/${localPart}/inbox.json`, result = await s3.getObject(this.bucket, indexKey);
70
+ if (!result)
71
+ return [];
72
+ let inbox = JSON.parse(result);
73
+ const offset = options?.offset || 0, limit = options?.limit || 50;
74
+ return inbox.slice(offset, offset + limit);
75
+ } catch (error) {
76
+ if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
77
+ return [];
78
+ throw error;
79
+ }
80
+ }
81
+ async getEmail(mailbox, messageId) {
82
+ try {
83
+ const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], email = (await this.getInbox(mailbox, { limit: 1000 })).find((e) => e.messageId === messageId);
84
+ if (!email)
85
+ return null;
86
+ const basePath = email.path, metaResult = await s3.getObject(this.bucket, `${basePath}/metadata.json`);
87
+ let metadata = {};
88
+ if (metaResult)
89
+ try {
90
+ metadata = JSON.parse(metaResult);
91
+ } catch (parseError) {
92
+ console.debug(`[email-sdk] Failed to parse email metadata: ${parseError.message}`);
93
+ }
94
+ let html;
95
+ try {
96
+ html = await s3.getObject(this.bucket, `${basePath}/body.html`) || void 0;
97
+ } catch (error) {
98
+ if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
99
+ console.debug(`[email-sdk] Failed to fetch HTML body: ${getErrorMessage(error)}`);
100
+ }
101
+ let text;
102
+ try {
103
+ text = await s3.getObject(this.bucket, `${basePath}/body.txt`) || void 0;
104
+ } catch (error) {
105
+ if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
106
+ console.debug(`[email-sdk] Failed to fetch text body: ${getErrorMessage(error)}`);
107
+ }
108
+ return { metadata, html, text };
109
+ } catch (error) {
110
+ if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
111
+ return null;
112
+ throw error;
113
+ }
114
+ }
115
+ async search(mailbox, options) {
116
+ let results = await this.getInbox(mailbox, { limit: 1000 });
117
+ if (options.from) {
118
+ const fromLower = options.from.toLowerCase();
119
+ results = results.filter((e) => e.from.toLowerCase().includes(fromLower));
120
+ }
121
+ if (options.subject) {
122
+ const subjectLower = options.subject.toLowerCase();
123
+ results = results.filter((e) => e.subject.toLowerCase().includes(subjectLower));
124
+ }
125
+ if (options.after)
126
+ results = results.filter((e) => new Date(e.date) >= options.after);
127
+ if (options.before)
128
+ results = results.filter((e) => new Date(e.date) <= options.before);
129
+ if (options.hasAttachments !== void 0)
130
+ results = results.filter((e) => e.hasAttachments === options.hasAttachments);
131
+ const offset = options.offset || 0, limit = options.limit || 50;
132
+ return results.slice(offset, offset + limit);
133
+ }
134
+ async delete(mailbox, messageId) {
135
+ try {
136
+ const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], inbox = await this.getInbox(mailbox, { limit: 1000 }), emailIndex = inbox.findIndex((e) => e.messageId === messageId);
137
+ if (emailIndex === -1)
138
+ return !1;
139
+ const email = inbox[emailIndex];
140
+ if (!email)
141
+ return !1;
142
+ const basePath = email.path, keysToDelete = [
143
+ `${basePath}/metadata.json`,
144
+ `${basePath}/raw.eml`,
145
+ `${basePath}/body.html`,
146
+ `${basePath}/body.txt`,
147
+ `${basePath}/preview.txt`
148
+ ];
149
+ for (const key of keysToDelete)
150
+ try {
151
+ await s3.deleteObject(this.bucket, key);
152
+ } catch (error) {
153
+ if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
154
+ console.debug(`[email-sdk] Failed to delete ${key}: ${getErrorMessage(error)}`);
155
+ }
156
+ inbox.splice(emailIndex, 1);
157
+ await s3.putObject({
158
+ bucket: this.bucket,
159
+ key: `mailboxes/${domain}/${localPart}/inbox.json`,
160
+ body: JSON.stringify(inbox, null, 2),
161
+ contentType: "application/json"
162
+ });
163
+ return !0;
164
+ } catch (error) {
165
+ console.debug(`[email-sdk] Failed to delete email ${messageId}: ${getErrorMessage(error)}`);
166
+ return !1;
167
+ }
168
+ }
169
+ async markAsRead(mailbox, messageId) {
170
+ return this.updateEmailStatus(mailbox, messageId, { read: !0 });
171
+ }
172
+ async markAsUnread(mailbox, messageId) {
173
+ return this.updateEmailStatus(mailbox, messageId, { read: !1 });
174
+ }
175
+ async updateEmailStatus(mailbox, messageId, updates) {
176
+ try {
177
+ const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], inbox = await this.getInbox(mailbox, { limit: 1000 }), emailIndex = inbox.findIndex((e) => e.messageId === messageId);
178
+ if (emailIndex === -1)
179
+ return !1;
180
+ Object.assign(inbox[emailIndex], updates);
181
+ await s3.putObject({
182
+ bucket: this.bucket,
183
+ key: `mailboxes/${domain}/${localPart}/inbox.json`,
184
+ body: JSON.stringify(inbox, null, 2),
185
+ contentType: "application/json"
186
+ });
187
+ return !0;
188
+ } catch (error) {
189
+ console.debug(`[email-sdk] Failed to update email status for ${messageId}: ${getErrorMessage(error)}`);
190
+ return !1;
191
+ }
192
+ }
193
+ normalizeAddress(addr) {
194
+ if (typeof addr === "string") {
195
+ const match = addr.match(/^(.+?)\s*<(.+)>$/);
196
+ if (match)
197
+ return { name: match[1].trim(), address: match[2].trim() };
198
+ return { address: addr };
199
+ }
200
+ return addr;
201
+ }
202
+ normalizeAddresses(addrs) {
203
+ return (Array.isArray(addrs) ? addrs : [addrs]).map((a) => {
204
+ if (typeof a === "string")
205
+ return a;
206
+ return a.name ? `${a.name} <${a.address}>` : a.address;
207
+ });
208
+ }
209
+ renderTemplate(template, data) {
210
+ let result = template;
211
+ for (const [key, value] of Object.entries(data)) {
212
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
213
+ result = result.replace(new RegExp(`{{\\s*${escapedKey}\\s*}}`, "g"), String(value));
214
+ }
215
+ return result;
216
+ }
217
+ }
218
+ export const emailSDK = new EmailSDK, sendEmail = (message) => emailSDK.send(message), getInbox = (mailbox, options) => emailSDK.getInbox(mailbox, options), searchEmails = (mailbox, options) => emailSDK.search(mailbox, options), deleteEmail = (mailbox, messageId) => emailSDK.delete(mailbox, messageId);
219
+ export default EmailSDK;
package/dist/send.d.ts ADDED
@@ -0,0 +1 @@
1
+
package/dist/send.js ADDED
File without changes
@@ -0,0 +1 @@
1
+
File without changes
@@ -0,0 +1 @@
1
+
File without changes
@@ -0,0 +1 @@
1
+
File without changes
@@ -0,0 +1,82 @@
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';
@@ -0,0 +1,104 @@
1
+ import { db } from "@stacksjs/database";
2
+ let warnedAboutMissingTable = !1;
3
+ function warnOnceAboutMissingTable() {
4
+ if (warnedAboutMissingTable)
5
+ return;
6
+ warnedAboutMissingTable = !0;
7
+ console.warn("[email/suppression] email_suppressions table missing \u2014 suppression checks accepted but NOT enforced. " + "Run migrations to enable enforcement.");
8
+ }
9
+ export function isMissingTableError(err) {
10
+ const e = err, msg = e?.message ?? "";
11
+ return e?.code === "42P01" || msg.includes("no such table") || msg.includes("doesn't exist") || /relation "[^"]*" does not exist/i.test(msg);
12
+ }
13
+ function isSuppressionStoreUnavailable(err) {
14
+ if (isMissingTableError(err))
15
+ return !0;
16
+ const msg = (err?.message ?? "").toLowerCase();
17
+ return msg.includes("connection closed") || msg.includes("unable to open database") || msg.includes("econnrefused") || msg.includes("connection terminated") || msg.includes("no database") || msg.includes("database connection");
18
+ }
19
+ function canonicalize(email) {
20
+ return String(email).trim().toLowerCase();
21
+ }
22
+ export async function isSuppressed(email, type) {
23
+ const canon = canonicalize(email);
24
+ try {
25
+ let query = db.selectFrom("email_suppressions").where("email", "=", canon).select(["email"]);
26
+ if (type)
27
+ query = query.where("type", "=", type);
28
+ const row = await query.executeTakeFirst();
29
+ return Boolean(row);
30
+ } catch (err) {
31
+ if (isSuppressionStoreUnavailable(err)) {
32
+ warnOnceAboutMissingTable();
33
+ return !1;
34
+ }
35
+ throw err;
36
+ }
37
+ }
38
+ export async function getSuppressions(email) {
39
+ const canon = canonicalize(email);
40
+ try {
41
+ return await db.selectFrom("email_suppressions").where("email", "=", canon).selectAll().execute() ?? [];
42
+ } catch (err) {
43
+ if (isSuppressionStoreUnavailable(err)) {
44
+ warnOnceAboutMissingTable();
45
+ return [];
46
+ }
47
+ throw err;
48
+ }
49
+ }
50
+ export async function suppress(email, type, reason) {
51
+ const canon = canonicalize(email), createdAt = new Date().toISOString().slice(0, 19).replace("T", " ");
52
+ try {
53
+ await db.insertInto("email_suppressions").values({
54
+ email: canon,
55
+ type,
56
+ reason: reason ?? null,
57
+ created_at: createdAt
58
+ }).execute();
59
+ } catch (err) {
60
+ if (isSuppressionStoreUnavailable(err)) {
61
+ warnOnceAboutMissingTable();
62
+ return;
63
+ }
64
+ const msg = err?.message ?? "";
65
+ if (msg.includes("UNIQUE constraint") || msg.includes("Duplicate entry"))
66
+ return;
67
+ throw err;
68
+ }
69
+ }
70
+ export async function unsuppress(email, type) {
71
+ const canon = canonicalize(email);
72
+ try {
73
+ await db.deleteFrom("email_suppressions").where("email", "=", canon).where("type", "=", type).execute();
74
+ } catch (err) {
75
+ if (isSuppressionStoreUnavailable(err)) {
76
+ warnOnceAboutMissingTable();
77
+ return;
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+ export async function getSuppressionPolicy() {
83
+ try {
84
+ const { config } = await import("@stacksjs/config"), policy = config?.email?.suppressionPolicy;
85
+ if (policy === "strict" || policy === "transactional-allowed" || policy === "off")
86
+ return policy;
87
+ } catch {}
88
+ return "strict";
89
+ }
90
+ export async function checkSuppressionFor(email, tag) {
91
+ const policy = await getSuppressionPolicy();
92
+ if (policy === "off")
93
+ return null;
94
+ if (policy === "transactional-allowed" && tag === "transactional")
95
+ return null;
96
+ const suppressions = await getSuppressions(email);
97
+ if (suppressions.length === 0)
98
+ return null;
99
+ const priority = ["unsubscribe", "complaint", "bounce", "manual"];
100
+ for (const p of priority)
101
+ if (suppressions.some((s) => s.type === p))
102
+ return p;
103
+ return suppressions[0].type;
104
+ }
@@ -0,0 +1,99 @@
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
+ }