@stacksjs/email 0.70.87 → 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.
- package/dist/css-inliner.js +172 -0
- package/dist/drivers/base.js +128 -0
- package/dist/drivers/capture.js +30 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/log.js +81 -0
- package/dist/drivers/mailgun.js +127 -0
- package/dist/drivers/mailtrap.js +129 -0
- package/dist/drivers/sendgrid.js +136 -0
- package/dist/drivers/ses.js +113 -0
- package/dist/drivers/smtp.js +225 -0
- package/dist/email.js +198 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.js +19 -28
- package/dist/mailable.js +145 -0
- package/dist/mime.js +89 -0
- package/dist/preview-ui.js +132 -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.js +104 -0
- package/dist/template.js +170 -0
- package/dist/types.js +0 -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.js +22 -0
- package/dist/webhook-dedup.js +33 -0
- package/dist/webhook-events.js +37 -0
- package/dist/webhook-handlers.js +264 -0
- package/dist/webhook-signatures.js +148 -0
- package/package.json +5 -5
|
@@ -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,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,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,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,127 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { config } from "@stacksjs/config";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
import { template } from "../template";
|
|
5
|
+
import { BaseEmailDriver } from "./base";
|
|
6
|
+
|
|
7
|
+
export class MailgunDriver extends BaseEmailDriver {
|
|
8
|
+
name = "mailgun";
|
|
9
|
+
apiKey = null;
|
|
10
|
+
domain = null;
|
|
11
|
+
endpoint = null;
|
|
12
|
+
getConfig() {
|
|
13
|
+
if (!this.apiKey || !this.domain || !this.endpoint) {
|
|
14
|
+
this.apiKey = config.services.mailgun?.apiKey ?? "";
|
|
15
|
+
this.domain = config.services.mailgun?.domain ?? "";
|
|
16
|
+
this.endpoint = config.services.mailgun?.endpoint ?? "api.mailgun.net";
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
apiKey: this.apiKey,
|
|
20
|
+
domain: this.domain,
|
|
21
|
+
endpoint: this.endpoint
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async send(message, options) {
|
|
25
|
+
const { domain } = this.getConfig(), logContext = {
|
|
26
|
+
provider: this.name,
|
|
27
|
+
to: message.to,
|
|
28
|
+
subject: message.subject,
|
|
29
|
+
domain
|
|
30
|
+
};
|
|
31
|
+
log.info("Sending email via Mailgun...", logContext);
|
|
32
|
+
try {
|
|
33
|
+
this.validateMessage(message);
|
|
34
|
+
let htmlContent;
|
|
35
|
+
if (message.template) {
|
|
36
|
+
const templ = await template(message.template, options);
|
|
37
|
+
if (templ && "html" in templ)
|
|
38
|
+
htmlContent = templ.html;
|
|
39
|
+
}
|
|
40
|
+
const finalHtml = htmlContent || message.html, formData = new FormData, fromAddress = {
|
|
41
|
+
address: message.from?.address || config.email.from?.address || "",
|
|
42
|
+
name: message.from?.name || config.email.from?.name
|
|
43
|
+
};
|
|
44
|
+
formData.append("from", this.formatMailgunAddress(fromAddress));
|
|
45
|
+
this.formatMailgunAddresses(message.to).forEach((to) => formData.append("to", to));
|
|
46
|
+
if (message.cc)
|
|
47
|
+
this.formatMailgunAddresses(message.cc).forEach((cc) => formData.append("cc", cc));
|
|
48
|
+
if (message.bcc)
|
|
49
|
+
this.formatMailgunAddresses(message.bcc).forEach((bcc) => formData.append("bcc", bcc));
|
|
50
|
+
formData.append("subject", message.subject);
|
|
51
|
+
if (message.replyTo) {
|
|
52
|
+
const formatted = this.formatMailgunAddresses(Array.isArray(message.replyTo) || typeof message.replyTo === "string" ? message.replyTo : [message.replyTo]);
|
|
53
|
+
if (formatted.length > 0)
|
|
54
|
+
formData.append("h:Reply-To", formatted.join(", "));
|
|
55
|
+
}
|
|
56
|
+
if (message.headers) {
|
|
57
|
+
for (const [k, v] of Object.entries(message.headers))
|
|
58
|
+
if (typeof v === "string")
|
|
59
|
+
formData.append(`h:${k}`, v);
|
|
60
|
+
}
|
|
61
|
+
if (finalHtml)
|
|
62
|
+
formData.append("html", finalHtml);
|
|
63
|
+
if (message.text)
|
|
64
|
+
formData.append("text", message.text);
|
|
65
|
+
if (message.attachments)
|
|
66
|
+
message.attachments.forEach((attachment) => {
|
|
67
|
+
const content = typeof attachment.content === "string" ? attachment.content : this.arrayBufferToBase64(attachment.content);
|
|
68
|
+
formData.append("attachment", new Blob([content], { type: attachment.contentType }), attachment.filename);
|
|
69
|
+
});
|
|
70
|
+
const response = await this.sendWithRetry(formData);
|
|
71
|
+
return this.handleSuccess(message, response.id);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return this.handleError(error, message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
formatMailgunAddress(address) {
|
|
77
|
+
return address.name ? `${address.name} <${address.address}>` : address.address;
|
|
78
|
+
}
|
|
79
|
+
formatMailgunAddresses(addresses) {
|
|
80
|
+
if (!addresses)
|
|
81
|
+
return [];
|
|
82
|
+
if (typeof addresses === "string")
|
|
83
|
+
return [addresses];
|
|
84
|
+
return addresses.map((_addr) => {
|
|
85
|
+
if (typeof _addr === "string")
|
|
86
|
+
return _addr;
|
|
87
|
+
if (!_addr.name)
|
|
88
|
+
return _addr.address;
|
|
89
|
+
return `${/[",()<>[\]:;@\\]/.test(_addr.name) ? `"${_addr.name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : _addr.name} <${_addr.address}>`;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
arrayBufferToBase64(buffer) {
|
|
93
|
+
let binary = "";
|
|
94
|
+
const bytes = new Uint8Array(buffer), len = bytes.byteLength;
|
|
95
|
+
for (let i = 0;i < len; i++)
|
|
96
|
+
binary += String.fromCharCode(bytes[i] ?? 0);
|
|
97
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary).toString("base64");
|
|
98
|
+
}
|
|
99
|
+
async sendWithRetry(formData, attempt = 1) {
|
|
100
|
+
const { apiKey, domain, endpoint } = this.getConfig(), url = `https://${endpoint}/v3/${domain}/messages`, auth = Buffer.from(`api:${apiKey}`).toString("base64");
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Basic ${auth}`
|
|
106
|
+
},
|
|
107
|
+
body: formData
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const errorData = await response.json();
|
|
111
|
+
throw Error(`Mailgun API error: ${response.status} - ${JSON.stringify(errorData)}`);
|
|
112
|
+
}
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
log.info(`[${this.name}] Email sent successfully`, { attempt, messageId: data.id });
|
|
115
|
+
return data;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (attempt < (config.services.mailgun?.maxRetries ?? 3)) {
|
|
118
|
+
const retryTimeout = config.services.mailgun?.retryTimeout ?? 1000;
|
|
119
|
+
log.warn(`[${this.name}] Email send failed, retrying (${attempt}/${config.services.mailgun?.maxRetries ?? 3})`);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
|
|
121
|
+
return this.sendWithRetry(formData, attempt + 1);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export default MailgunDriver;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { config } from "@stacksjs/config";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
import { template } from "../template";
|
|
5
|
+
import { filterStringHeaders } from "../validation";
|
|
6
|
+
import { BaseEmailDriver } from "./base";
|
|
7
|
+
|
|
8
|
+
export class MailtrapDriver extends BaseEmailDriver {
|
|
9
|
+
name = "mailtrap";
|
|
10
|
+
host = null;
|
|
11
|
+
token = null;
|
|
12
|
+
inboxId = null;
|
|
13
|
+
getConfig() {
|
|
14
|
+
if (this.host === null || this.token === null || this.inboxId === null) {
|
|
15
|
+
this.host = config.services.mailtrap?.host ?? "https://sandbox.api.mailtrap.io/api/send";
|
|
16
|
+
this.token = config.services.mailtrap?.token ?? "";
|
|
17
|
+
this.inboxId = config.services.mailtrap?.inboxId ? Number(config.services.mailtrap.inboxId) : void 0;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
host: this.host,
|
|
21
|
+
token: this.token,
|
|
22
|
+
inboxId: this.inboxId
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async send(message, options) {
|
|
26
|
+
const { inboxId } = this.getConfig(), logContext = {
|
|
27
|
+
provider: this.name,
|
|
28
|
+
to: message.to,
|
|
29
|
+
subject: message.subject,
|
|
30
|
+
inboxId
|
|
31
|
+
};
|
|
32
|
+
log.info("Sending email via Mailtrap...", logContext);
|
|
33
|
+
try {
|
|
34
|
+
this.validateMessage(message);
|
|
35
|
+
let templ;
|
|
36
|
+
if (message.template)
|
|
37
|
+
templ = await template(message.template, options);
|
|
38
|
+
const htmlContent = templ?.html || message.html, replyTo = this.firstMailtrapAddress(message.replyTo), customHeaders = filterStringHeaders(message.headers), mailtrapPayload = {
|
|
39
|
+
from: {
|
|
40
|
+
email: message.from?.address || config.email.from?.address || "",
|
|
41
|
+
name: message.from?.name || config.email.from?.name
|
|
42
|
+
},
|
|
43
|
+
to: this.formatMailtrapAddresses(message.to),
|
|
44
|
+
...message.cc && { cc: this.formatMailtrapAddresses(message.cc) },
|
|
45
|
+
...message.bcc && { bcc: this.formatMailtrapAddresses(message.bcc) },
|
|
46
|
+
...replyTo ? { reply_to: replyTo } : {},
|
|
47
|
+
...customHeaders ? { headers: customHeaders } : {},
|
|
48
|
+
subject: message.subject,
|
|
49
|
+
...htmlContent && { html: htmlContent },
|
|
50
|
+
...message.text && { text: message.text },
|
|
51
|
+
...message.attachments && {
|
|
52
|
+
attachments: message.attachments.map((attachment) => ({
|
|
53
|
+
filename: attachment.filename,
|
|
54
|
+
content: typeof attachment.content === "string" ? attachment.content : this.arrayBufferToBase64(attachment.content),
|
|
55
|
+
type: attachment.contentType || "application/octet-stream"
|
|
56
|
+
}))
|
|
57
|
+
}
|
|
58
|
+
}, response = await this.sendWithRetry(mailtrapPayload);
|
|
59
|
+
return this.handleSuccess(message, response.message_ids?.[0]);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return this.handleError(error, message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
formatMailtrapAddresses(addresses) {
|
|
65
|
+
if (!addresses)
|
|
66
|
+
return [];
|
|
67
|
+
if (typeof addresses === "string")
|
|
68
|
+
return [{ email: addresses }];
|
|
69
|
+
return addresses.map((addr) => {
|
|
70
|
+
if (typeof addr === "string")
|
|
71
|
+
return { email: addr };
|
|
72
|
+
return { email: addr.address, ...addr.name && { name: addr.name } };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
firstMailtrapAddress(value) {
|
|
76
|
+
if (!value)
|
|
77
|
+
return;
|
|
78
|
+
if (typeof value === "string")
|
|
79
|
+
return { email: value };
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
const first = value[0];
|
|
82
|
+
if (first === void 0)
|
|
83
|
+
return;
|
|
84
|
+
if (typeof first === "string")
|
|
85
|
+
return { email: first };
|
|
86
|
+
return { email: first.address, ...first.name && { name: first.name } };
|
|
87
|
+
}
|
|
88
|
+
return { email: value.address, ...value.name && { name: value.name } };
|
|
89
|
+
}
|
|
90
|
+
arrayBufferToBase64(buffer) {
|
|
91
|
+
let binary = "";
|
|
92
|
+
const bytes = new Uint8Array(buffer), len = bytes.byteLength;
|
|
93
|
+
for (let i = 0;i < len; i++)
|
|
94
|
+
binary += String.fromCharCode(bytes[i] ?? 0);
|
|
95
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary).toString("base64");
|
|
96
|
+
}
|
|
97
|
+
async sendWithRetry(payload, attempt = 1) {
|
|
98
|
+
const { host, token, inboxId } = this.getConfig();
|
|
99
|
+
if (!inboxId)
|
|
100
|
+
throw Error("Mailtrap inbox ID is required but not provided. Please set MAILTRAP_INBOX_ID in your environment variables.");
|
|
101
|
+
const endpoint = `${host}/${inboxId}`;
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(endpoint, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${token}`,
|
|
107
|
+
"Content-Type": "application/json"
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify(payload)
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const errorData = await response.json();
|
|
113
|
+
throw Error(`Mailtrap API error: ${response.status} - ${JSON.stringify(errorData)}`);
|
|
114
|
+
}
|
|
115
|
+
const data = await response.json();
|
|
116
|
+
log.info(`[${this.name}] Email sent successfully`, { attempt, messageId: data.message_ids?.[0] });
|
|
117
|
+
return data;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (attempt < (config.services.mailtrap?.maxRetries ?? 3)) {
|
|
120
|
+
const retryTimeout = config.services.mailtrap?.retryTimeout ?? 1000;
|
|
121
|
+
log.warn(`[${this.name}] Email send failed, retrying (${attempt}/${config.services.mailtrap?.maxRetries ?? 3})`);
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
|
|
123
|
+
return this.sendWithRetry(payload, attempt + 1);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export default MailtrapDriver;
|