@sandsoftwaresolutions/mail-kit 0.0.0-stage → 0.1.1

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/src/smtp.js ADDED
@@ -0,0 +1,275 @@
1
+ import { hostname } from "node:os";
2
+ import { connect as netConnect } from "node:net";
3
+ import { connect as tlsConnect } from "node:tls";
4
+
5
+ import { MailError } from "./errors.js";
6
+ import { prepareMessage } from "./message.js";
7
+
8
+ const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|::1)$/;
9
+
10
+ const networkError = (error, stage) => {
11
+ const code = error?.code;
12
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || /certificate|self.signed|unable to verify/i.test(error?.message ?? "")) {
13
+ return new MailError(`The mail server's certificate was refused: ${error.message}`, { code: "tls_failed", cause: error });
14
+ }
15
+ if (stage === "sent") {
16
+ return new MailError("The connection broke after the message was sent; it may or may not have been delivered", { code: "delivery_unknown", cause: error });
17
+ }
18
+ return new MailError(`Could not talk to the mail server (${code ?? error?.message ?? "error"})`, { code: "connection_failed", retryable: true, cause: error });
19
+ };
20
+
21
+ class Connection {
22
+ constructor(socket, timeoutMs) {
23
+ this.timeoutMs = timeoutMs;
24
+ this.stage = "connect";
25
+ this.bind(socket);
26
+ }
27
+
28
+ bind(socket) {
29
+ this.socket = socket;
30
+ this.buffer = "";
31
+ this.lines = [];
32
+ this.queue = [];
33
+ this.waiter = null;
34
+ this.failure = null;
35
+ socket.setTimeout(this.timeoutMs);
36
+ socket.on("data", (chunk) => this.onData(chunk));
37
+ socket.on("error", (error) => this.fail(networkError(error, this.stage)));
38
+ socket.on("close", () => this.fail(networkError(Object.assign(new Error("connection closed"), { code: "ECONNRESET" }), this.stage)));
39
+ socket.on("timeout", () => this.fail(this.stage === "sent"
40
+ ? new MailError("The mail server did not answer after the message was sent; it may or may not have been delivered", { code: "delivery_unknown" })
41
+ : new MailError("The mail server did not answer in time", { code: "timeout", retryable: true })));
42
+ }
43
+
44
+ onData(chunk) {
45
+ this.buffer += chunk.toString("latin1");
46
+ let index;
47
+ while ((index = this.buffer.indexOf("\n")) >= 0) {
48
+ const line = this.buffer.slice(0, index).replace(/\r$/, "");
49
+ this.buffer = this.buffer.slice(index + 1);
50
+ this.lines.push(line);
51
+ const match = /^(\d{3})([ -]?)/.exec(line);
52
+ if (!match) continue;
53
+ if (match[2] !== "-") {
54
+ this.queue.push({ code: Number(match[1]), lines: this.lines.map((l) => l.slice(4)), text: this.lines.map((l) => l.slice(4)).join("\n") });
55
+ this.lines = [];
56
+ this.flush();
57
+ }
58
+ }
59
+ }
60
+
61
+ flush() {
62
+ if (this.waiter && this.queue.length) {
63
+ const { resolve } = this.waiter;
64
+ this.waiter = null;
65
+ resolve(this.queue.shift());
66
+ }
67
+ }
68
+
69
+ fail(error) {
70
+ if (this.failure) return;
71
+ this.failure = error;
72
+ if (this.waiter) {
73
+ const { reject } = this.waiter;
74
+ this.waiter = null;
75
+ reject(error);
76
+ }
77
+ }
78
+
79
+ reply() {
80
+ if (this.queue.length) return Promise.resolve(this.queue.shift());
81
+ if (this.failure) return Promise.reject(this.failure);
82
+ return new Promise((resolve, reject) => (this.waiter = { resolve, reject }));
83
+ }
84
+
85
+ write(data) {
86
+ if (this.failure) return;
87
+ this.socket.write(data, "latin1");
88
+ }
89
+
90
+ async command(line, expect) {
91
+ this.write(`${line}\r\n`);
92
+ const reply = await this.reply();
93
+ if (expect && !expect.includes(reply.code)) throw replyError(reply, line.split(" ")[0]);
94
+ return reply;
95
+ }
96
+
97
+ destroy() {
98
+ this.socket.destroy();
99
+ }
100
+ }
101
+
102
+ function replyError(reply, command) {
103
+ const transient = reply.code >= 400 && reply.code < 500;
104
+ const auth = reply.code === 535 || reply.code === 534 || reply.code === 530 || reply.code === 454 || command === "AUTH";
105
+ const code = auth ? "auth_failed" : command === "RCPT" ? "recipient_rejected" : command === "MAIL" ? "sender_rejected" : transient ? "temporary_failure" : "rejected";
106
+ return new MailError(`Mail server answered ${reply.code} to ${command}: ${reply.text.slice(0, 200)}`, {
107
+ code,
108
+ retryable: transient && !auth,
109
+ responseCode: reply.code,
110
+ response: reply.text.slice(0, 500),
111
+ });
112
+ }
113
+
114
+ function parseCapabilities(reply) {
115
+ const caps = new Map();
116
+ for (const line of reply.lines.slice(1)) {
117
+ const [name, ...rest] = line.trim().split(/\s+/);
118
+ caps.set(name.toUpperCase(), rest.join(" "));
119
+ }
120
+ return caps;
121
+ }
122
+
123
+ function openSocket(o) {
124
+ return new Promise((resolve, reject) => {
125
+ const socket =
126
+ o.security === "tls"
127
+ ? tlsConnect({ host: o.host, port: o.port, servername: o.tls.servername ?? (LOOPBACK.test(o.host) ? undefined : o.host), ...o.tls })
128
+ : netConnect({ host: o.host, port: o.port });
129
+ const timer = setTimeout(() => {
130
+ socket.destroy();
131
+ reject(new MailError(`Could not connect to ${o.host}:${o.port} in time`, { code: "timeout", retryable: true }));
132
+ }, o.timeoutMs);
133
+ socket.once(o.security === "tls" ? "secureConnect" : "connect", () => {
134
+ clearTimeout(timer);
135
+ socket.removeListener("error", onError);
136
+ resolve(socket);
137
+ });
138
+ const onError = (error) => {
139
+ clearTimeout(timer);
140
+ reject(networkError(error, "connect"));
141
+ };
142
+ socket.once("error", onError);
143
+ });
144
+ }
145
+
146
+ function upgrade(connection, o) {
147
+ return new Promise((resolve, reject) => {
148
+ const plain = connection.socket;
149
+ plain.removeAllListeners();
150
+ plain.setTimeout(0);
151
+ plain.on("error", () => {});
152
+ const secure = tlsConnect({ socket: plain, servername: o.tls.servername ?? (LOOPBACK.test(o.host) ? undefined : o.host), ...o.tls });
153
+ secure.once("secureConnect", () => resolve(secure));
154
+ secure.once("error", (error) => reject(networkError(error, "connect")));
155
+ });
156
+ }
157
+
158
+ async function openSession(o) {
159
+ const socket = await openSocket(o);
160
+ const c = new Connection(socket, o.timeoutMs);
161
+ try {
162
+ const hello = await c.reply();
163
+ if (hello.code !== 220) throw replyError(hello, "CONNECT");
164
+ let ehlo = await c.command(`EHLO ${o.clientName}`);
165
+ if (ehlo.code >= 500) ehlo = await c.command(`HELO ${o.clientName}`, [250]);
166
+ let caps = parseCapabilities(ehlo);
167
+ let secureNow = o.security === "tls";
168
+ if (o.security === "starttls") {
169
+ if (!caps.has("STARTTLS")) throw new MailError("The mail server does not offer STARTTLS, so the connection would not be encrypted. Use security: \"none\" only if you really want that.", { code: "tls_unavailable" });
170
+ await c.command("STARTTLS", [220]);
171
+ c.bind(await upgrade(c, o));
172
+ secureNow = true;
173
+ ehlo = await c.command(`EHLO ${o.clientName}`, [250]);
174
+ caps = parseCapabilities(ehlo);
175
+ }
176
+ if (o.user !== undefined) {
177
+ if (!secureNow && !o.allowInsecureAuth) throw new MailError("Refusing to send the password over an unencrypted connection", { code: "tls_unavailable" });
178
+ await authenticate(c, caps, o);
179
+ }
180
+ return { c, caps };
181
+ } catch (error) {
182
+ c.destroy();
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ async function authenticate(c, caps, o) {
188
+ const offered = (caps.get("AUTH") ?? "").toUpperCase().split(/\s+/);
189
+ const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
190
+ if (offered.includes("PLAIN")) {
191
+ await c.command(`AUTH PLAIN ${b64(`\0${o.user}\0${o.pass}`)}`, [235]);
192
+ } else if (offered.includes("LOGIN")) {
193
+ await c.command("AUTH LOGIN", [334]);
194
+ await c.command(b64(o.user), [334]);
195
+ await c.command(b64(o.pass), [235]);
196
+ } else {
197
+ throw new MailError(`The mail server offers no login method this package supports (it offers: ${offered.join(" ") || "none"})`, { code: "auth_failed" });
198
+ }
199
+ }
200
+
201
+ const dotStuff = (raw) => {
202
+ const text = raw.toString("latin1").replace(/(^|\r\n)\./g, "$1..");
203
+ return text.endsWith("\r\n") ? text : `${text}\r\n`;
204
+ };
205
+
206
+ /**
207
+ * Sends through any SMTP server (Zoho, Gmail, your own). Encrypted by default: port 465 uses TLS right away,
208
+ * other ports must offer STARTTLS or the send is refused. No dependencies.
209
+ */
210
+ export function smtpTransport(options = {}) {
211
+ const port = options.port ?? (options.security === "tls" ? 465 : 587);
212
+ const security = options.security ?? (port === 465 ? "tls" : "starttls");
213
+ if (!options.host || typeof options.host !== "string") throw new MailError("smtpTransport needs a host", { code: "invalid_config" });
214
+ if (!["tls", "starttls", "none"].includes(security)) throw new MailError('security must be "tls", "starttls" or "none"', { code: "invalid_config" });
215
+ if ((options.user === undefined) !== (options.pass === undefined)) throw new MailError("Give both user and pass, or neither", { code: "invalid_config" });
216
+ if (security === "none" && options.user !== undefined && !options.allowInsecureAuth) {
217
+ throw new MailError('A login over security: "none" would send the password unencrypted; set allowInsecureAuth: true to allow it', { code: "invalid_config" });
218
+ }
219
+ const o = {
220
+ host: options.host,
221
+ port,
222
+ security,
223
+ user: options.user,
224
+ pass: options.pass,
225
+ allowInsecureAuth: options.allowInsecureAuth === true,
226
+ timeoutMs: options.timeoutMs ?? 20_000,
227
+ clientName: String(options.clientName ?? (hostname().replace(/[^A-Za-z0-9.-]/g, "") || "localhost")),
228
+ tls: { ...options.tls },
229
+ };
230
+
231
+ const withSession = async (work) => {
232
+ const session = await openSession(o);
233
+ try {
234
+ return await work(session);
235
+ } finally {
236
+ session.c.write("QUIT\r\n");
237
+ setTimeout(() => session.c.destroy(), 200).unref();
238
+ }
239
+ };
240
+
241
+ return {
242
+ name: "smtp",
243
+ /** Opens a connection, encrypts it and logs in, then closes. Use it at startup to catch wrong settings early. */
244
+ verify: () => withSession(async () => true),
245
+ async send(input) {
246
+ const message = input.raw ? input : prepareMessage(input);
247
+ const raw = message.raw;
248
+ return withSession(async ({ c, caps }) => {
249
+ const limit = Number(caps.get("SIZE"));
250
+ if (limit > 0 && raw.length > limit) throw new MailError(`The message is ${raw.length} bytes; the server accepts at most ${limit}`, { code: "too_large" });
251
+ await c.command(`MAIL FROM:<${message.envelope.from}>`, [250]);
252
+ const accepted = [];
253
+ const rejected = [];
254
+ let lastError;
255
+ for (const to of message.envelope.to) {
256
+ const reply = await c.command(`RCPT TO:<${to}>`);
257
+ if (reply.code === 250 || reply.code === 251) accepted.push(to);
258
+ else {
259
+ rejected.push(to);
260
+ lastError = replyError(reply, "RCPT");
261
+ }
262
+ }
263
+ if (!accepted.length) throw lastError;
264
+ await c.command("DATA", [354]);
265
+ c.stage = "sent";
266
+ c.write(`${dotStuff(raw)}.\r\n`);
267
+ const done = await c.reply();
268
+ c.stage = "done";
269
+ if (done.code !== 250) throw replyError(done, "DATA");
270
+ const queued = /queued as ([\w.@-]+)|\bid[ =:]+([\w.@-]+)/i.exec(done.text);
271
+ return { id: queued?.[1] ?? queued?.[2] ?? message.messageId, messageId: message.messageId, accepted, rejected, response: done.text.slice(0, 500) };
272
+ });
273
+ },
274
+ };
275
+ }
@@ -0,0 +1,201 @@
1
+ import { MailError } from "./errors.js";
2
+ import { escapeHtml, wrapText } from "./text.js";
3
+
4
+ const COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
5
+ const FONT = /^[\w\s,'"-]{1,200}$/;
6
+ const HTTPS_OR_HTTP = /^https?:\/\/[^\s<>"']+$/i;
7
+ const LINKABLE = /^(?:https?:\/\/|mailto:|tel:)[^\s<>"']+$/i;
8
+
9
+ const fail = (message) => new MailError(message, { code: "invalid_template" });
10
+ const color = (value, fallback, name) => {
11
+ if (value === undefined) return fallback;
12
+ if (!COLOR.test(value)) throw fail(`${name} must be a hex color like #2563eb`);
13
+ return value;
14
+ };
15
+
16
+ // Inline markup used in every text block: **bold**, `code`, [label](https://url). Everything else is plain text.
17
+ // A backslash makes the next markup character literal; escapeMarkup() does that for text you did not write yourself.
18
+ const INLINE = /\\([\\*`[\]()])|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*([^*\n]+)\*\*|`([^`\n]+)`/g;
19
+
20
+ /** Makes a name, address or any other outside text safe to put inside a block: it can no longer become a link or bold text. */
21
+ export const escapeMarkup = (value) => String(value).replace(/([\\*`[\]()])/g, "\\$1");
22
+
23
+ function inlineHtml(text, theme) {
24
+ let out = "";
25
+ let last = 0;
26
+ for (const m of String(text).matchAll(INLINE)) {
27
+ out += escapeHtml(text.slice(last, m.index));
28
+ last = m.index + m[0].length;
29
+ if (m[1] !== undefined) out += escapeHtml(m[1]);
30
+ else if (m[2] !== undefined) {
31
+ out += LINKABLE.test(m[3]) ? `<a href="${escapeHtml(m[3])}" style="color:${theme.accent};text-decoration:underline;">${escapeHtml(m[2])}</a>` : escapeHtml(m[2]);
32
+ } else if (m[4] !== undefined) out += `<strong>${escapeHtml(m[4])}</strong>`;
33
+ else out += `<code class="ss-soft" style="font-family:Menlo,Consolas,monospace;font-size:0.95em;background:${theme.soft};padding:1px 5px;border-radius:4px;">${escapeHtml(m[5])}</code>`;
34
+ }
35
+ return (out + escapeHtml(text.slice(last))).replace(/\n/g, "<br>");
36
+ }
37
+
38
+ function inlineText(text) {
39
+ return String(text).replace(INLINE, (_, escaped, label, url, bold, code) => {
40
+ if (escaped !== undefined) return escaped;
41
+ if (label !== undefined) return LINKABLE.test(url) && label !== url ? `${label} (${url})` : label;
42
+ return bold ?? code;
43
+ });
44
+ }
45
+
46
+ /** Building blocks for `email({ blocks })`. A plain string in the list means `paragraph(string)`. */
47
+ export const block = {
48
+ heading: (text, level = 1) => ({ type: "heading", text: String(text), level: level === 2 ? 2 : 1 }),
49
+ paragraph: (text) => ({ type: "paragraph", text: String(text) }),
50
+ /** A call-to-action button. The text version prints the label and the full URL on separate lines. */
51
+ button: (label, url) => {
52
+ if (!HTTPS_OR_HTTP.test(url ?? "")) throw fail(`Button URL must be an http(s) URL: ${String(url).slice(0, 80)}`);
53
+ return { type: "button", label: String(label), url };
54
+ },
55
+ /** A big monospaced value, like a verification code. */
56
+ code: (value, caption) => ({ type: "code", value: String(value), caption }),
57
+ list: (items, { ordered = false } = {}) => ({ type: "list", items: items.map(String), ordered }),
58
+ /** Label/value rows, for an invoice or an order summary: `table([["Plan", "Klein"], ["Total", "€ 9,00"]])`. */
59
+ table: (rows, { total = false } = {}) => ({ type: "table", rows: rows.map(([a, b]) => [String(a), String(b)]), total }),
60
+ /** A highlighted note (warnings, "you did not request this"). */
61
+ note: (text) => ({ type: "note", text: String(text) }),
62
+ divider: () => ({ type: "divider" }),
63
+ /** An image. Use `cid:name` as src for an inline attachment. Alt text is required. */
64
+ image: (src, alt, { width } = {}) => {
65
+ if (!/^(https:\/\/[^\s<>"']+|cid:[\w.@+-]+)$/.test(src ?? "")) throw fail("Image src must be an https URL or cid:name");
66
+ if (!alt) throw fail("Images need alt text, many mail clients block images");
67
+ return { type: "image", src, alt: String(alt), width };
68
+ },
69
+ };
70
+
71
+ function renderBlockHtml(b, theme) {
72
+ const text = `color:${theme.text};font-family:${theme.font};`;
73
+ switch (b.type) {
74
+ case "heading":
75
+ return `<h${b.level} style="margin:0 0 16px;${text}font-size:${b.level === 1 ? 24 : 18}px;line-height:1.3;font-weight:700;">${inlineHtml(b.text, theme)}</h${b.level}>`;
76
+ case "paragraph":
77
+ return `<p style="margin:0 0 16px;${text}font-size:16px;line-height:1.55;">${inlineHtml(b.text, theme)}</p>`;
78
+ case "button":
79
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;"><tr><td align="center" bgcolor="${theme.accent}" style="border-radius:8px;"><a class="ss-btn" href="${escapeHtml(b.url)}" style="display:inline-block;padding:13px 26px;font-family:${theme.font};font-size:16px;font-weight:700;line-height:1.2;color:${theme.accentText};text-decoration:none;border-radius:8px;">${escapeHtml(b.label)}</a></td></tr></table>`;
80
+ case "code":
81
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;"><tr><td class="ss-soft" style="background:${theme.soft};border-radius:8px;padding:14px 22px;font-family:Menlo,Consolas,monospace;font-size:28px;letter-spacing:6px;font-weight:700;color:${theme.text};">${escapeHtml(b.value)}</td></tr></table>${b.caption ? `<p style="margin:-12px 0 16px;${text}font-size:14px;color:${theme.muted};">${inlineHtml(b.caption, theme)}</p>` : ""}`;
82
+ case "list": {
83
+ const tag = b.ordered ? "ol" : "ul";
84
+ return `<${tag} style="margin:0 0 16px;padding-left:22px;${text}font-size:16px;line-height:1.55;">${b.items.map((i) => `<li style="margin:0 0 6px;">${inlineHtml(i, theme)}</li>`).join("")}</${tag}>`;
85
+ }
86
+ case "table":
87
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;border-collapse:collapse;">${b.rows
88
+ .map(([k, v], i) => {
89
+ const last = b.total && i === b.rows.length - 1;
90
+ const cell = `padding:9px 0;border-bottom:1px solid ${theme.line};${text}font-size:15px;${last ? "font-weight:700;" : ""}`;
91
+ return `<tr><td style="${cell}">${inlineHtml(k, theme)}</td><td align="right" style="${cell}">${inlineHtml(v, theme)}</td></tr>`;
92
+ })
93
+ .join("")}</table>`;
94
+ case "note":
95
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td class="ss-soft" style="background:${theme.soft};border-left:4px solid ${theme.accent};border-radius:4px;padding:12px 16px;${text}font-size:14px;line-height:1.5;">${inlineHtml(b.text, theme)}</td></tr></table>`;
96
+ case "divider":
97
+ return `<hr style="border:0;border-top:1px solid ${theme.line};margin:24px 0;">`;
98
+ case "image":
99
+ return `<img src="${escapeHtml(b.src)}" alt="${escapeHtml(b.alt)}"${b.width ? ` width="${Number(b.width) | 0}"` : ""} style="display:block;max-width:100%;height:auto;border:0;margin:0 0 16px;">`;
100
+ default:
101
+ throw fail(`Unknown block type: ${b.type}`);
102
+ }
103
+ }
104
+
105
+ function renderBlockText(b) {
106
+ switch (b.type) {
107
+ case "heading":
108
+ return b.level === 1 ? `${inlineText(b.text).toUpperCase()}` : inlineText(b.text);
109
+ case "paragraph":
110
+ case "note":
111
+ return wrapText(inlineText(b.text));
112
+ case "button":
113
+ return `${b.label}:\n${b.url}`;
114
+ case "code":
115
+ return `${b.value}${b.caption ? `\n${inlineText(b.caption)}` : ""}`;
116
+ case "list":
117
+ return b.items.map((i, n) => wrapText(`${b.ordered ? `${n + 1}.` : "-"} ${inlineText(i)}`)).join("\n");
118
+ case "table": {
119
+ const width = Math.max(...b.rows.map(([k]) => inlineText(k).length));
120
+ return b.rows.map(([k, v]) => `${inlineText(k).padEnd(width)} ${inlineText(v)}`).join("\n");
121
+ }
122
+ case "divider":
123
+ return "----";
124
+ case "image":
125
+ return `[${b.alt}]`;
126
+ default:
127
+ throw fail(`Unknown block type: ${b.type}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Makes a finished mail: `{ subject, html, text }`. The HTML is table-based with inline styles (works in Outlook, Gmail
133
+ * and Apple Mail, with a dark-mode variant), the text version is written from the same blocks, and all text is escaped.
134
+ */
135
+ export function email(options = {}) {
136
+ const { subject, preheader = "", blocks = [], footer = [], lang = "en", brand = {} } = options;
137
+ if (typeof subject !== "string" || !subject.trim()) throw fail("email needs a subject");
138
+ if (!Array.isArray(blocks) || !blocks.length) throw fail("email needs at least one block");
139
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw fail("lang must be a language tag like nl or en-GB");
140
+ const font = brand.font ?? "-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
141
+ if (!FONT.test(font)) throw fail("brand.font may only contain letters, digits, spaces, commas, quotes and dashes");
142
+ const accent = color(brand.color, "#2563eb", "brand.color");
143
+ const theme = {
144
+ font,
145
+ accent,
146
+ accentText: color(brand.buttonTextColor, "#ffffff", "brand.buttonTextColor"),
147
+ text: color(brand.textColor, "#1f2933", "brand.textColor"),
148
+ muted: "#6b7785",
149
+ soft: "#f1f4f8",
150
+ line: "#e1e6ec",
151
+ page: color(brand.background, "#f4f6f9", "brand.background"),
152
+ card: "#ffffff",
153
+ };
154
+ if (brand.logoUrl !== undefined && !/^https:\/\/[^\s<>"']+$/.test(brand.logoUrl) && !/^cid:[\w.@+-]+$/.test(brand.logoUrl)) throw fail("brand.logoUrl must be an https URL or cid:name");
155
+ const items = blocks.map((b) => (typeof b === "string" ? block.paragraph(b) : b));
156
+ const footerLines = (Array.isArray(footer) ? footer : [footer]).map(String);
157
+
158
+ const header = brand.logoUrl
159
+ ? `<img src="${escapeHtml(brand.logoUrl)}" alt="${escapeHtml(brand.name ?? "")}" height="32" style="display:block;border:0;height:32px;width:auto;">`
160
+ : brand.name
161
+ ? `<span style="font-family:${font};font-size:20px;font-weight:700;color:${theme.text};">${escapeHtml(brand.name)}</span>`
162
+ : "";
163
+ const headerHtml = header && brand.url && HTTPS_OR_HTTP.test(brand.url) ? `<a href="${escapeHtml(brand.url)}" style="text-decoration:none;">${header}</a>` : header;
164
+
165
+ const hiddenPreheader = preheader
166
+ ? `<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)}${"&#847;&zwnj;&nbsp;".repeat(40)}</div>`
167
+ : "";
168
+
169
+ const html = `<!doctype html>
170
+ <html lang="${lang}">
171
+ <head>
172
+ <meta charset="utf-8">
173
+ <meta name="viewport" content="width=device-width,initial-scale=1">
174
+ <meta name="color-scheme" content="light dark">
175
+ <meta name="supported-color-schemes" content="light dark">
176
+ <meta name="x-apple-disable-message-reformatting">
177
+ <title>${escapeHtml(subject)}</title>
178
+ <style>
179
+ @media (max-width:620px){.ss-card{width:100%!important;border-radius:0!important}.ss-pad{padding:24px 20px!important}}
180
+ @media (prefers-color-scheme:dark){.ss-page{background:#12161b!important}.ss-card{background:#1b2128!important}.ss-card h1,.ss-card h2,.ss-card p,.ss-card li,.ss-card td,.ss-card span{color:#e8edf2!important}.ss-card a:not(.ss-btn){color:#8db4ff!important}.ss-card .ss-soft{background:#2a333d!important}}
181
+ </style>
182
+ </head>
183
+ <body class="ss-page" style="margin:0;padding:0;background:${theme.page};">
184
+ ${hiddenPreheader}
185
+ <table role="presentation" class="ss-page" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${theme.page};"><tr><td align="center" style="padding:24px 12px;">
186
+ <table role="presentation" class="ss-card" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;background:${theme.card};border-radius:12px;">
187
+ <tr><td class="ss-pad" style="padding:32px 36px 8px;">${headerHtml}</td></tr>
188
+ <tr><td class="ss-pad" style="padding:16px 36px 28px;">
189
+ ${items.map((b) => renderBlockHtml(b, theme)).join("\n")}
190
+ </td></tr>
191
+ ${footerLines.length ? `<tr><td class="ss-pad" style="padding:0 36px 28px;border-top:1px solid ${theme.line};"><p style="margin:18px 0 0;font-family:${font};font-size:12px;line-height:1.6;color:${theme.muted};">${footerLines.map((l) => inlineHtml(l, theme)).join("<br>")}</p></td></tr>` : ""}
192
+ </table>
193
+ </td></tr></table>
194
+ </body>
195
+ </html>`;
196
+
197
+ const parts = items.map(renderBlockText);
198
+ if (brand.name) parts.unshift(brand.name);
199
+ if (footerLines.length) parts.push(`--\n${footerLines.map(inlineText).join("\n")}`);
200
+ return { subject: subject.trim(), html, text: parts.join("\n\n") + "\n" };
201
+ }
package/src/text.js ADDED
@@ -0,0 +1,74 @@
1
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…", euro: "€", copy: "©", laquo: "«", raquo: "»" };
2
+
3
+ export const escapeHtml = (value) =>
4
+ String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
5
+
6
+ const decodeEntities = (text) =>
7
+ text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name) => {
8
+ if (name[0] === "#") {
9
+ const code = name[1].toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
10
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
11
+ }
12
+ return ENTITIES[name.toLowerCase()] ?? whole;
13
+ });
14
+
15
+ /** Plain-text version of an HTML mail: links become `label (url)`, lists get dashes, blocks get blank lines. */
16
+ export function htmlToText(html) {
17
+ let text = String(html)
18
+ .replace(/<(head|style|script|title)[\s\S]*?<\/\1>/gi, "")
19
+ .replace(/<!--[\s\S]*?-->/g, "")
20
+ .replace(/<a\s[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi, (_, a, b, label) => {
21
+ const url = decodeEntities(a ?? b ?? "");
22
+ const inner = label.replace(/<[^>]*>/g, "").trim();
23
+ if (!inner || inner === url || /^(mailto|tel):/i.test(url) && decodeEntities(inner) === url.replace(/^\w+:/, "")) return inner || url;
24
+ return /^(https?|mailto|tel):/i.test(url) ? `${inner} (${url})` : inner;
25
+ })
26
+ .replace(/<br\s*\/?>/gi, "\n")
27
+ .replace(/<hr\s*\/?>/gi, "\n----\n")
28
+ .replace(/<li[^>]*>/gi, "\n- ")
29
+ .replace(/<\/(p|div|tr|h[1-6]|ul|ol|table|blockquote)>/gi, "\n\n")
30
+ .replace(/<\/t[dh]>/gi, " ")
31
+ .replace(/<[^>]*>/g, "");
32
+ text = decodeEntities(text)
33
+ .replace(/[ \t\f\v]+/g, " ")
34
+ .replace(/ *\n */g, "\n")
35
+ .replace(/\n{3,}/g, "\n\n")
36
+ .trim();
37
+ return text;
38
+ }
39
+
40
+ /** Wraps text at `width` characters on spaces. Words longer than the width (like URLs) stay whole. */
41
+ export function wrapText(text, width = 76) {
42
+ return text
43
+ .split("\n")
44
+ .map((line) => {
45
+ if (line.length <= width) return line;
46
+ const indent = /^(\s*(?:- )?)/.exec(line)[1];
47
+ const hang = " ".repeat(indent.length);
48
+ const out = [];
49
+ let current = "";
50
+ for (const word of line.slice(indent.length).split(" ")) {
51
+ const candidate = current ? `${current} ${word}` : word;
52
+ if (current && (out.length ? hang : indent).length + candidate.length > width) {
53
+ out.push((out.length ? hang : indent) + current);
54
+ current = word;
55
+ } else current = candidate;
56
+ }
57
+ out.push((out.length ? hang : indent) + current);
58
+ return out.join("\n");
59
+ })
60
+ .join("\n");
61
+ }
62
+
63
+ /** Every http(s) link in an HTML string (from href attributes) or in a plain text, in order, without duplicates. */
64
+ export function extractLinks(content) {
65
+ const found = [];
66
+ const add = (url) => {
67
+ const clean = decodeEntities(url).replace(/[).,;:!?]+$/, "");
68
+ if (/^https?:\/\//i.test(clean) && !found.includes(clean)) found.push(clean);
69
+ };
70
+ const source = String(content);
71
+ if (/<[a-z!][^>]*>/i.test(source)) for (const m of source.matchAll(/href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) add(m[1] ?? m[2]);
72
+ else for (const m of source.matchAll(/https?:\/\/[^\s<>"')]+/gi)) add(m[0]);
73
+ return found;
74
+ }
@@ -0,0 +1,113 @@
1
+ import { formatAddress } from "./address.js";
2
+ import { MailError } from "./errors.js";
3
+ import { prepareMessage } from "./message.js";
4
+ import { extractLinks } from "./text.js";
5
+
6
+ const asMessage = (input) => (input && typeof input.raw !== "undefined" && input.envelope ? input : prepareMessage(input));
7
+
8
+ /**
9
+ * Sends through the Resend HTTP API. The message id is sent as the idempotency key, so a retry after a timeout
10
+ * can never deliver the same mail twice.
11
+ */
12
+ export function resendTransport(options = {}) {
13
+ const apiKey = options.apiKey;
14
+ if (typeof apiKey !== "string" || !/^re_\S+$/.test(apiKey)) throw new MailError("resendTransport needs an apiKey that starts with re_", { code: "invalid_config" });
15
+ const apiUrl = String(options.apiUrl ?? "https://api.resend.com").replace(/\/+$/, "");
16
+ const doFetch = options.fetch ?? globalThis.fetch;
17
+ const timeoutMs = options.timeoutMs ?? 15_000;
18
+ return {
19
+ name: "resend",
20
+ async send(input) {
21
+ const m = asMessage(input);
22
+ const body = {
23
+ from: formatAddress(m.from),
24
+ to: m.to.map(formatAddress),
25
+ subject: m.subject,
26
+ text: m.text,
27
+ headers: m.headers,
28
+ };
29
+ if (m.cc.length) body.cc = m.cc.map(formatAddress);
30
+ if (m.bcc.length) body.bcc = m.bcc.map(formatAddress);
31
+ if (m.replyTo.length) body.reply_to = m.replyTo.map(formatAddress);
32
+ if (m.html !== undefined) body.html = m.html;
33
+ if (m.attachments.length) {
34
+ body.attachments = m.attachments.map((a) => ({ filename: a.filename, content: a.content.toString("base64"), content_type: a.contentType, ...(a.cid ? { content_id: a.cid } : {}) }));
35
+ }
36
+ let response;
37
+ try {
38
+ response = await doFetch(`${apiUrl}/emails`, {
39
+ method: "POST",
40
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", "idempotency-key": m.messageId.replace(/[<>]/g, "") },
41
+ body: JSON.stringify(body),
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ } catch (error) {
45
+ const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
46
+ throw new MailError(timedOut ? "Resend did not answer in time" : `Could not reach Resend (${error?.cause?.code ?? error?.message ?? "error"})`, { code: timedOut ? "timeout" : "connection_failed", retryable: true, cause: error });
47
+ }
48
+ const payload = await response.json().catch(() => ({}));
49
+ if (!response.ok) {
50
+ const detail = String(payload?.message ?? payload?.error ?? response.statusText ?? "").slice(0, 300);
51
+ const retryable = response.status === 429 || response.status >= 500;
52
+ const code = response.status === 401 || response.status === 403 ? "auth_failed" : response.status === 429 ? "rate_limited" : retryable ? "temporary_failure" : "rejected";
53
+ throw new MailError(`Resend answered ${response.status}${detail ? `: ${detail}` : ""}`, { code, retryable, responseCode: response.status, response: detail });
54
+ }
55
+ return { id: payload?.id ?? m.messageId, messageId: m.messageId, accepted: m.envelope.to, rejected: [], response: "ok" };
56
+ },
57
+ };
58
+ }
59
+
60
+ /** Keeps every mail in memory instead of sending it. For tests and local development. */
61
+ export function memoryTransport() {
62
+ const sent = [];
63
+ return {
64
+ name: "memory",
65
+ sent,
66
+ async send(input) {
67
+ const m = asMessage(input);
68
+ const record = {
69
+ ...m,
70
+ raw: m.raw,
71
+ /** Every http(s) link in the mail (html first, then text), e.g. the verification link. */
72
+ links: [...new Set([...extractLinks(m.html ?? ""), ...extractLinks(m.text)])],
73
+ };
74
+ sent.push(record);
75
+ return { id: `memory-${sent.length}`, messageId: m.messageId, accepted: m.envelope.to, rejected: [], response: "stored" };
76
+ },
77
+ last: () => sent[sent.length - 1],
78
+ clear: () => (sent.length = 0),
79
+ /** Newest mail sent to this address, or undefined. */
80
+ to: (address) => [...sent].reverse().find((m) => m.envelope.to.some((a) => a.toLowerCase() === address.toLowerCase())),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Tries the first transport and falls back to the next only when the first never got to accept the mail
86
+ * (could not connect, timed out before sending, server said "try later"). Never after a permanent rejection,
87
+ * and never when delivery is unknown, so no mail goes out twice.
88
+ */
89
+ export function failoverTransport(transports) {
90
+ if (!Array.isArray(transports) || transports.length < 2) throw new MailError("failoverTransport needs at least two transports", { code: "invalid_config" });
91
+ return {
92
+ name: `failover(${transports.map((t) => t.name).join(",")})`,
93
+ async send(input) {
94
+ const m = asMessage(input);
95
+ let last;
96
+ for (const transport of transports) {
97
+ try {
98
+ const result = await transport.send(m);
99
+ return { ...result, transport: transport.name };
100
+ } catch (error) {
101
+ last = error;
102
+ if (!(error instanceof MailError) || !(error.retryable || error.code === "auth_failed" || error.code === "tls_failed" || error.code === "tls_unavailable")) throw error;
103
+ }
104
+ }
105
+ throw last;
106
+ },
107
+ async verify() {
108
+ const results = await Promise.allSettled(transports.map((t) => t.verify?.()));
109
+ if (results.every((r) => r.status === "rejected")) throw results[0].reason;
110
+ return true;
111
+ },
112
+ };
113
+ }