@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,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,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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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;
|