@stacksjs/email 0.70.88 → 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.
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,116 @@
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
+ }
@@ -0,0 +1,145 @@
1
+ import { config } from "@stacksjs/config";
2
+ import { mail } from "./email";
3
+ import { template as renderTemplate } from "./template";
4
+
5
+ export class Mailable {
6
+ _to = [];
7
+ _cc = [];
8
+ _bcc = [];
9
+ _replyTo;
10
+ _from;
11
+ _subject;
12
+ _text;
13
+ _html;
14
+ _template;
15
+ _attachments = [];
16
+ to(address) {
17
+ this._to = normalizeAddresses(address);
18
+ return this;
19
+ }
20
+ cc(address) {
21
+ this._cc = normalizeAddresses(address);
22
+ return this;
23
+ }
24
+ bcc(address) {
25
+ this._bcc = normalizeAddresses(address);
26
+ return this;
27
+ }
28
+ replyTo(address) {
29
+ this._replyTo = typeof address === "string" ? { address } : address;
30
+ return this;
31
+ }
32
+ from(addr) {
33
+ this._from = addr;
34
+ return this;
35
+ }
36
+ subject(s) {
37
+ this._subject = s;
38
+ return this;
39
+ }
40
+ text(body) {
41
+ this._text = body;
42
+ return this;
43
+ }
44
+ html(body) {
45
+ this._html = body;
46
+ return this;
47
+ }
48
+ template(name, ...rest) {
49
+ const props = rest[0] ?? {};
50
+ this._template = { name, props };
51
+ return this;
52
+ }
53
+ inspect() {
54
+ return {
55
+ to: this._to,
56
+ cc: this._cc,
57
+ bcc: this._bcc,
58
+ from: this._from,
59
+ replyTo: this._replyTo,
60
+ subject: this._subject,
61
+ text: this._text,
62
+ html: this._html,
63
+ template: this._template,
64
+ attachments: this._attachments
65
+ };
66
+ }
67
+ attach(path, name) {
68
+ this._attachments.push({
69
+ filename: name || basename(path),
70
+ content: `__file__:${path}`,
71
+ encoding: "binary"
72
+ });
73
+ return this;
74
+ }
75
+ attachData(buffer, name, mime) {
76
+ this._attachments.push({
77
+ filename: name,
78
+ content: buffer,
79
+ contentType: mime,
80
+ encoding: typeof buffer === "string" ? "utf8" : "binary"
81
+ });
82
+ return this;
83
+ }
84
+ async send(options = {}) {
85
+ await Promise.resolve(this.build());
86
+ if (this._to.length === 0)
87
+ throw Error("[Mailable] no recipients \u2014 call this.to(...) inside build()");
88
+ if (!this._subject)
89
+ throw Error("[Mailable] no subject \u2014 call this.subject(...) inside build()");
90
+ let html = this._html, text = this._text;
91
+ if (this._template) {
92
+ const rendered = await renderTemplate(this._template.name, {
93
+ variables: this._template.props,
94
+ subject: this._subject
95
+ });
96
+ if (!html)
97
+ html = rendered.html;
98
+ if (!text)
99
+ text = rendered.text;
100
+ }
101
+ const message = {
102
+ to: this._to,
103
+ subject: this._subject,
104
+ from: this._from || resolveDefaultFrom(),
105
+ ...this._cc.length ? { cc: this._cc } : {},
106
+ ...this._bcc.length ? { bcc: this._bcc } : {},
107
+ ...html ? { html } : {},
108
+ ...text ? { text } : {},
109
+ ...this._attachments.length ? { attachments: await materializeAttachments(this._attachments) } : {}
110
+ };
111
+ if (this._replyTo)
112
+ message.replyTo = this._replyTo;
113
+ return (options.driver ? mail.use(options.driver) : mail).send(message);
114
+ }
115
+ }
116
+ function normalizeAddresses(input) {
117
+ const arr = Array.isArray(input) ? input : [input];
118
+ if (arr.some((a) => typeof a === "object" && a !== null))
119
+ return arr.map((a) => typeof a === "string" ? { address: a } : a);
120
+ return arr;
121
+ }
122
+ function resolveDefaultFrom() {
123
+ return {
124
+ name: config.email.from?.name || "Stacks",
125
+ address: config.email.from?.address || "no-reply@stacksjs.com"
126
+ };
127
+ }
128
+ function basename(p) {
129
+ const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
130
+ return idx === -1 ? p : p.slice(idx + 1);
131
+ }
132
+ async function materializeAttachments(atts) {
133
+ return Promise.all(atts.map(async (att) => {
134
+ if (typeof att.content === "string" && att.content.startsWith("__file__:")) {
135
+ const filePath = att.content.slice(9), file = Bun.file(filePath), buf = new Uint8Array(await file.arrayBuffer());
136
+ return {
137
+ ...att,
138
+ content: buf,
139
+ contentType: att.contentType || file.type || void 0,
140
+ encoding: "binary"
141
+ };
142
+ }
143
+ return att;
144
+ }));
145
+ }
package/dist/mime.d.ts ADDED
@@ -0,0 +1,37 @@
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
+ }
package/dist/mime.js ADDED
@@ -0,0 +1,89 @@
1
+ import { Buffer } from "node:buffer";
2
+ export function encodeRfc2047IfNeeded(value) {
3
+ if (/^[\x00-\x7F]*$/.test(value))
4
+ return value;
5
+ return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
6
+ }
7
+ export function buildMimeMessage(options) {
8
+ const {
9
+ from,
10
+ to,
11
+ cc,
12
+ replyTo,
13
+ subject,
14
+ text,
15
+ html,
16
+ attachments,
17
+ messageIdDomain,
18
+ customHeaders
19
+ } = options, lines = [], hasAttachments = !!(attachments && attachments.length > 0), boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
20
+ lines.push(`From: ${from}`);
21
+ lines.push(`To: ${to}`);
22
+ if (cc)
23
+ lines.push(`Cc: ${cc}`);
24
+ if (replyTo)
25
+ lines.push(`Reply-To: ${replyTo}`);
26
+ lines.push(`Subject: ${encodeRfc2047IfNeeded(subject)}`);
27
+ lines.push("MIME-Version: 1.0");
28
+ lines.push(`Date: ${new Date().toUTCString()}`);
29
+ lines.push(`Message-ID: <${Date.now()}.${Math.random().toString(36).substring(2)}@${messageIdDomain || "localhost"}>`);
30
+ if (customHeaders)
31
+ for (const [k, v] of Object.entries(customHeaders))
32
+ lines.push(`${k}: ${v}`);
33
+ if (hasAttachments) {
34
+ const mixedBoundary = boundary, altBoundary = `${boundary}_alt`;
35
+ lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
36
+ lines.push("");
37
+ lines.push(`--${mixedBoundary}`);
38
+ pushBodyParts(lines, { text, html, altBoundary });
39
+ for (const attachment of attachments) {
40
+ lines.push(`--${mixedBoundary}`);
41
+ pushAttachmentPart(lines, attachment);
42
+ }
43
+ lines.push(`--${mixedBoundary}--`);
44
+ } else
45
+ pushBodyParts(lines, { text, html, altBoundary: boundary });
46
+ return lines.join(`\r
47
+ `);
48
+ }
49
+ function pushBodyParts(lines, options) {
50
+ const { text, html, altBoundary } = options;
51
+ if (html && text) {
52
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
53
+ lines.push("");
54
+ lines.push(`--${altBoundary}`);
55
+ lines.push("Content-Type: text/plain; charset=UTF-8");
56
+ lines.push("Content-Transfer-Encoding: 7bit");
57
+ lines.push("");
58
+ lines.push(text);
59
+ lines.push("");
60
+ lines.push(`--${altBoundary}`);
61
+ lines.push("Content-Type: text/html; charset=UTF-8");
62
+ lines.push("Content-Transfer-Encoding: 7bit");
63
+ lines.push("");
64
+ lines.push(html);
65
+ lines.push("");
66
+ lines.push(`--${altBoundary}--`);
67
+ } else if (html) {
68
+ lines.push("Content-Type: text/html; charset=UTF-8");
69
+ lines.push("Content-Transfer-Encoding: 7bit");
70
+ lines.push("");
71
+ lines.push(html);
72
+ } else if (text) {
73
+ lines.push("Content-Type: text/plain; charset=UTF-8");
74
+ lines.push("Content-Transfer-Encoding: 7bit");
75
+ lines.push("");
76
+ lines.push(text);
77
+ }
78
+ }
79
+ function pushAttachmentPart(lines, attachment) {
80
+ const contentType = attachment.contentType || "application/octet-stream", safeFilename = encodeRfc2047IfNeeded(attachment.filename.replace(/\\/g, "\\\\").replace(/"/g, "\\\""));
81
+ lines.push(`Content-Type: ${contentType}; name="${safeFilename}"`);
82
+ lines.push(`Content-Disposition: attachment; filename="${safeFilename}"`);
83
+ lines.push("Content-Transfer-Encoding: base64");
84
+ lines.push("");
85
+ const raw = typeof attachment.content === "string" ? Buffer.from(attachment.content, "utf-8").toString("base64") : Buffer.from(attachment.content).toString("base64"), wrapped = raw.match(/.{1,76}/g)?.join(`\r
86
+ `) ?? raw;
87
+ lines.push(wrapped);
88
+ lines.push("");
89
+ }
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,132 @@
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>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22
+ <title>${escape(title)} \u2014 Stacks Mail Preview</title>
23
+ <style>
24
+ :root { color-scheme: light dark; }
25
+ * { box-sizing: border-box; }
26
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background: #f4f4f5; color: #111827; line-height: 1.5; }
27
+ @media (prefers-color-scheme: dark) {
28
+ body { background: #0a0a0a; color: #ededed; }
29
+ .panel { background: #1a1a1a; border-color: #2a2a2a; }
30
+ a { color: #818cf8; }
31
+ .muted { color: #9ca3af; }
32
+ pre { background: #2a2a2a; color: #ededed; }
33
+ }
34
+ header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #e5e7eb; }
35
+ @media (prefers-color-scheme: dark) { header { background: #1a1a1a; border-color: #2a2a2a; } }
36
+ header h1 { margin: 0; font-size: 16px; font-weight: 600; }
37
+ header h1 a { color: inherit; text-decoration: none; }
38
+ header .crumb { font-size: 13px; color: #6b7280; }
39
+ main { padding: 24px; max-width: 1200px; margin: 0 auto; }
40
+ .panel { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
41
+ .grid { display: grid; grid-template-columns: 220px 1fr; gap: 16px; }
42
+ .pill { display: inline-block; padding: 2px 8px; font-size: 12px; border-radius: 999px; background: #eef2ff; color: #3730a3; }
43
+ .toolbar { display: flex; gap: 8px; padding: 8px 0; flex-wrap: wrap; align-items: center; }
44
+ .toolbar a { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; text-decoration: none; color: inherit; font-size: 13px; }
45
+ .toolbar a.active { background: #4f46e5; color: white; border-color: #4f46e5; }
46
+ .muted { color: #6b7280; font-size: 13px; }
47
+ iframe { width: 100%; min-height: 800px; border: 0; background: #fff; border-radius: 8px; }
48
+ iframe.mobile { max-width: 375px; margin: 0 auto; display: block; min-height: 700px; }
49
+ pre { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow: auto; font-size: 12px; line-height: 1.5; max-height: 400px; }
50
+ .meta { display: grid; grid-template-columns: 80px 1fr; gap: 6px 12px; font-size: 13px; }
51
+ .meta dt { color: #6b7280; }
52
+ .meta dd { margin: 0; }
53
+ .empty { text-align: center; padding: 48px 24px; color: #6b7280; }
54
+ ul.list { list-style: none; padding: 0; margin: 0; }
55
+ ul.list li { padding: 12px; border-bottom: 1px solid #e5e7eb; }
56
+ @media (prefers-color-scheme: dark) { ul.list li { border-color: #2a2a2a; } }
57
+ ul.list li:last-child { border-bottom: 0; }
58
+ ul.list a { color: inherit; text-decoration: none; font-weight: 500; }
59
+ ul.list a:hover { color: #4f46e5; }
60
+ .error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 12px 16px; border-radius: 6px; }
61
+ @media (prefers-color-scheme: dark) { .error { background: #1f0a0a; border-color: #7f1d1d; color: #fca5a5; } }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <header>
66
+ <h1><a href="${ROOT}">Mail Preview</a> <span class="crumb">${escape(title)}</span></h1>
67
+ </header>
68
+ <main>${body}</main>
69
+ </body>
70
+ </html>`;
71
+ }
72
+ export function renderIndexHtml(mailables) {
73
+ if (mailables.length === 0)
74
+ return shell("Index", `
75
+ <div class="empty panel">
76
+ <p>No Mailables found in <code>app/Mail/</code>.</p>
77
+ <p class="muted">Run <code>./buddy make:mail Welcome</code> to scaffold one.</p>
78
+ </div>
79
+ `);
80
+ const items = mailables.map((m) => `
81
+ <li>
82
+ <a href="${ROOT}/${escape(m.slug)}">${escape(m.name)}</a>
83
+ <div class="muted">app/Mail/${escape(m.name)}.ts \xB7 template: ${escape(m.slug)}.stx</div>
84
+ </li>
85
+ `).join("");
86
+ return shell("Index", `
87
+ <div class="panel">
88
+ <p class="muted">${mailables.length} mailable${mailables.length === 1 ? "" : "s"} discovered. Add sample props at
89
+ <code>resources/emails/_previews/&lt;slug&gt;.ts</code> for richer previews.</p>
90
+ <ul class="list">${items}</ul>
91
+ </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, `
97
+ ${errorBlock}
98
+
99
+ <div class="grid">
100
+ <aside>
101
+ <div class="panel">
102
+ <dl class="meta">
103
+ <dt>Subject</dt><dd>${escape(inspection.subject || "(no subject)")}</dd>
104
+ <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>` : ""}
111
+ </dl>
112
+ </div>
113
+
114
+ <div class="panel">
115
+ <h3 style="margin: 0 0 8px; font-size: 13px; font-weight: 600;">Sample props</h3>
116
+ ${sampleHint}
117
+ ${sampleProps ? `<pre>${escape(JSON.stringify(sampleProps, null, 2))}</pre>` : ""}
118
+ </div>
119
+ </aside>
120
+
121
+ <section>
122
+ <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>
126
+ <span class="muted" style="margin-left: auto;">${escape(mailable.name)}</span>
127
+ </div>
128
+ ${iframeBlock}
129
+ </section>
130
+ </div>
131
+ `);
132
+ }
@@ -0,0 +1,50 @@
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
+ }
@@ -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
+ }