@stacksjs/email 0.70.258 → 0.70.259

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.
@@ -1,20 +1,4 @@
1
- const ROOT = "/_stacks/mail/preview";
2
- function escape(s) {
3
- return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4
- }
5
- function fmtAddresses(addresses) {
6
- if (!addresses?.length)
7
- return "\u2014";
8
- return addresses.map((a) => {
9
- if (typeof a === "string")
10
- return escape(a);
11
- if (a.name)
12
- return `${escape(a.name)} &lt;${escape(a.address)}&gt;`;
13
- return escape(a.address);
14
- }).join(", ");
15
- }
16
- function shell(title, body) {
17
- return `<!DOCTYPE html>
1
+ const ROOT="/_stacks/mail/preview";function escape(s){return String(s??"").replace(/[&<>"']/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function fmtAddresses(addresses){if(!addresses?.length)return"\u2014";return addresses.map((a)=>{if(typeof a==="string")return escape(a);if(a.name)return`${escape(a.name)} &lt;${escape(a.address)}&gt;`;return escape(a.address)}).join(", ")}function shell(title,body){return`<!DOCTYPE html>
18
2
  <html lang="en">
19
3
  <head>
20
4
  <meta charset="UTF-8">
@@ -67,66 +51,55 @@ function shell(title, body) {
67
51
  </header>
68
52
  <main>${body}</main>
69
53
  </body>
70
- </html>`;
71
- }
72
- export function renderIndexHtml(mailables) {
73
- if (mailables.length === 0)
74
- return shell("Index", `
54
+ </html>`}export function renderIndexHtml(mailables){if(mailables.length===0)return shell("Index",`
75
55
  <div class="empty panel">
76
56
  <p>No Mailables found in <code>app/Mail/</code>.</p>
77
57
  <p class="muted">Run <code>./buddy make:mail Welcome</code> to scaffold one.</p>
78
58
  </div>
79
- `);
80
- const items = mailables.map((m) => `
59
+ `);const items=mailables.map((m)=>`
81
60
  <li>
82
61
  <a href="${ROOT}/${escape(m.slug)}">${escape(m.name)}</a>
83
62
  <div class="muted">app/Mail/${escape(m.name)}.ts \xB7 template: ${escape(m.slug)}.stx</div>
84
63
  </li>
85
- `).join("");
86
- return shell("Index", `
64
+ `).join("");return shell("Index",`
87
65
  <div class="panel">
88
- <p class="muted">${mailables.length} mailable${mailables.length === 1 ? "" : "s"} discovered. Add sample props at
66
+ <p class="muted">${mailables.length} mailable${mailables.length===1?"":"s"} discovered. Add sample props at
89
67
  <code>resources/emails/_previews/&lt;slug&gt;.ts</code> for richer previews.</p>
90
68
  <ul class="list">${items}</ul>
91
69
  </div>
92
- `);
93
- }
94
- export function renderPreviewHtml(mailable, preview, view = "desktop") {
95
- const { inspection, text, sampleProps, error } = preview, baseUrl = `${ROOT}/${mailable.slug}`, rawUrl = `${baseUrl}/raw`, errorBlock = error ? `<div class="panel"><div class="error"><strong>${escape(mailable.name)} failed to render:</strong> ${escape(error)}</div></div>` : "", iframeBlock = error ? "" : view === "text" ? `<div class="panel"><pre>${escape(text || "(no plain-text body)")}</pre></div>` : `<div class="panel"><iframe src="${rawUrl}" class="${view === "mobile" ? "mobile" : ""}" title="${escape(mailable.name)} body" sandbox="allow-same-origin"></iframe></div>`, sampleHint = sampleProps ? `<dd><code>resources/emails/_previews/${escape(mailable.slug)}.ts</code></dd>` : `<dd class="muted">No sample file \u2014 edit <code>resources/emails/_previews/${escape(mailable.slug)}.ts</code> to customize.</dd>`;
96
- return shell(mailable.name, `
70
+ `)}export function renderPreviewHtml(mailable,preview,view="desktop"){const{inspection,text,sampleProps,error}=preview,baseUrl=`${ROOT}/${mailable.slug}`,rawUrl=`${baseUrl}/raw`,errorBlock=error?`<div class="panel"><div class="error"><strong>${escape(mailable.name)} failed to render:</strong> ${escape(error)}</div></div>`:"",iframeBlock=error?"":view==="text"?`<div class="panel"><pre>${escape(text||"(no plain-text body)")}</pre></div>`:`<div class="panel"><iframe src="${rawUrl}" class="${view==="mobile"?"mobile":""}" title="${escape(mailable.name)} body" sandbox="allow-same-origin"></iframe></div>`,sampleHint=sampleProps?`<dd><code>resources/emails/_previews/${escape(mailable.slug)}.ts</code></dd>`:`<dd class="muted">No sample file \u2014 edit <code>resources/emails/_previews/${escape(mailable.slug)}.ts</code> to customize.</dd>`;return shell(mailable.name,`
97
71
  ${errorBlock}
98
72
 
99
73
  <div class="grid">
100
74
  <aside>
101
75
  <div class="panel">
102
76
  <dl class="meta">
103
- <dt>Subject</dt><dd>${escape(inspection.subject || "(no subject)")}</dd>
77
+ <dt>Subject</dt><dd>${escape(inspection.subject||"(no subject)")}</dd>
104
78
  <dt>To</dt><dd>${fmtAddresses(inspection.to)}</dd>
105
- ${inspection.cc?.length ? `<dt>Cc</dt><dd>${fmtAddresses(inspection.cc)}</dd>` : ""}
106
- ${inspection.bcc?.length ? `<dt>Bcc</dt><dd>${fmtAddresses(inspection.bcc)}</dd>` : ""}
107
- ${inspection.from ? `<dt>From</dt><dd>${fmtAddresses([inspection.from])}</dd>` : ""}
108
- ${inspection.replyTo ? `<dt>Reply-To</dt><dd>${fmtAddresses([inspection.replyTo])}</dd>` : ""}
109
- ${inspection.template ? `<dt>Template</dt><dd>${escape(inspection.template.name)}.stx</dd>` : ""}
110
- ${inspection.attachments?.length ? `<dt>Attachments</dt><dd>${inspection.attachments.length}</dd>` : ""}
79
+ ${inspection.cc?.length?`<dt>Cc</dt><dd>${fmtAddresses(inspection.cc)}</dd>`:""}
80
+ ${inspection.bcc?.length?`<dt>Bcc</dt><dd>${fmtAddresses(inspection.bcc)}</dd>`:""}
81
+ ${inspection.from?`<dt>From</dt><dd>${fmtAddresses([inspection.from])}</dd>`:""}
82
+ ${inspection.replyTo?`<dt>Reply-To</dt><dd>${fmtAddresses([inspection.replyTo])}</dd>`:""}
83
+ ${inspection.template?`<dt>Template</dt><dd>${escape(inspection.template.name)}.stx</dd>`:""}
84
+ ${inspection.attachments?.length?`<dt>Attachments</dt><dd>${inspection.attachments.length}</dd>`:""}
111
85
  </dl>
112
86
  </div>
113
87
 
114
88
  <div class="panel">
115
89
  <h3 style="margin: 0 0 8px; font-size: 13px; font-weight: 600;">Sample props</h3>
116
90
  ${sampleHint}
117
- ${sampleProps ? `<pre>${escape(JSON.stringify(sampleProps, null, 2))}</pre>` : ""}
91
+ ${sampleProps?`<pre>${escape(JSON.stringify(sampleProps,null,2))}</pre>`:""}
118
92
  </div>
119
93
  </aside>
120
94
 
121
95
  <section>
122
96
  <div class="toolbar">
123
- <a href="${baseUrl}" class="${view === "desktop" ? "active" : ""}">Desktop</a>
124
- <a href="${baseUrl}?view=mobile" class="${view === "mobile" ? "active" : ""}">Mobile</a>
125
- <a href="${baseUrl}?view=text" class="${view === "text" ? "active" : ""}">Text</a>
97
+ <a href="${baseUrl}" class="${view==="desktop"?"active":""}">Desktop</a>
98
+ <a href="${baseUrl}?view=mobile" class="${view==="mobile"?"active":""}">Mobile</a>
99
+ <a href="${baseUrl}?view=text" class="${view==="text"?"active":""}">Text</a>
126
100
  <span class="muted" style="margin-left: auto;">${escape(mailable.name)}</span>
127
101
  </div>
128
102
  ${iframeBlock}
129
103
  </section>
130
104
  </div>
131
- `);
132
- }
105
+ `)}
package/dist/preview.js CHANGED
@@ -1,97 +1 @@
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
- }
1
+ import{readdirSync}from"node:fs";import{join}from"node:path";import{existsSync}from"@stacksjs/storage";import{userEmailsPath,userMailPath}from"@stacksjs/path";import{kebabCase}from"@stacksjs/strings";import{Mailable}from"./mailable";import{template}from"./template";export function discoverMailables(){const dir=userMailPath();if(!existsSync(dir))return[];const entries=readdirSync(dir,{withFileTypes:!0}),out=[];for(const entry of entries){if(!entry.isFile())continue;if(!entry.name.endsWith(".ts"))continue;if(entry.name.endsWith(".d.ts"))continue;if(entry.name.endsWith(".test.ts"))continue;if(entry.name.endsWith(".spec.ts"))continue;const base=entry.name.replace(/\.ts$/,"");out.push({name:base,path:join(dir,entry.name),slug:kebabCase(base)})}out.sort((a,b)=>a.name.localeCompare(b.name));return out}export async function loadSampleProps(slug){const samplePath=userEmailsPath(`_previews/${slug}.ts`);if(!existsSync(samplePath))return null;try{const mod=await import(samplePath),props=mod.default??mod.props??null;return props&&typeof props==="object"?props:null}catch(err){return{__preview_error__:`Failed to load sample props: ${err instanceof Error?err.message:String(err)}`}}}export async function renderMailablePreview(mailable){const empty={inspection:{to:[],cc:[],bcc:[],attachments:[]},html:"",text:"",sampleProps:null};try{const mod=await import(mailable.path),Cls=pickMailableConstructor(mod);if(!Cls)return{...empty,error:`No subclass of \`Mailable\` exported from ${mailable.path}.`};const sampleProps=await loadSampleProps(mailable.slug),instance=new Cls(sampleProps??{});if(!(instance instanceof Mailable))return{...empty,sampleProps,error:"Constructor did not produce a Mailable instance."};await instance.build();const inspection=instance.inspect();if(!inspection.template)return{inspection,html:inspection.html??"",text:inspection.text??"",sampleProps};const rendered=await template(inspection.template.name,{variables:inspection.template.props,subject:inspection.subject});return{inspection,html:rendered.html,text:rendered.text,sampleProps}}catch(err){return{...empty,error:err instanceof Error?`${err.name}: ${err.message}`:String(err)}}}function pickMailableConstructor(mod){const candidates=[];if(mod.default)candidates.push(mod.default);for(const key of Object.keys(mod))if(key!=="default")candidates.push(mod[key]);for(const candidate of candidates){if(typeof candidate!=="function")continue;if(!(candidate.prototype instanceof Mailable))continue;return candidate}return null}
package/dist/sdk/index.js CHANGED
@@ -1,221 +1 @@
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
- const appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
10
- this.bucket = options?.bucket || process.env.AWS_BUCKET || `${appName}-production-email`;
11
- this.region = options?.region || process.env.AWS_REGION || "us-east-1";
12
- const fromAddress = emailConfig?.from?.address, parsedDomain = fromAddress?.includes("@") ? fromAddress.split("@")[1] : void 0;
13
- this.domain = options?.domain || parsedDomain || "stacksjs.com";
14
- }
15
- async send(message) {
16
- try {
17
- 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;
18
- return {
19
- success: !0,
20
- messageId: (await ses.sendEmail({
21
- FromEmailAddress: typeof from === "string" ? from : `${from.name} <${from.address}>`,
22
- Destination: {
23
- ToAddresses: toAddresses,
24
- CcAddresses: ccAddresses,
25
- BccAddresses: bccAddresses
26
- },
27
- ReplyToAddresses: message.replyTo ? [typeof message.replyTo === "string" ? message.replyTo : message.replyTo.address] : void 0,
28
- Content: {
29
- Simple: {
30
- Subject: {
31
- Data: message.subject,
32
- Charset: "UTF-8"
33
- },
34
- Body: {
35
- ...message.html && {
36
- Html: {
37
- Data: message.html,
38
- Charset: "UTF-8"
39
- }
40
- },
41
- ...message.text && {
42
- Text: {
43
- Data: message.text,
44
- Charset: "UTF-8"
45
- }
46
- }
47
- }
48
- }
49
- }
50
- })).MessageId
51
- };
52
- } catch (error) {
53
- return {
54
- success: !1,
55
- error: getErrorMessage(error)
56
- };
57
- }
58
- }
59
- async sendTemplate(options) {
60
- const html = this.renderTemplate(options.template, options.data), subject = options.subject || options.template;
61
- return this.send({
62
- to: options.to,
63
- from: options.from,
64
- subject,
65
- html
66
- });
67
- }
68
- async getInbox(mailbox, options) {
69
- try {
70
- 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);
71
- if (!result)
72
- return [];
73
- let inbox = JSON.parse(result);
74
- const offset = options?.offset || 0, limit = options?.limit || 50;
75
- return inbox.slice(offset, offset + limit);
76
- } catch (error) {
77
- if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
78
- return [];
79
- throw error;
80
- }
81
- }
82
- async getInboxStats(mailbox) {
83
- const inbox = await this.getInbox(mailbox, { limit: 1000 }), unread = inbox.filter((e) => !e.read).length;
84
- return {
85
- total: inbox.length,
86
- unread,
87
- read: inbox.length - unread
88
- };
89
- }
90
- async getUnreadCount(mailbox) {
91
- return (await this.getInboxStats(mailbox)).unread;
92
- }
93
- async getEmail(mailbox, messageId) {
94
- try {
95
- 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);
96
- if (!email)
97
- return null;
98
- const basePath = email.path, metaResult = await s3.getObject(this.bucket, `${basePath}/metadata.json`);
99
- let metadata = {};
100
- if (metaResult)
101
- try {
102
- metadata = JSON.parse(metaResult);
103
- } catch (parseError) {
104
- console.debug(`[email-sdk] Failed to parse email metadata: ${parseError.message}`);
105
- }
106
- let html;
107
- try {
108
- html = await s3.getObject(this.bucket, `${basePath}/body.html`) || void 0;
109
- } catch (error) {
110
- if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
111
- console.debug(`[email-sdk] Failed to fetch HTML body: ${getErrorMessage(error)}`);
112
- }
113
- let text;
114
- try {
115
- text = await s3.getObject(this.bucket, `${basePath}/body.txt`) || void 0;
116
- } catch (error) {
117
- if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
118
- console.debug(`[email-sdk] Failed to fetch text body: ${getErrorMessage(error)}`);
119
- }
120
- return { metadata, html, text };
121
- } catch (error) {
122
- if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
123
- return null;
124
- throw error;
125
- }
126
- }
127
- async search(mailbox, options) {
128
- let results = await this.getInbox(mailbox, { limit: 1000 });
129
- if (options.from) {
130
- const fromLower = options.from.toLowerCase();
131
- results = results.filter((e) => e.from.toLowerCase().includes(fromLower));
132
- }
133
- if (options.subject) {
134
- const subjectLower = options.subject.toLowerCase();
135
- results = results.filter((e) => e.subject.toLowerCase().includes(subjectLower));
136
- }
137
- if (options.after)
138
- results = results.filter((e) => new Date(e.date) >= options.after);
139
- if (options.before)
140
- results = results.filter((e) => new Date(e.date) <= options.before);
141
- if (options.hasAttachments !== void 0)
142
- results = results.filter((e) => e.hasAttachments === options.hasAttachments);
143
- const offset = options.offset || 0, limit = options.limit || 50;
144
- return results.slice(offset, offset + limit);
145
- }
146
- async delete(mailbox, messageId) {
147
- 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);
148
- if (emailIndex === -1)
149
- return !1;
150
- const email = inbox[emailIndex];
151
- if (!email)
152
- return !1;
153
- const basePath = email.path, keysToDelete = [
154
- `${basePath}/metadata.json`,
155
- `${basePath}/raw.eml`,
156
- `${basePath}/body.html`,
157
- `${basePath}/body.txt`,
158
- `${basePath}/preview.txt`
159
- ];
160
- for (const key of keysToDelete)
161
- try {
162
- await s3.deleteObject(this.bucket, key);
163
- } catch (error) {
164
- if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
165
- throw error;
166
- }
167
- inbox.splice(emailIndex, 1);
168
- await s3.putObject({
169
- bucket: this.bucket,
170
- key: `mailboxes/${domain}/${localPart}/inbox.json`,
171
- body: JSON.stringify(inbox, null, 2),
172
- contentType: "application/json"
173
- });
174
- return !0;
175
- }
176
- async markAsRead(mailbox, messageId) {
177
- return this.updateEmailStatus(mailbox, messageId, { read: !0 });
178
- }
179
- async markAsUnread(mailbox, messageId) {
180
- return this.updateEmailStatus(mailbox, messageId, { read: !1 });
181
- }
182
- async updateEmailStatus(mailbox, messageId, updates) {
183
- 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);
184
- if (emailIndex === -1)
185
- return !1;
186
- Object.assign(inbox[emailIndex], updates);
187
- await s3.putObject({
188
- bucket: this.bucket,
189
- key: `mailboxes/${domain}/${localPart}/inbox.json`,
190
- body: JSON.stringify(inbox, null, 2),
191
- contentType: "application/json"
192
- });
193
- return !0;
194
- }
195
- normalizeAddress(addr) {
196
- if (typeof addr === "string") {
197
- const match = addr.match(/^(.+?)\s*<(.+)>$/);
198
- if (match)
199
- return { name: match[1].trim(), address: match[2].trim() };
200
- return { address: addr };
201
- }
202
- return addr;
203
- }
204
- normalizeAddresses(addrs) {
205
- return (Array.isArray(addrs) ? addrs : [addrs]).map((a) => {
206
- if (typeof a === "string")
207
- return a;
208
- return a.name ? `${a.name} <${a.address}>` : a.address;
209
- });
210
- }
211
- renderTemplate(template, data) {
212
- let result = template;
213
- for (const [key, value] of Object.entries(data)) {
214
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
- result = result.replace(new RegExp(`{{\\s*${escapedKey}\\s*}}`, "g"), String(value));
216
- }
217
- return result;
218
- }
219
- }
220
- export const emailSDK = new EmailSDK, sendEmail = (message) => emailSDK.send(message), getInbox = (mailbox, options) => emailSDK.getInbox(mailbox, options), getInboxStats = (mailbox) => emailSDK.getInboxStats(mailbox), getUnreadCount = (mailbox) => emailSDK.getUnreadCount(mailbox), searchEmails = (mailbox, options) => emailSDK.search(mailbox, options), deleteEmail = (mailbox, messageId) => emailSDK.delete(mailbox, messageId);
221
- export default EmailSDK;
1
+ import{email as emailConfig}from"@stacksjs/config";import{getErrorMessage}from"@stacksjs/utils";export class EmailSDK{bucket;region;domain;constructor(options){const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");this.bucket=options?.bucket||process.env.AWS_BUCKET||`${appName}-production-email`;this.region=options?.region||process.env.AWS_REGION||"us-east-1";const fromAddress=emailConfig?.from?.address,parsedDomain=fromAddress?.includes("@")?fromAddress.split("@")[1]:void 0;this.domain=options?.domain||parsedDomain||"stacksjs.com"}async send(message){try{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;return{success:!0,messageId:(await ses.sendEmail({FromEmailAddress:typeof from==="string"?from:`${from.name} <${from.address}>`,Destination:{ToAddresses:toAddresses,CcAddresses:ccAddresses,BccAddresses:bccAddresses},ReplyToAddresses:message.replyTo?[typeof message.replyTo==="string"?message.replyTo:message.replyTo.address]:void 0,Content:{Simple:{Subject:{Data:message.subject,Charset:"UTF-8"},Body:{...message.html&&{Html:{Data:message.html,Charset:"UTF-8"}},...message.text&&{Text:{Data:message.text,Charset:"UTF-8"}}}}}})).MessageId}}catch(error){return{success:!1,error:getErrorMessage(error)}}}async sendTemplate(options){const html=this.renderTemplate(options.template,options.data),subject=options.subject||options.template;return this.send({to:options.to,from:options.from,subject,html})}async getInbox(mailbox,options){try{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);if(!result)return[];let inbox=JSON.parse(result);const offset=options?.offset||0,limit=options?.limit||50;return inbox.slice(offset,offset+limit)}catch(error){if(getErrorMessage(error).includes("NoSuchKey")||getErrorMessage(error).includes("404"))return[];throw error}}async getInboxStats(mailbox){const inbox=await this.getInbox(mailbox,{limit:1000}),unread=inbox.filter((e)=>!e.read).length;return{total:inbox.length,unread,read:inbox.length-unread}}async getUnreadCount(mailbox){return(await this.getInboxStats(mailbox)).unread}async getEmail(mailbox,messageId){try{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);if(!email)return null;const basePath=email.path,metaResult=await s3.getObject(this.bucket,`${basePath}/metadata.json`);let metadata={};if(metaResult)try{metadata=JSON.parse(metaResult)}catch(parseError){console.debug(`[email-sdk] Failed to parse email metadata: ${parseError.message}`)}let html;try{html=await s3.getObject(this.bucket,`${basePath}/body.html`)||void 0}catch(error){if(!getErrorMessage(error)?.includes("NoSuchKey")&&!getErrorMessage(error)?.includes("404"))console.debug(`[email-sdk] Failed to fetch HTML body: ${getErrorMessage(error)}`)}let text;try{text=await s3.getObject(this.bucket,`${basePath}/body.txt`)||void 0}catch(error){if(!getErrorMessage(error)?.includes("NoSuchKey")&&!getErrorMessage(error)?.includes("404"))console.debug(`[email-sdk] Failed to fetch text body: ${getErrorMessage(error)}`)}return{metadata,html,text}}catch(error){if(getErrorMessage(error).includes("NoSuchKey")||getErrorMessage(error).includes("404"))return null;throw error}}async search(mailbox,options){let results=await this.getInbox(mailbox,{limit:1000});if(options.from){const fromLower=options.from.toLowerCase();results=results.filter((e)=>e.from.toLowerCase().includes(fromLower))}if(options.subject){const subjectLower=options.subject.toLowerCase();results=results.filter((e)=>e.subject.toLowerCase().includes(subjectLower))}if(options.after)results=results.filter((e)=>new Date(e.date)>=options.after);if(options.before)results=results.filter((e)=>new Date(e.date)<=options.before);if(options.hasAttachments!==void 0)results=results.filter((e)=>e.hasAttachments===options.hasAttachments);const offset=options.offset||0,limit=options.limit||50;return results.slice(offset,offset+limit)}async delete(mailbox,messageId){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);if(emailIndex===-1)return!1;const email=inbox[emailIndex];if(!email)return!1;const basePath=email.path,keysToDelete=[`${basePath}/metadata.json`,`${basePath}/raw.eml`,`${basePath}/body.html`,`${basePath}/body.txt`,`${basePath}/preview.txt`];for(const key of keysToDelete)try{await s3.deleteObject(this.bucket,key)}catch(error){if(!getErrorMessage(error)?.includes("NoSuchKey")&&!getErrorMessage(error)?.includes("404"))throw error}inbox.splice(emailIndex,1);await s3.putObject({bucket:this.bucket,key:`mailboxes/${domain}/${localPart}/inbox.json`,body:JSON.stringify(inbox,null,2),contentType:"application/json"});return!0}async markAsRead(mailbox,messageId){return this.updateEmailStatus(mailbox,messageId,{read:!0})}async markAsUnread(mailbox,messageId){return this.updateEmailStatus(mailbox,messageId,{read:!1})}async updateEmailStatus(mailbox,messageId,updates){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);if(emailIndex===-1)return!1;Object.assign(inbox[emailIndex],updates);await s3.putObject({bucket:this.bucket,key:`mailboxes/${domain}/${localPart}/inbox.json`,body:JSON.stringify(inbox,null,2),contentType:"application/json"});return!0}normalizeAddress(addr){if(typeof addr==="string"){const match=addr.match(/^(.+?)\s*<(.+)>$/);if(match)return{name:match[1].trim(),address:match[2].trim()};return{address:addr}}return addr}normalizeAddresses(addrs){return(Array.isArray(addrs)?addrs:[addrs]).map((a)=>{if(typeof a==="string")return a;return a.name?`${a.name} <${a.address}>`:a.address})}renderTemplate(template,data){let result=template;for(const[key,value]of Object.entries(data)){const escapedKey=key.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");result=result.replace(new RegExp(`{{\\s*${escapedKey}\\s*}}`,"g"),String(value))}return result}}export const emailSDK=new EmailSDK,sendEmail=(message)=>emailSDK.send(message),getInbox=(mailbox,options)=>emailSDK.getInbox(mailbox,options),getInboxStats=(mailbox)=>emailSDK.getInboxStats(mailbox),getUnreadCount=(mailbox)=>emailSDK.getUnreadCount(mailbox),searchEmails=(mailbox,options)=>emailSDK.search(mailbox,options),deleteEmail=(mailbox,messageId)=>emailSDK.delete(mailbox,messageId);export default EmailSDK;
@@ -1,104 +1 @@
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
- }
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[email/suppression] email_suppressions table missing \u2014 suppression checks accepted but NOT enforced. "+"Run migrations to enable enforcement.")}export function isMissingTableError(err){const e=err,msg=e?.message??"";return e?.code==="42P01"||msg.includes("no such table")||msg.includes("doesn't exist")||/relation "[^"]*" does not exist/i.test(msg)}function isSuppressionStoreUnavailable(err){if(isMissingTableError(err))return!0;const msg=(err?.message??"").toLowerCase();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")}function canonicalize(email){return String(email).trim().toLowerCase()}export async function isSuppressed(email,type){const canon=canonicalize(email);try{let query=db.selectFrom("email_suppressions").where("email","=",canon).select(["email"]);if(type)query=query.where("type","=",type);const row=await query.executeTakeFirst();return Boolean(row)}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function getSuppressions(email){const canon=canonicalize(email);try{return await db.selectFrom("email_suppressions").where("email","=",canon).selectAll().execute()??[]}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function suppress(email,type,reason){const canon=canonicalize(email),createdAt=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("email_suppressions").values({email:canon,type,reason:reason??null,created_at:createdAt}).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}export async function unsuppress(email,type){const canon=canonicalize(email);try{await db.deleteFrom("email_suppressions").where("email","=",canon).where("type","=",type).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}throw err}}export async function getSuppressionPolicy(){try{const{config}=await import("@stacksjs/config"),policy=config?.email?.suppressionPolicy;if(policy==="strict"||policy==="transactional-allowed"||policy==="off")return policy}catch{}return"strict"}export async function checkSuppressionFor(email,tag){const policy=await getSuppressionPolicy();if(policy==="off")return null;if(policy==="transactional-allowed"&&tag==="transactional")return null;const suppressions=await getSuppressions(email);if(suppressions.length===0)return null;const priority=["unsubscribe","complaint","bounce","manual"];for(const p of priority)if(suppressions.some((s)=>s.type===p))return p;return suppressions[0].type}