@stacksjs/email 0.70.88 → 0.70.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css-inliner.d.ts +26 -0
- package/dist/css-inliner.js +172 -0
- package/dist/drivers/base.d.ts +14 -0
- package/dist/drivers/base.js +128 -0
- package/dist/drivers/capture.d.ts +55 -0
- package/dist/drivers/capture.js +30 -0
- package/dist/drivers/index.d.ts +10 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/log.d.ts +30 -0
- package/dist/drivers/log.js +81 -0
- package/dist/drivers/mailgun.d.ts +8 -0
- package/dist/drivers/mailgun.js +127 -0
- package/dist/drivers/mailtrap.d.ts +8 -0
- package/dist/drivers/mailtrap.js +129 -0
- package/dist/drivers/sendgrid.d.ts +8 -0
- package/dist/drivers/sendgrid.js +136 -0
- package/dist/drivers/ses.d.ts +8 -0
- package/dist/drivers/ses.js +113 -0
- package/dist/drivers/smtp.d.ts +14 -0
- package/dist/drivers/smtp.js +225 -0
- package/dist/email.d.ts +39 -0
- package/dist/email.js +198 -0
- package/dist/idempotency.d.ts +20 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +19 -0
- package/dist/mailable.d.ts +116 -0
- package/dist/mailable.js +145 -0
- package/dist/mime.d.ts +37 -0
- package/dist/mime.js +89 -0
- package/dist/preview-ui.d.ts +11 -0
- package/dist/preview-ui.js +132 -0
- package/dist/preview.d.ts +50 -0
- package/dist/preview.js +97 -0
- package/dist/sdk/index.d.ts +81 -0
- package/dist/sdk/index.js +219 -0
- package/dist/send.d.ts +1 -0
- package/dist/send.js +0 -0
- package/dist/server/converter.d.ts +1 -0
- package/dist/server/converter.js +0 -0
- package/dist/server/inbound.d.ts +1 -0
- package/dist/server/inbound.js +0 -0
- package/dist/server/outbound.d.ts +1 -0
- package/dist/server/outbound.js +0 -0
- package/dist/suppression.d.ts +82 -0
- package/dist/suppression.js +104 -0
- package/dist/template.d.ts +99 -0
- package/dist/template.js +170 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +0 -0
- package/dist/unsubscribe.d.ts +39 -0
- package/dist/unsubscribe.js +65 -0
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +3 -0
- package/dist/validation.d.ts +48 -0
- package/dist/validation.js +22 -0
- package/dist/webhook-dedup.d.ts +10 -0
- package/dist/webhook-dedup.js +33 -0
- package/dist/webhook-events.d.ts +34 -0
- package/dist/webhook-events.js +37 -0
- package/dist/webhook-handlers.d.ts +27 -0
- package/dist/webhook-handlers.js +264 -0
- package/dist/webhook-signatures.d.ts +91 -0
- package/dist/webhook-signatures.js +148 -0
- 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
|
+
}
|
package/dist/mailable.js
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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)} <${escape(a.address)}>`;
|
|
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/<slug>.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
|
+
}
|
package/dist/preview.js
ADDED
|
@@ -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
|
+
}
|