@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.
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ const { MailError } = require("./errors.cjs");
3
+ const { escapeHtml, wrapText } = require("./text.cjs");
4
+
5
+ const COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
6
+ const FONT = /^[\w\s,'"-]{1,200}$/;
7
+ const HTTPS_OR_HTTP = /^https?:\/\/[^\s<>"']+$/i;
8
+ const LINKABLE = /^(?:https?:\/\/|mailto:|tel:)[^\s<>"']+$/i;
9
+
10
+ const fail = (message) => new MailError(message, { code: "invalid_template" });
11
+ const color = (value, fallback, name) => {
12
+ if (value === undefined) return fallback;
13
+ if (!COLOR.test(value)) throw fail(`${name} must be a hex color like #2563eb`);
14
+ return value;
15
+ };
16
+
17
+ // Inline markup used in every text block: **bold**, `code`, [label](https://url). Everything else is plain text.
18
+ // A backslash makes the next markup character literal; escapeMarkup() does that for text you did not write yourself.
19
+ const INLINE = /\\([\\*`[\]()])|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*([^*\n]+)\*\*|`([^`\n]+)`/g;
20
+
21
+ /** 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. */
22
+ const escapeMarkup = (value) => String(value).replace(/([\\*`[\]()])/g, "\\$1");
23
+
24
+ function inlineHtml(text, theme) {
25
+ let out = "";
26
+ let last = 0;
27
+ for (const m of String(text).matchAll(INLINE)) {
28
+ out += escapeHtml(text.slice(last, m.index));
29
+ last = m.index + m[0].length;
30
+ if (m[1] !== undefined) out += escapeHtml(m[1]);
31
+ else if (m[2] !== undefined) {
32
+ out += LINKABLE.test(m[3]) ? `<a href="${escapeHtml(m[3])}" style="color:${theme.accent};text-decoration:underline;">${escapeHtml(m[2])}</a>` : escapeHtml(m[2]);
33
+ } else if (m[4] !== undefined) out += `<strong>${escapeHtml(m[4])}</strong>`;
34
+ 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>`;
35
+ }
36
+ return (out + escapeHtml(text.slice(last))).replace(/\n/g, "<br>");
37
+ }
38
+
39
+ function inlineText(text) {
40
+ return String(text).replace(INLINE, (_, escaped, label, url, bold, code) => {
41
+ if (escaped !== undefined) return escaped;
42
+ if (label !== undefined) return LINKABLE.test(url) && label !== url ? `${label} (${url})` : label;
43
+ return bold ?? code;
44
+ });
45
+ }
46
+
47
+ /** Building blocks for `email({ blocks })`. A plain string in the list means `paragraph(string)`. */
48
+ const block = {
49
+ heading: (text, level = 1) => ({ type: "heading", text: String(text), level: level === 2 ? 2 : 1 }),
50
+ paragraph: (text) => ({ type: "paragraph", text: String(text) }),
51
+ /** A call-to-action button. The text version prints the label and the full URL on separate lines. */
52
+ button: (label, url) => {
53
+ if (!HTTPS_OR_HTTP.test(url ?? "")) throw fail(`Button URL must be an http(s) URL: ${String(url).slice(0, 80)}`);
54
+ return { type: "button", label: String(label), url };
55
+ },
56
+ /** A big monospaced value, like a verification code. */
57
+ code: (value, caption) => ({ type: "code", value: String(value), caption }),
58
+ list: (items, { ordered = false } = {}) => ({ type: "list", items: items.map(String), ordered }),
59
+ /** Label/value rows, for an invoice or an order summary: `table([["Plan", "Klein"], ["Total", "€ 9,00"]])`. */
60
+ table: (rows, { total = false } = {}) => ({ type: "table", rows: rows.map(([a, b]) => [String(a), String(b)]), total }),
61
+ /** A highlighted note (warnings, "you did not request this"). */
62
+ note: (text) => ({ type: "note", text: String(text) }),
63
+ divider: () => ({ type: "divider" }),
64
+ /** An image. Use `cid:name` as src for an inline attachment. Alt text is required. */
65
+ image: (src, alt, { width } = {}) => {
66
+ if (!/^(https:\/\/[^\s<>"']+|cid:[\w.@+-]+)$/.test(src ?? "")) throw fail("Image src must be an https URL or cid:name");
67
+ if (!alt) throw fail("Images need alt text, many mail clients block images");
68
+ return { type: "image", src, alt: String(alt), width };
69
+ },
70
+ };
71
+
72
+ function renderBlockHtml(b, theme) {
73
+ const text = `color:${theme.text};font-family:${theme.font};`;
74
+ switch (b.type) {
75
+ case "heading":
76
+ 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}>`;
77
+ case "paragraph":
78
+ return `<p style="margin:0 0 16px;${text}font-size:16px;line-height:1.55;">${inlineHtml(b.text, theme)}</p>`;
79
+ case "button":
80
+ 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>`;
81
+ case "code":
82
+ 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>` : ""}`;
83
+ case "list": {
84
+ const tag = b.ordered ? "ol" : "ul";
85
+ 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}>`;
86
+ }
87
+ case "table":
88
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;border-collapse:collapse;">${b.rows
89
+ .map(([k, v], i) => {
90
+ const last = b.total && i === b.rows.length - 1;
91
+ const cell = `padding:9px 0;border-bottom:1px solid ${theme.line};${text}font-size:15px;${last ? "font-weight:700;" : ""}`;
92
+ return `<tr><td style="${cell}">${inlineHtml(k, theme)}</td><td align="right" style="${cell}">${inlineHtml(v, theme)}</td></tr>`;
93
+ })
94
+ .join("")}</table>`;
95
+ case "note":
96
+ 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>`;
97
+ case "divider":
98
+ return `<hr style="border:0;border-top:1px solid ${theme.line};margin:24px 0;">`;
99
+ case "image":
100
+ 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;">`;
101
+ default:
102
+ throw fail(`Unknown block type: ${b.type}`);
103
+ }
104
+ }
105
+
106
+ function renderBlockText(b) {
107
+ switch (b.type) {
108
+ case "heading":
109
+ return b.level === 1 ? `${inlineText(b.text).toUpperCase()}` : inlineText(b.text);
110
+ case "paragraph":
111
+ case "note":
112
+ return wrapText(inlineText(b.text));
113
+ case "button":
114
+ return `${b.label}:\n${b.url}`;
115
+ case "code":
116
+ return `${b.value}${b.caption ? `\n${inlineText(b.caption)}` : ""}`;
117
+ case "list":
118
+ return b.items.map((i, n) => wrapText(`${b.ordered ? `${n + 1}.` : "-"} ${inlineText(i)}`)).join("\n");
119
+ case "table": {
120
+ const width = Math.max(...b.rows.map(([k]) => inlineText(k).length));
121
+ return b.rows.map(([k, v]) => `${inlineText(k).padEnd(width)} ${inlineText(v)}`).join("\n");
122
+ }
123
+ case "divider":
124
+ return "----";
125
+ case "image":
126
+ return `[${b.alt}]`;
127
+ default:
128
+ throw fail(`Unknown block type: ${b.type}`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Makes a finished mail: `{ subject, html, text }`. The HTML is table-based with inline styles (works in Outlook, Gmail
134
+ * and Apple Mail, with a dark-mode variant), the text version is written from the same blocks, and all text is escaped.
135
+ */
136
+ function email(options = {}) {
137
+ const { subject, preheader = "", blocks = [], footer = [], lang = "en", brand = {} } = options;
138
+ if (typeof subject !== "string" || !subject.trim()) throw fail("email needs a subject");
139
+ if (!Array.isArray(blocks) || !blocks.length) throw fail("email needs at least one block");
140
+ 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");
141
+ const font = brand.font ?? "-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
142
+ if (!FONT.test(font)) throw fail("brand.font may only contain letters, digits, spaces, commas, quotes and dashes");
143
+ const accent = color(brand.color, "#2563eb", "brand.color");
144
+ const theme = {
145
+ font,
146
+ accent,
147
+ accentText: color(brand.buttonTextColor, "#ffffff", "brand.buttonTextColor"),
148
+ text: color(brand.textColor, "#1f2933", "brand.textColor"),
149
+ muted: "#6b7785",
150
+ soft: "#f1f4f8",
151
+ line: "#e1e6ec",
152
+ page: color(brand.background, "#f4f6f9", "brand.background"),
153
+ card: "#ffffff",
154
+ };
155
+ 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");
156
+ const items = blocks.map((b) => (typeof b === "string" ? block.paragraph(b) : b));
157
+ const footerLines = (Array.isArray(footer) ? footer : [footer]).map(String);
158
+
159
+ const header = brand.logoUrl
160
+ ? `<img src="${escapeHtml(brand.logoUrl)}" alt="${escapeHtml(brand.name ?? "")}" height="32" style="display:block;border:0;height:32px;width:auto;">`
161
+ : brand.name
162
+ ? `<span style="font-family:${font};font-size:20px;font-weight:700;color:${theme.text};">${escapeHtml(brand.name)}</span>`
163
+ : "";
164
+ const headerHtml = header && brand.url && HTTPS_OR_HTTP.test(brand.url) ? `<a href="${escapeHtml(brand.url)}" style="text-decoration:none;">${header}</a>` : header;
165
+
166
+ const hiddenPreheader = preheader
167
+ ? `<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>`
168
+ : "";
169
+
170
+ const html = `<!doctype html>
171
+ <html lang="${lang}">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width,initial-scale=1">
175
+ <meta name="color-scheme" content="light dark">
176
+ <meta name="supported-color-schemes" content="light dark">
177
+ <meta name="x-apple-disable-message-reformatting">
178
+ <title>${escapeHtml(subject)}</title>
179
+ <style>
180
+ @media (max-width:620px){.ss-card{width:100%!important;border-radius:0!important}.ss-pad{padding:24px 20px!important}}
181
+ @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}}
182
+ </style>
183
+ </head>
184
+ <body class="ss-page" style="margin:0;padding:0;background:${theme.page};">
185
+ ${hiddenPreheader}
186
+ <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;">
187
+ <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;">
188
+ <tr><td class="ss-pad" style="padding:32px 36px 8px;">${headerHtml}</td></tr>
189
+ <tr><td class="ss-pad" style="padding:16px 36px 28px;">
190
+ ${items.map((b) => renderBlockHtml(b, theme)).join("\n")}
191
+ </td></tr>
192
+ ${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>` : ""}
193
+ </table>
194
+ </td></tr></table>
195
+ </body>
196
+ </html>`;
197
+
198
+ const parts = items.map(renderBlockText);
199
+ if (brand.name) parts.unshift(brand.name);
200
+ if (footerLines.length) parts.push(`--\n${footerLines.map(inlineText).join("\n")}`);
201
+ return { subject: subject.trim(), html, text: parts.join("\n\n") + "\n" };
202
+ }
203
+
204
+ exports.escapeMarkup = escapeMarkup;
205
+ exports.block = block;
206
+ exports.email = email;
package/dist/text.cjs ADDED
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…", euro: "€", copy: "©", laquo: "«", raquo: "»" };
3
+
4
+ const escapeHtml = (value) =>
5
+ String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
6
+
7
+ const decodeEntities = (text) =>
8
+ text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name) => {
9
+ if (name[0] === "#") {
10
+ const code = name[1].toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
11
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
12
+ }
13
+ return ENTITIES[name.toLowerCase()] ?? whole;
14
+ });
15
+
16
+ /** Plain-text version of an HTML mail: links become `label (url)`, lists get dashes, blocks get blank lines. */
17
+ function htmlToText(html) {
18
+ let text = String(html)
19
+ .replace(/<(head|style|script|title)[\s\S]*?<\/\1>/gi, "")
20
+ .replace(/<!--[\s\S]*?-->/g, "")
21
+ .replace(/<a\s[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi, (_, a, b, label) => {
22
+ const url = decodeEntities(a ?? b ?? "");
23
+ const inner = label.replace(/<[^>]*>/g, "").trim();
24
+ if (!inner || inner === url || /^(mailto|tel):/i.test(url) && decodeEntities(inner) === url.replace(/^\w+:/, "")) return inner || url;
25
+ return /^(https?|mailto|tel):/i.test(url) ? `${inner} (${url})` : inner;
26
+ })
27
+ .replace(/<br\s*\/?>/gi, "\n")
28
+ .replace(/<hr\s*\/?>/gi, "\n----\n")
29
+ .replace(/<li[^>]*>/gi, "\n- ")
30
+ .replace(/<\/(p|div|tr|h[1-6]|ul|ol|table|blockquote)>/gi, "\n\n")
31
+ .replace(/<\/t[dh]>/gi, " ")
32
+ .replace(/<[^>]*>/g, "");
33
+ text = decodeEntities(text)
34
+ .replace(/[ \t\f\v]+/g, " ")
35
+ .replace(/ *\n */g, "\n")
36
+ .replace(/\n{3,}/g, "\n\n")
37
+ .trim();
38
+ return text;
39
+ }
40
+
41
+ /** Wraps text at `width` characters on spaces. Words longer than the width (like URLs) stay whole. */
42
+ function wrapText(text, width = 76) {
43
+ return text
44
+ .split("\n")
45
+ .map((line) => {
46
+ if (line.length <= width) return line;
47
+ const indent = /^(\s*(?:- )?)/.exec(line)[1];
48
+ const hang = " ".repeat(indent.length);
49
+ const out = [];
50
+ let current = "";
51
+ for (const word of line.slice(indent.length).split(" ")) {
52
+ const candidate = current ? `${current} ${word}` : word;
53
+ if (current && (out.length ? hang : indent).length + candidate.length > width) {
54
+ out.push((out.length ? hang : indent) + current);
55
+ current = word;
56
+ } else current = candidate;
57
+ }
58
+ out.push((out.length ? hang : indent) + current);
59
+ return out.join("\n");
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ /** Every http(s) link in an HTML string (from href attributes) or in a plain text, in order, without duplicates. */
65
+ function extractLinks(content) {
66
+ const found = [];
67
+ const add = (url) => {
68
+ const clean = decodeEntities(url).replace(/[).,;:!?]+$/, "");
69
+ if (/^https?:\/\//i.test(clean) && !found.includes(clean)) found.push(clean);
70
+ };
71
+ const source = String(content);
72
+ if (/<[a-z!][^>]*>/i.test(source)) for (const m of source.matchAll(/href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) add(m[1] ?? m[2]);
73
+ else for (const m of source.matchAll(/https?:\/\/[^\s<>"')]+/gi)) add(m[0]);
74
+ return found;
75
+ }
76
+
77
+ exports.escapeHtml = escapeHtml;
78
+ exports.htmlToText = htmlToText;
79
+ exports.wrapText = wrapText;
80
+ exports.extractLinks = extractLinks;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ const { formatAddress } = require("./address.cjs");
3
+ const { MailError } = require("./errors.cjs");
4
+ const { prepareMessage } = require("./message.cjs");
5
+ const { extractLinks } = require("./text.cjs");
6
+
7
+ const asMessage = (input) => (input && typeof input.raw !== "undefined" && input.envelope ? input : prepareMessage(input));
8
+
9
+ /**
10
+ * Sends through the Resend HTTP API. The message id is sent as the idempotency key, so a retry after a timeout
11
+ * can never deliver the same mail twice.
12
+ */
13
+ function resendTransport(options = {}) {
14
+ const apiKey = options.apiKey;
15
+ if (typeof apiKey !== "string" || !/^re_\S+$/.test(apiKey)) throw new MailError("resendTransport needs an apiKey that starts with re_", { code: "invalid_config" });
16
+ const apiUrl = String(options.apiUrl ?? "https://api.resend.com").replace(/\/+$/, "");
17
+ const doFetch = options.fetch ?? globalThis.fetch;
18
+ const timeoutMs = options.timeoutMs ?? 15_000;
19
+ return {
20
+ name: "resend",
21
+ async send(input) {
22
+ const m = asMessage(input);
23
+ const body = {
24
+ from: formatAddress(m.from),
25
+ to: m.to.map(formatAddress),
26
+ subject: m.subject,
27
+ text: m.text,
28
+ headers: m.headers,
29
+ };
30
+ if (m.cc.length) body.cc = m.cc.map(formatAddress);
31
+ if (m.bcc.length) body.bcc = m.bcc.map(formatAddress);
32
+ if (m.replyTo.length) body.reply_to = m.replyTo.map(formatAddress);
33
+ if (m.html !== undefined) body.html = m.html;
34
+ if (m.attachments.length) {
35
+ body.attachments = m.attachments.map((a) => ({ filename: a.filename, content: a.content.toString("base64"), content_type: a.contentType, ...(a.cid ? { content_id: a.cid } : {}) }));
36
+ }
37
+ let response;
38
+ try {
39
+ response = await doFetch(`${apiUrl}/emails`, {
40
+ method: "POST",
41
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", "idempotency-key": m.messageId.replace(/[<>]/g, "") },
42
+ body: JSON.stringify(body),
43
+ signal: AbortSignal.timeout(timeoutMs),
44
+ });
45
+ } catch (error) {
46
+ const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
47
+ 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 });
48
+ }
49
+ const payload = await response.json().catch(() => ({}));
50
+ if (!response.ok) {
51
+ const detail = String(payload?.message ?? payload?.error ?? response.statusText ?? "").slice(0, 300);
52
+ const retryable = response.status === 429 || response.status >= 500;
53
+ const code = response.status === 401 || response.status === 403 ? "auth_failed" : response.status === 429 ? "rate_limited" : retryable ? "temporary_failure" : "rejected";
54
+ throw new MailError(`Resend answered ${response.status}${detail ? `: ${detail}` : ""}`, { code, retryable, responseCode: response.status, response: detail });
55
+ }
56
+ return { id: payload?.id ?? m.messageId, messageId: m.messageId, accepted: m.envelope.to, rejected: [], response: "ok" };
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Keeps every mail in memory instead of sending it. For tests and local development. */
62
+ function memoryTransport() {
63
+ const sent = [];
64
+ return {
65
+ name: "memory",
66
+ sent,
67
+ async send(input) {
68
+ const m = asMessage(input);
69
+ const record = {
70
+ ...m,
71
+ raw: m.raw,
72
+ /** Every http(s) link in the mail (html first, then text), e.g. the verification link. */
73
+ links: [...new Set([...extractLinks(m.html ?? ""), ...extractLinks(m.text)])],
74
+ };
75
+ sent.push(record);
76
+ return { id: `memory-${sent.length}`, messageId: m.messageId, accepted: m.envelope.to, rejected: [], response: "stored" };
77
+ },
78
+ last: () => sent[sent.length - 1],
79
+ clear: () => (sent.length = 0),
80
+ /** Newest mail sent to this address, or undefined. */
81
+ to: (address) => [...sent].reverse().find((m) => m.envelope.to.some((a) => a.toLowerCase() === address.toLowerCase())),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Tries the first transport and falls back to the next only when the first never got to accept the mail
87
+ * (could not connect, timed out before sending, server said "try later"). Never after a permanent rejection,
88
+ * and never when delivery is unknown, so no mail goes out twice.
89
+ */
90
+ function failoverTransport(transports) {
91
+ if (!Array.isArray(transports) || transports.length < 2) throw new MailError("failoverTransport needs at least two transports", { code: "invalid_config" });
92
+ return {
93
+ name: `failover(${transports.map((t) => t.name).join(",")})`,
94
+ async send(input) {
95
+ const m = asMessage(input);
96
+ let last;
97
+ for (const transport of transports) {
98
+ try {
99
+ const result = await transport.send(m);
100
+ return { ...result, transport: transport.name };
101
+ } catch (error) {
102
+ last = error;
103
+ if (!(error instanceof MailError) || !(error.retryable || error.code === "auth_failed" || error.code === "tls_failed" || error.code === "tls_unavailable")) throw error;
104
+ }
105
+ }
106
+ throw last;
107
+ },
108
+ async verify() {
109
+ const results = await Promise.allSettled(transports.map((t) => t.verify?.()));
110
+ if (results.every((r) => r.status === "rejected")) throw results[0].reason;
111
+ return true;
112
+ },
113
+ };
114
+ }
115
+
116
+ exports.resendTransport = resendTransport;
117
+ exports.memoryTransport = memoryTransport;
118
+ exports.failoverTransport = failoverTransport;
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@sandsoftwaresolutions/mail-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Send good-looking email from Node with no dependencies: SMTP (Zoho, Gmail, ...) and Resend, templates that work in Outlook and dark mode, automatic text version, safe by default, retries without double-sending.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.cts",
16
+ "default": "./dist/index.cjs"
17
+ }
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test && node scripts/build-cjs.mjs --check",
32
+ "pack:check": "npm pack --dry-run",
33
+ "build": "node scripts/build-cjs.mjs",
34
+ "prepack": "node scripts/build-cjs.mjs"
35
+ },
36
+ "keywords": [
37
+ "email",
38
+ "mail",
39
+ "smtp",
40
+ "resend",
41
+ "template",
42
+ "transactional",
43
+ "mime",
44
+ "zoho"
45
+ ],
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/SandSoftwareSolutions/ss-mail-kit.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/SandSoftwareSolutions/ss-mail-kit/issues"
53
+ },
54
+ "homepage": "https://github.com/SandSoftwareSolutions/ss-mail-kit#readme",
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
package/src/address.js ADDED
@@ -0,0 +1,123 @@
1
+ import { domainToASCII } from "node:url";
2
+
3
+ import { MailError } from "./errors.js";
4
+
5
+ const LOCAL = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
6
+ const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
7
+ const CONTROL = /[\u0000-\u001f\u007f]/;
8
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
9
+
10
+ const bad = (message, value) => new MailError(message, { code: "invalid_address", value: typeof value === "string" ? value.slice(0, 200) : value });
11
+
12
+ /** Checks one bare address and returns it with a lowercased, ASCII (punycode) domain. Throws MailError. */
13
+ export function normalizeAddress(address) {
14
+ if (typeof address !== "string") throw bad("Email address must be a string", address);
15
+ const value = address.trim();
16
+ const at = value.lastIndexOf("@");
17
+ if (at < 1 || value.length > 254) throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
18
+ const local = value.slice(0, at);
19
+ const domain = domainToASCII(value.slice(at + 1)).toLowerCase();
20
+ const labels = domain.split(".");
21
+ if (local.length > 64 || !LOCAL.test(local)) throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
22
+ if (labels.length < 2 || domain.length > 253 || !labels.every((l) => LABEL.test(l)) || /^\d+$/.test(labels[labels.length - 1])) {
23
+ throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
24
+ }
25
+ return `${local}@${domain}`;
26
+ }
27
+
28
+ export function isEmail(value) {
29
+ try {
30
+ normalizeAddress(value);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Accepts `"a@b.nl"`, `"Name <a@b.nl>"` or `{ name, address }` and returns `{ name, address }`. */
38
+ export function parseAddress(input) {
39
+ let name = "";
40
+ let address;
41
+ if (input && typeof input === "object") {
42
+ name = input.name ?? "";
43
+ address = input.address;
44
+ } else if (typeof input === "string") {
45
+ const angle = /^([\s\S]*?)<([^<>]*)>\s*$/.exec(input.trim());
46
+ if (angle) {
47
+ name = angle[1].trim().replace(/^"([\s\S]*)"$/, (_, inner) => inner.replace(/\\(.)/g, "$1"));
48
+ address = angle[2];
49
+ } else address = input;
50
+ } else throw bad("Email address must be a string or { name, address }", input);
51
+ if (typeof name !== "string" || CONTROL.test(name)) throw bad("Display name may not contain control characters or line breaks", name);
52
+ return { name: name.trim(), address: normalizeAddress(address) };
53
+ }
54
+
55
+ /** Splits `"A <a@x.nl>, b@y.nl"` on commas outside quotes and angle brackets. */
56
+ function splitList(text) {
57
+ const parts = [];
58
+ let current = "";
59
+ let quoted = false;
60
+ let angle = false;
61
+ for (let i = 0; i < text.length; i++) {
62
+ const ch = text[i];
63
+ if (ch === "\\" && quoted) {
64
+ current += ch + (text[++i] ?? "");
65
+ continue;
66
+ }
67
+ if (ch === '"') quoted = !quoted;
68
+ else if (!quoted && ch === "<") angle = true;
69
+ else if (!quoted && ch === ">") angle = false;
70
+ if (ch === "," && !quoted && !angle) {
71
+ parts.push(current);
72
+ current = "";
73
+ } else current += ch;
74
+ }
75
+ parts.push(current);
76
+ return parts.map((p) => p.trim()).filter(Boolean);
77
+ }
78
+
79
+ /** A string, an object, or an array of those; always returns an array of `{ name, address }` without duplicates. */
80
+ export function parseAddressList(input) {
81
+ if (input === undefined || input === null || input === "") return [];
82
+ const items = Array.isArray(input) ? input.flatMap((i) => (typeof i === "string" ? splitList(i) : [i])) : typeof input === "string" ? splitList(input) : [input];
83
+ const seen = new Set();
84
+ const out = [];
85
+ for (const item of items) {
86
+ const parsed = parseAddress(item);
87
+ const key = parsed.address.toLowerCase();
88
+ if (seen.has(key)) continue;
89
+ seen.add(key);
90
+ out.push(parsed);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Encodes non-ASCII text for a header (RFC 2047), in pieces that stay under 75 characters. ASCII is returned as is. */
96
+ export function encodeWords(text) {
97
+ if (PRINTABLE_ASCII.test(text)) return text;
98
+ const words = [];
99
+ let current = "";
100
+ let bytes = 0;
101
+ for (const ch of text) {
102
+ const size = Buffer.byteLength(ch);
103
+ if (bytes + size > 45) {
104
+ words.push(current);
105
+ current = "";
106
+ bytes = 0;
107
+ }
108
+ current += ch;
109
+ bytes += size;
110
+ }
111
+ if (current) words.push(current);
112
+ return words.map((w) => `=?UTF-8?B?${Buffer.from(w, "utf8").toString("base64")}?=`).join("\r\n ");
113
+ }
114
+
115
+ export function formatAddress({ name, address }) {
116
+ if (!name) return address;
117
+ if (PRINTABLE_ASCII.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>`;
118
+ return `${encodeWords(name)} <${address}>`;
119
+ }
120
+
121
+ export function formatAddressList(list) {
122
+ return list.map(formatAddress).join(",\r\n ");
123
+ }
package/src/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Every failure of this package is a MailError, so callers can branch on `code` and `retryable`. */
2
+ export class MailError extends Error {
3
+ constructor(message, { code = "mail_error", retryable = false, cause, ...extra } = {}) {
4
+ super(message, cause ? { cause } : undefined);
5
+ this.name = "MailError";
6
+ this.code = code;
7
+ this.retryable = retryable;
8
+ Object.assign(this, extra);
9
+ }
10
+ }