@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,26 @@
1
+ /**
2
+ * Inline `<style>` blocks into per-element `style=""` attributes.
3
+ *
4
+ * Simple selectors (class `.x`, id `#x`, tag `p`, and chains like
5
+ * `p.foo` or `.a.b`) are inlined and removed from the source block.
6
+ * Rules that can't be safely inlined (`@media`, pseudo-classes,
7
+ * descendant / sibling combinators) stay in a slimmed-down `<style>`
8
+ * block so clients that DO honour styles can still apply them.
9
+ *
10
+ * Returns the HTML unchanged when `inline: false`. `<style
11
+ * data-inline="false">` blocks are passed through verbatim regardless
12
+ * of `inline`.
13
+ */
14
+ export declare function inlineCss(html: string, options?: InlineCssOptions): string;
15
+ /**
16
+ * Honour the global production-default. Apps that want explicit
17
+ * control should pass `inline` through {@link inlineCss} directly.
18
+ */
19
+ export declare function shouldInlineByDefault(): boolean;
20
+ /**
21
+ * Options accepted by {@link inlineCss}.
22
+ */
23
+ export declare interface InlineCssOptions {
24
+ inline?: boolean
25
+ important?: boolean
26
+ }
@@ -0,0 +1,172 @@
1
+ export function inlineCss(html, options = {}) {
2
+ const { inline = !0, important = !0 } = options;
3
+ if (!inline)
4
+ return html;
5
+ let working = html;
6
+ const passthroughBlocks = [];
7
+ working = working.replace(/<style\b[^>]*\bdata-inline=["']false["'][^>]*>[\s\S]*?<\/style>/gi, (match) => {
8
+ passthroughBlocks.push(match);
9
+ return `\x00STX_PASSTHROUGH_${passthroughBlocks.length - 1}\x00`;
10
+ });
11
+ const styleBlocks = [];
12
+ working = working.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_match, body) => {
13
+ styleBlocks.push(body);
14
+ return "";
15
+ });
16
+ if (styleBlocks.length === 0)
17
+ return restorePassthroughs(working, passthroughBlocks);
18
+ const inlinableRules = [], leftover = [];
19
+ for (const body of styleBlocks)
20
+ for (const rule of splitRules(body)) {
21
+ const { selector, declarations } = rule;
22
+ if (!selector || !declarations)
23
+ continue;
24
+ if (isInlinable(selector))
25
+ inlinableRules.push({ selector, decls: declarations });
26
+ else
27
+ leftover.push(`${selector} { ${declarations.map((d) => `${d.prop}: ${d.value};`).join(" ")} }`);
28
+ }
29
+ for (const { selector, decls } of inlinableRules)
30
+ working = applyRule(working, selector, decls, important);
31
+ if (leftover.length > 0) {
32
+ const styleTag = `<style>
33
+ ${leftover.join(`
34
+ `)}
35
+ </style>`;
36
+ working = /<\/head>/i.test(working) ? working.replace(/<\/head>/i, `${styleTag}
37
+ </head>`) : `${styleTag}
38
+ ${working}`;
39
+ }
40
+ return restorePassthroughs(working, passthroughBlocks);
41
+ }
42
+ function restorePassthroughs(html, blocks) {
43
+ if (blocks.length === 0)
44
+ return html;
45
+ return html.replace(/\u0000STX_PASSTHROUGH_(\d+)\u0000/g, (_match, idx) => {
46
+ return blocks[Number.parseInt(idx, 10)] ?? "";
47
+ });
48
+ }
49
+ function splitRules(body) {
50
+ const cleaned = body.replace(/\/\*[\s\S]*?\*\//g, ""), rules = [];
51
+ let i = 0;
52
+ while (i < cleaned.length) {
53
+ const open = cleaned.indexOf("{", i);
54
+ if (open === -1)
55
+ break;
56
+ const selector = cleaned.slice(i, open).trim();
57
+ let depth = 1, j = open + 1;
58
+ while (j < cleaned.length && depth > 0) {
59
+ const ch = cleaned[j];
60
+ if (ch === "{")
61
+ depth++;
62
+ else if (ch === "}")
63
+ depth--;
64
+ j++;
65
+ }
66
+ const inner = cleaned.slice(open + 1, j - 1);
67
+ if (selector.startsWith("@") || inner.includes("{"))
68
+ rules.push({
69
+ selector: selector || "@unknown",
70
+ declarations: parseDeclarationsLoose(inner)
71
+ });
72
+ else
73
+ rules.push({ selector, declarations: parseDeclarations(inner) });
74
+ i = j;
75
+ }
76
+ return rules;
77
+ }
78
+ function parseDeclarations(body) {
79
+ return body.split(";").map((decl) => decl.trim()).filter(Boolean).map((decl) => {
80
+ const colon = decl.indexOf(":");
81
+ if (colon === -1)
82
+ return null;
83
+ return {
84
+ prop: decl.slice(0, colon).trim(),
85
+ value: decl.slice(colon + 1).trim()
86
+ };
87
+ }).filter((d) => d !== null);
88
+ }
89
+ function parseDeclarationsLoose(body) {
90
+ return [{ prop: "", value: body.trim() }];
91
+ }
92
+ function isInlinable(selector) {
93
+ if (!selector)
94
+ return !1;
95
+ if (selector.includes(","))
96
+ return selector.split(",").every((s) => isInlinable(s.trim()));
97
+ if (selector.startsWith("@"))
98
+ return !1;
99
+ if (/[\s>+~:[]/.test(selector))
100
+ return !1;
101
+ return /^[a-z][a-z0-9-]*?$|^([a-z][a-z0-9-]*)?([.#][a-z][\w-]*)+$/i.test(selector);
102
+ }
103
+ function applyRule(html, selector, decls, important) {
104
+ if (selector.includes(",")) {
105
+ let acc = html;
106
+ for (const branch of selector.split(","))
107
+ acc = applyRule(acc, branch.trim(), decls, important);
108
+ return acc;
109
+ }
110
+ const { tag, classes, ids } = parseSimpleSelector(selector), tagRe = new RegExp(`<(${tag ?? "[a-z][a-z0-9-]*"})\\b([^>]*?)(/?)>`, "gi"), newDeclString = decls.map((d) => `${d.prop}:${d.value}${important && !/!important\b/i.test(d.value) ? " !important" : ""};`).join("");
111
+ return html.replace(tagRe, (match, _tagName, attrs) => {
112
+ if (!elementMatches(attrs, classes, ids))
113
+ return match;
114
+ return mergeStyleAttr(match, attrs, newDeclString);
115
+ });
116
+ }
117
+ function parseSimpleSelector(selector) {
118
+ let tag = null;
119
+ const classes = [], ids = [];
120
+ let i = 0;
121
+ while (i < selector.length && /[a-z0-9-]/i.test(selector[i])) {
122
+ tag = (tag ?? "") + selector[i];
123
+ i++;
124
+ }
125
+ if (tag === "")
126
+ tag = null;
127
+ while (i < selector.length) {
128
+ const ch = selector[i];
129
+ if (ch !== "." && ch !== "#")
130
+ break;
131
+ let j = i + 1;
132
+ while (j < selector.length && /[\w-]/.test(selector[j]))
133
+ j++;
134
+ const name = selector.slice(i + 1, j);
135
+ if (ch === ".")
136
+ classes.push(name);
137
+ else
138
+ ids.push(name);
139
+ i = j;
140
+ }
141
+ return { tag, classes, ids };
142
+ }
143
+ function elementMatches(attrs, classes, ids) {
144
+ if (classes.length > 0) {
145
+ const classMatch = attrs.match(/\bclass\s*=\s*["']([^"']*)["']/i);
146
+ if (!classMatch)
147
+ return !1;
148
+ const present = classMatch[1].split(/\s+/).filter(Boolean);
149
+ if (!classes.every((c) => present.includes(c)))
150
+ return !1;
151
+ }
152
+ if (ids.length > 0) {
153
+ const idMatch = attrs.match(/\bid\s*=\s*["']([^"']+)["']/i);
154
+ if (!idMatch)
155
+ return !1;
156
+ if (!ids.every((id) => idMatch[1] === id))
157
+ return !1;
158
+ }
159
+ return !0;
160
+ }
161
+ function mergeStyleAttr(originalTag, attrs, newDeclString) {
162
+ const styleMatch = attrs.match(/\bstyle\s*=\s*["']([^"']*)["']/i);
163
+ if (styleMatch) {
164
+ const existing = styleMatch[1].trim(), merged = `${newDeclString}${existing}${existing.endsWith(";") || existing === "" ? "" : ";"}`, replaced = attrs.replace(styleMatch[0], `style="${merged}"`);
165
+ return originalTag.replace(attrs, replaced);
166
+ }
167
+ const extra = ` style="${newDeclString}"`;
168
+ return originalTag.replace(/(\s*\/?)>$/, `${extra}$1>`);
169
+ }
170
+ export function shouldInlineByDefault() {
171
+ return (globalThis.process?.env?.APP_ENV ?? globalThis.process?.env?.NODE_ENV ?? "").toLowerCase() === "production";
172
+ }
@@ -0,0 +1,14 @@
1
+ import type { EmailAddress, EmailDriver, EmailDriverConfig, EmailMessage, EmailResult } from '@stacksjs/types';
2
+ import type { TemplateOptions } from '../template';
3
+ export declare abstract class BaseEmailDriver implements EmailDriver {
4
+ abstract name: string;
5
+ protected config: Required<EmailDriverConfig>;
6
+ constructor(config?: EmailDriverConfig);
7
+ configure(config: EmailDriverConfig): void;
8
+ abstract send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
9
+ protected validateMessage(message: EmailMessage): boolean;
10
+ protected formatAddresses(addresses: string | string[] | EmailAddress[] | undefined): string[];
11
+ protected formatAddressList(value: string | string[] | EmailAddress | EmailAddress[] | undefined): string[];
12
+ protected handleError(error: unknown, message: EmailMessage): Promise<EmailResult>;
13
+ protected handleSuccess(message: EmailMessage, messageId?: string): Promise<EmailResult>;
14
+ }
@@ -0,0 +1,128 @@
1
+ import { config as appConfig } from "@stacksjs/config";
2
+ import { log } from "@stacksjs/logging";
3
+ import { assertEnvelopeAddress, assertHeaderSafeSubject } from "../validation";
4
+
5
+ export class BaseEmailDriver {
6
+ config;
7
+ constructor(config) {
8
+ this.config = {
9
+ maxRetries: config?.maxRetries || 3,
10
+ retryTimeout: config?.retryTimeout || 1000,
11
+ ...config
12
+ };
13
+ }
14
+ configure(config) {
15
+ this.config = { ...this.config, ...config };
16
+ }
17
+ validateMessage(message) {
18
+ if (!message.from?.address && !appConfig.email.from?.address)
19
+ throw Error("Email sender address is required either in message or config");
20
+ if (!message.to || Array.isArray(message.to) && message.to.length === 0)
21
+ throw Error("At least one recipient is required");
22
+ if (!message.subject)
23
+ throw Error("Email subject is required");
24
+ assertHeaderSafeSubject(message.subject);
25
+ const checkAddress = (raw, role) => {
26
+ if (!raw)
27
+ return;
28
+ assertEnvelopeAddress(raw, role);
29
+ }, flatten = (v) => {
30
+ if (!v)
31
+ return [];
32
+ if (typeof v === "string")
33
+ return [v];
34
+ if (Array.isArray(v))
35
+ return v.flatMap((item) => typeof item === "string" ? [item] : item?.address ? [item.address] : []);
36
+ const obj = v;
37
+ return obj.address ? [obj.address] : [];
38
+ };
39
+ if (message.from)
40
+ checkAddress(message.from.address, "from");
41
+ for (const addr of flatten(message.to))
42
+ checkAddress(addr, "to");
43
+ for (const addr of flatten(message.cc))
44
+ checkAddress(addr, "cc");
45
+ for (const addr of flatten(message.bcc))
46
+ checkAddress(addr, "bcc");
47
+ return !0;
48
+ }
49
+ formatAddresses(addresses) {
50
+ if (!addresses)
51
+ return [];
52
+ if (typeof addresses === "string")
53
+ return [addresses];
54
+ return addresses.map((_addr) => {
55
+ if (typeof _addr === "string")
56
+ return _addr;
57
+ if (!_addr.name)
58
+ return _addr.address;
59
+ return `${/[",()<>[\]:;@\\]/.test(_addr.name) ? `"${_addr.name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : _addr.name} <${_addr.address}>`;
60
+ });
61
+ }
62
+ formatAddressList(value) {
63
+ if (!value)
64
+ return [];
65
+ if (Array.isArray(value))
66
+ return this.formatAddresses(value);
67
+ if (typeof value === "string")
68
+ return this.formatAddresses(value);
69
+ return this.formatAddresses([value]);
70
+ }
71
+ async handleError(error, message) {
72
+ const err = error instanceof Error ? error : Error(String(error));
73
+ log.error(`[${this.name}] Email sending failed`, {
74
+ error: err.message,
75
+ stack: err.stack,
76
+ to: message.to,
77
+ subject: message.subject
78
+ });
79
+ let result = {
80
+ message: `Email sending failed: ${err.message}`,
81
+ success: !1,
82
+ provider: this.name
83
+ };
84
+ if (message.onError) {
85
+ const customResult = message.onError(err), handlerResult = customResult instanceof Promise ? await customResult : customResult;
86
+ result = {
87
+ ...result,
88
+ ...handlerResult,
89
+ success: !1,
90
+ provider: this.name
91
+ };
92
+ }
93
+ return result;
94
+ }
95
+ async handleSuccess(message, messageId) {
96
+ let result = {
97
+ message: "Email sent successfully",
98
+ success: !0,
99
+ provider: this.name,
100
+ messageId
101
+ };
102
+ try {
103
+ if (message.handle) {
104
+ const customResult = message.handle(), handlerResult = customResult instanceof Promise ? await customResult : customResult;
105
+ result = {
106
+ ...result,
107
+ ...handlerResult,
108
+ success: !0,
109
+ provider: this.name,
110
+ messageId
111
+ };
112
+ }
113
+ if (message.onSuccess) {
114
+ const successResult = message.onSuccess(), handlerResult = successResult instanceof Promise ? await successResult : successResult;
115
+ result = {
116
+ ...result,
117
+ ...handlerResult,
118
+ success: !0,
119
+ provider: this.name,
120
+ messageId
121
+ };
122
+ }
123
+ } catch (error) {
124
+ return this.handleError(error, message);
125
+ }
126
+ return result;
127
+ }
128
+ }
@@ -0,0 +1,55 @@
1
+ import { BaseEmailDriver } from './base';
2
+ import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
+ import type { TemplateOptions } from '../template';
4
+ /**
5
+ * Test-only capture driver (stacksjs/stacks#1871 M-12).
6
+ *
7
+ * Records every `mail.send(...)` payload in memory without opening
8
+ * a network socket or writing to disk. Lighter-weight than the `log`
9
+ * driver (which writes inspection files to `storage/logs/mail/` and
10
+ * does template rendering) — chosen by tests that just need to
11
+ * assert "this flow sent that email" without any I/O side effect.
12
+ *
13
+ * Pick this driver when:
14
+ * - running fast unit tests that shouldn't touch the filesystem
15
+ * - asserting on the exact message shape (subject/body/headers)
16
+ * before any driver-specific transformation
17
+ * - writing tests that mutate captured state and need
18
+ * `CaptureEmailDriver.clear()` between cases
19
+ *
20
+ * The `log` driver remains the better fit for local dev (because
21
+ * the disk dump makes inspection easy) and for CI smoke tests that
22
+ * want to surface a render failure visibly. Capture is for unit
23
+ * tests.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // bunfig.toml or test setup
28
+ * config.email.default = 'capture'
29
+ *
30
+ * // in tests
31
+ * import { CaptureEmailDriver } from '@stacksjs/email/drivers/capture'
32
+ *
33
+ * beforeEach(() => CaptureEmailDriver.clear())
34
+ *
35
+ * test('signup sends welcome', async () => {
36
+ * await POST('/api/signup', { email: 'a@b.com' })
37
+ * const sent = CaptureEmailDriver.all()
38
+ * expect(sent).toHaveLength(1)
39
+ * expect(sent[0].subject).toContain('Welcome')
40
+ * expect(CaptureEmailDriver.last()?.to).toBe('a@b.com')
41
+ * })
42
+ * ```
43
+ */
44
+ export declare interface CapturedMessage extends EmailMessage {
45
+ sentAt: Date
46
+ messageId: string
47
+ }
48
+ export declare class CaptureEmailDriver extends BaseEmailDriver {
49
+ name: string;
50
+ send(message: EmailMessage, _options?: TemplateOptions): Promise<EmailResult>;
51
+ static all(): readonly CapturedMessage[];
52
+ static last(): CapturedMessage | undefined;
53
+ static count(): number;
54
+ static clear(): void;
55
+ }
@@ -0,0 +1,30 @@
1
+ import { BaseEmailDriver } from "./base";
2
+ const captured = [];
3
+ let nextId = 1;
4
+
5
+ export class CaptureEmailDriver extends BaseEmailDriver {
6
+ name = "capture";
7
+ async send(message, _options) {
8
+ try {
9
+ this.validateMessage(message);
10
+ const sentAt = new Date, messageId = `capture-${sentAt.getTime()}-${nextId++}`;
11
+ captured.push({ ...message, sentAt, messageId });
12
+ return this.handleSuccess(message, messageId);
13
+ } catch (error) {
14
+ return this.handleError(error, message);
15
+ }
16
+ }
17
+ static all() {
18
+ return captured;
19
+ }
20
+ static last() {
21
+ return captured[captured.length - 1];
22
+ }
23
+ static count() {
24
+ return captured.length;
25
+ }
26
+ static clear() {
27
+ captured.length = 0;
28
+ nextId = 1;
29
+ }
30
+ }
@@ -0,0 +1,10 @@
1
+ export * as capture from './capture';
2
+ export * as log from './log';
3
+ export * as mailgun from './mailgun';
4
+ export * as mailtrap from './mailtrap';
5
+ // `nodemailer` driver removed — it was a throwing stub that surfaced as a
6
+ // runtime crash on `mail.send()` only after a user had already wired it
7
+ // into config. Use the SMTP driver (`smtp` in MAIL_MAILER) for SMTP-based
8
+ // providers; see stacksjs/stacks#1871 M-7.
9
+ export * as sendgrid from './sendgrid';
10
+ export * as ses from './ses';
@@ -0,0 +1,6 @@
1
+ export * as capture from "./capture";
2
+ export * as log from "./log";
3
+ export * as mailgun from "./mailgun";
4
+ export * as mailtrap from "./mailtrap";
5
+ export * as sendgrid from "./sendgrid";
6
+ export * as ses from "./ses";
@@ -0,0 +1,30 @@
1
+ import { BaseEmailDriver } from './base';
2
+ import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
+ import type { TemplateOptions } from '../template';
4
+ declare const captured: CapturedEmail[];
5
+ /**
6
+ * Local-only email driver that never opens a network socket. Renders
7
+ * the message to disk so devs can inspect it (and tests can read it),
8
+ * and remembers the last N sends in-memory so tests can assert against
9
+ * them without scraping log output.
10
+ *
11
+ * Pick this driver when:
12
+ * - running tests (no SMTP credentials, deterministic output)
13
+ * - local development (no AWS/SendGrid setup, no mailbox spam)
14
+ * - CI smoke tests where we want to assert "an email was sent"
15
+ *
16
+ * In production, use `ses` / `sendgrid` / `mailgun` / `smtp` instead.
17
+ */
18
+ declare interface CapturedEmail extends EmailMessage {
19
+ sentAt: Date
20
+ rendered?: { html?: string, text?: string }
21
+ }
22
+ export declare class LogEmailDriver extends BaseEmailDriver {
23
+ name: string;
24
+ send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
25
+ static captured(): readonly CapturedEmail[];
26
+ static reset(): void;
27
+ }
28
+ // Convenience export to mirror the other drivers' module shape — the
29
+ // drivers/index.ts re-exports each driver namespace (`export * as log`).
30
+ export default LogEmailDriver;
@@ -0,0 +1,81 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { log } from "@stacksjs/logging";
4
+ import { template } from "../template";
5
+ import { BaseEmailDriver } from "./base";
6
+ const STORE_LIMIT = 100, captured = [];
7
+
8
+ export class LogEmailDriver extends BaseEmailDriver {
9
+ name = "log";
10
+ resolveDir() {
11
+ const fromEnv = process.env.LOG_MAIL_DIR;
12
+ if (fromEnv)
13
+ return resolve(fromEnv);
14
+ return resolve(join(import.meta.dir, "..", "..", "..", "..", "..", "logs", "mail"));
15
+ }
16
+ async send(message, options) {
17
+ try {
18
+ this.validateMessage(message);
19
+ let rendered;
20
+ if (message.template) {
21
+ const t = await template(message.template, options);
22
+ if (t)
23
+ rendered = { html: t.html, text: t.text };
24
+ }
25
+ const html = rendered?.html ?? message.html, text = rendered?.text ?? message.text, stamp = new Date, safeSubject = (message.subject || "no-subject").replace(/[^\w.-]+/g, "-").slice(0, 60), filename = `${stamp.toISOString().replace(/[:.]/g, "-")}-${safeSubject}.html`, dir = this.resolveDir();
26
+ try {
27
+ await mkdir(dir, { recursive: !0 });
28
+ const filePath = join(dir, filename), body = html ? html : text ? `<pre>${escapeHtml(text)}</pre>` : "<em>(empty body)</em>", headerBlock = renderHeader({ stamp, message });
29
+ await writeFile(filePath, `${headerBlock}
30
+ ${body}
31
+ `);
32
+ } catch (err) {
33
+ log.warn(`[email:log] could not write inspection file: ${err.message}`);
34
+ }
35
+ const flatTo = Array.isArray(message.to) ? message.to.map((t) => typeof t === "string" ? t : t.address).join(", ") : typeof message.to === "string" ? message.to : message.to.address;
36
+ log.info(`[email:log] would send \u2192 ${flatTo} :: ${message.subject}`);
37
+ captured.push({ ...message, sentAt: stamp, rendered });
38
+ if (captured.length > STORE_LIMIT)
39
+ captured.splice(0, captured.length - STORE_LIMIT);
40
+ return this.handleSuccess(message, `log-${stamp.getTime()}`);
41
+ } catch (error) {
42
+ return this.handleError(error, message);
43
+ }
44
+ }
45
+ static captured() {
46
+ return captured;
47
+ }
48
+ static reset() {
49
+ captured.length = 0;
50
+ }
51
+ }
52
+ function renderHeader({ stamp, message }) {
53
+ return [
54
+ "<!--",
55
+ ` Captured by @stacksjs/email log driver at ${stamp.toISOString()}`,
56
+ ` From: ${formatAddr(message.from)}`,
57
+ ` To: ${formatList(message.to)}`,
58
+ message.cc ? ` Cc: ${formatList(message.cc)}` : null,
59
+ message.bcc ? ` Bcc: ${formatList(message.bcc)}` : null,
60
+ ` Subject: ${message.subject}`,
61
+ "-->"
62
+ ].filter(Boolean).join(`
63
+ `);
64
+ }
65
+ function formatAddr(v) {
66
+ if (!v)
67
+ return "";
68
+ if (typeof v === "string")
69
+ return v;
70
+ const o = v;
71
+ return o.name ? `${o.name} <${o.address ?? ""}>` : o.address ?? "";
72
+ }
73
+ function formatList(v) {
74
+ if (Array.isArray(v))
75
+ return v.map(formatAddr).join(", ");
76
+ return formatAddr(v);
77
+ }
78
+ function escapeHtml(s) {
79
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
80
+ }
81
+ export default LogEmailDriver;
@@ -0,0 +1,8 @@
1
+ import { BaseEmailDriver } from './base';
2
+ import type { EmailMessage, EmailResult } from '@stacksjs/types';
3
+ import type { TemplateOptions } from '../template';
4
+ export declare class MailgunDriver extends BaseEmailDriver {
5
+ name: string;
6
+ send(message: EmailMessage, options?: TemplateOptions): Promise<EmailResult>;
7
+ }
8
+ export default MailgunDriver;