@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.
@@ -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,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,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,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;