@stacksjs/email 0.70.87 → 0.70.90

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.
@@ -0,0 +1,97 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "@stacksjs/storage";
4
+ import { userEmailsPath, userMailPath } from "@stacksjs/path";
5
+ import { kebabCase } from "@stacksjs/strings";
6
+ import { Mailable } from "./mailable";
7
+ import { template } from "./template";
8
+ export function discoverMailables() {
9
+ const dir = userMailPath();
10
+ if (!existsSync(dir))
11
+ return [];
12
+ const entries = readdirSync(dir, { withFileTypes: !0 }), out = [];
13
+ for (const entry of entries) {
14
+ if (!entry.isFile())
15
+ continue;
16
+ if (!entry.name.endsWith(".ts"))
17
+ continue;
18
+ if (entry.name.endsWith(".d.ts"))
19
+ continue;
20
+ if (entry.name.endsWith(".test.ts"))
21
+ continue;
22
+ if (entry.name.endsWith(".spec.ts"))
23
+ continue;
24
+ const base = entry.name.replace(/\.ts$/, "");
25
+ out.push({
26
+ name: base,
27
+ path: join(dir, entry.name),
28
+ slug: kebabCase(base)
29
+ });
30
+ }
31
+ out.sort((a, b) => a.name.localeCompare(b.name));
32
+ return out;
33
+ }
34
+ export async function loadSampleProps(slug) {
35
+ const samplePath = userEmailsPath(`_previews/${slug}.ts`);
36
+ if (!existsSync(samplePath))
37
+ return null;
38
+ try {
39
+ const mod = await import(samplePath), props = mod.default ?? mod.props ?? null;
40
+ return props && typeof props === "object" ? props : null;
41
+ } catch (err) {
42
+ return { __preview_error__: `Failed to load sample props: ${err instanceof Error ? err.message : String(err)}` };
43
+ }
44
+ }
45
+ export async function renderMailablePreview(mailable) {
46
+ const empty = {
47
+ inspection: { to: [], cc: [], bcc: [], attachments: [] },
48
+ html: "",
49
+ text: "",
50
+ sampleProps: null
51
+ };
52
+ try {
53
+ const mod = await import(mailable.path), Cls = pickMailableConstructor(mod);
54
+ if (!Cls)
55
+ return { ...empty, error: `No subclass of \`Mailable\` exported from ${mailable.path}.` };
56
+ const sampleProps = await loadSampleProps(mailable.slug), instance = new Cls(sampleProps ?? {});
57
+ if (!(instance instanceof Mailable))
58
+ return { ...empty, sampleProps, error: "Constructor did not produce a Mailable instance." };
59
+ await instance.build();
60
+ const inspection = instance.inspect();
61
+ if (!inspection.template)
62
+ return {
63
+ inspection,
64
+ html: inspection.html ?? "",
65
+ text: inspection.text ?? "",
66
+ sampleProps
67
+ };
68
+ const rendered = await template(inspection.template.name, {
69
+ variables: inspection.template.props,
70
+ subject: inspection.subject
71
+ });
72
+ return {
73
+ inspection,
74
+ html: rendered.html,
75
+ text: rendered.text,
76
+ sampleProps
77
+ };
78
+ } catch (err) {
79
+ return { ...empty, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err) };
80
+ }
81
+ }
82
+ function pickMailableConstructor(mod) {
83
+ const candidates = [];
84
+ if (mod.default)
85
+ candidates.push(mod.default);
86
+ for (const key of Object.keys(mod))
87
+ if (key !== "default")
88
+ candidates.push(mod[key]);
89
+ for (const candidate of candidates) {
90
+ if (typeof candidate !== "function")
91
+ continue;
92
+ if (!(candidate.prototype instanceof Mailable))
93
+ continue;
94
+ return candidate;
95
+ }
96
+ return null;
97
+ }
@@ -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,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,170 @@
1
+ import { config } from "@stacksjs/config";
2
+ import { log } from "@stacksjs/logging";
3
+ import { fs } from "@stacksjs/storage";
4
+ import { defaultsResourcesPath, resourcesPath } from "@stacksjs/path";
5
+ import { join } from "node:path";
6
+ import { inlineCss, shouldInlineByDefault } from "./css-inliner";
7
+
8
+ export class SafeHtml {
9
+ value;
10
+ __safeHtml = !0;
11
+ constructor(value) {
12
+ this.value = value;
13
+ }
14
+ }
15
+ export function safe(html) {
16
+ return new SafeHtml(html);
17
+ }
18
+ function getDefaultVariables() {
19
+ const primaryColor = config.app.primaryColor || "#3b82f6";
20
+ return {
21
+ appName: config.app.name || "Stacks",
22
+ appUrl: config.app.url || "https://localhost",
23
+ primaryColor,
24
+ primaryColorDark: darkenColor(primaryColor, 15),
25
+ year: new Date().getFullYear()
26
+ };
27
+ }
28
+ function darkenColor(hex, percent) {
29
+ const num = Number.parseInt(hex.replace("#", ""), 16), amt = Math.round(2.55 * percent), R = Math.max(0, (num >> 16) - amt), G = Math.max(0, (num >> 8 & 255) - amt), B = Math.max(0, (num & 255) - amt);
30
+ return `#${(16777216 + R * 65536 + G * 256 + B).toString(16).slice(1)}`;
31
+ }
32
+ function escapeHtml(input) {
33
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
+ }
35
+ function replaceVariables(html, variables) {
36
+ let result = html;
37
+ for (const [key, value] of Object.entries(variables)) {
38
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), regex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, "g");
39
+ result = result.replace(regex, renderTemplateValue(value));
40
+ }
41
+ return result;
42
+ }
43
+ function renderTemplateValue(value) {
44
+ if (value === null || value === void 0)
45
+ return "";
46
+ if (value instanceof SafeHtml)
47
+ return value.value;
48
+ return escapeHtml(String(value));
49
+ }
50
+ const templateRoots = [
51
+ (relativePath) => resourcesPath(join("emails", relativePath)),
52
+ (relativePath) => defaultsResourcesPath(join("emails", relativePath))
53
+ ];
54
+ function resolveTemplatePath(templateName) {
55
+ for (const root of templateRoots) {
56
+ if (templateName.endsWith(".stx")) {
57
+ const fullPath = root(templateName);
58
+ if (fs.existsSync(fullPath))
59
+ return { path: fullPath, type: "stx" };
60
+ continue;
61
+ }
62
+ if (templateName.endsWith(".html")) {
63
+ const fullPath = root(templateName);
64
+ if (fs.existsSync(fullPath))
65
+ return { path: fullPath, type: "html" };
66
+ continue;
67
+ }
68
+ const stxPath = root(`${templateName}.stx`);
69
+ if (fs.existsSync(stxPath))
70
+ return { path: stxPath, type: "stx" };
71
+ const htmlPath = root(`${templateName}.html`);
72
+ if (fs.existsSync(htmlPath))
73
+ return { path: htmlPath, type: "html" };
74
+ }
75
+ return null;
76
+ }
77
+ function loadHtmlTemplate(templatePath) {
78
+ const path = templatePath.endsWith(".html") ? templatePath : `${templatePath}.html`;
79
+ for (const root of templateRoots) {
80
+ const fullPath = root(path);
81
+ if (fs.existsSync(fullPath))
82
+ return fs.readFileSync(fullPath, "utf-8");
83
+ }
84
+ return null;
85
+ }
86
+ function loadLayout(layoutName) {
87
+ return loadHtmlTemplate(`layouts/${layoutName}`);
88
+ }
89
+ function htmlToText(html) {
90
+ return html.replace(/<br\s*\/?>/gi, `
91
+ `).replace(/<\/(p|div|h[1-6]|li|tr)>/gi, `
92
+ `).replace(/<\/td>/gi, "\t").replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&copy;/g, "(c)").replace(/\n\s*\n\s*\n/g, `
93
+
94
+ `).trim();
95
+ }
96
+ export async function template(templateName, options = {}) {
97
+ const {
98
+ variables = {},
99
+ layout = "base",
100
+ subject = "",
101
+ inline = shouldInlineByDefault()
102
+ } = options, allVariables = {
103
+ ...getDefaultVariables(),
104
+ subject,
105
+ ...variables
106
+ }, resolved = resolveTemplatePath(templateName);
107
+ if (!resolved) {
108
+ console.warn(`[Email Template] Template "${templateName}" not found`);
109
+ return { html: "", text: "" };
110
+ }
111
+ if (resolved.type === "stx")
112
+ try {
113
+ const { renderEmail } = await import("@stacksjs/stx"), result = await renderEmail(resolved.path, allVariables, {
114
+ componentsDir: defaultsResourcesPath("components/Email")
115
+ });
116
+ return {
117
+ ...result,
118
+ html: inlineCss(result.html, { inline })
119
+ };
120
+ } catch (error) {
121
+ log.warn(`[email] STX template rendering failed for ${templateName}: ${error instanceof Error ? error.message : String(error)}`);
122
+ return { html: "", text: "" };
123
+ }
124
+ let content = fs.readFileSync(resolved.path, "utf-8");
125
+ content = replaceVariables(content, allVariables);
126
+ let html;
127
+ if (layout !== !1) {
128
+ const layoutHtml = loadLayout(layout);
129
+ if (!layoutHtml) {
130
+ console.warn(`[Email Template] Layout "${layout}" not found, using content only`);
131
+ html = content;
132
+ } else {
133
+ allVariables.content = safe(content);
134
+ html = replaceVariables(layoutHtml, allVariables);
135
+ }
136
+ } else
137
+ html = content;
138
+ html = inlineCss(html, { inline });
139
+ const text = htmlToText(html);
140
+ return { html, text };
141
+ }
142
+ export function renderHtml(htmlContent, variables = {}) {
143
+ const allVariables = {
144
+ ...getDefaultVariables(),
145
+ ...variables
146
+ }, html = replaceVariables(htmlContent, allVariables), text = htmlToText(html);
147
+ return { html, text };
148
+ }
149
+ export function templateExists(templateName) {
150
+ return resolveTemplatePath(templateName) !== null;
151
+ }
152
+ export function listTemplates() {
153
+ const emailsPath = resourcesPath("emails");
154
+ if (!fs.existsSync(emailsPath))
155
+ return [];
156
+ const templates = [];
157
+ function scanDir(dir, prefix = "") {
158
+ const entries = fs.readdirSync(dir, { withFileTypes: !0 });
159
+ for (const entry of entries)
160
+ if (entry.isDirectory() && entry.name !== "layouts")
161
+ scanDir(join(dir, entry.name), `${prefix}${entry.name}/`);
162
+ else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".stx"))) {
163
+ const name = entry.name.replace(/\.(html|stx)$/, "");
164
+ if (!templates.includes(`${prefix}${name}`))
165
+ templates.push(`${prefix}${name}`);
166
+ }
167
+ }
168
+ scanDir(emailsPath);
169
+ return templates;
170
+ }