@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SandSoftwareSolutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # @sandsoftwaresolutions/mail-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/SandSoftwareSolutions/ss-mail-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/SandSoftwareSolutions/ss-mail-kit/actions/workflows/ci.yml)
4
+
5
+ Send good-looking email from Node without learning email. One small API for the whole job: build the mail, send it over **SMTP** (Zoho, Gmail, your own server) or **Resend**, retry temporary failures without ever sending twice, and test it without sending anything. No dependencies.
6
+
7
+ ```js
8
+ import { createMailer, smtpTransport, email, block } from "@sandsoftwaresolutions/mail-kit";
9
+
10
+ const mailer = createMailer({
11
+ from: "Prijzenwacht <noreply@prijzenwacht.sandsoftwaresolutions.com>",
12
+ transport: smtpTransport({ host: "smtp.zoho.eu", user: "noreply@…", pass: process.env.SMTP_PASS }),
13
+ });
14
+
15
+ await mailer.send({
16
+ to: "klant@example.nl",
17
+ ...email({
18
+ subject: "Bevestig je e-mailadres",
19
+ preheader: "Nog één stap",
20
+ lang: "nl",
21
+ brand: { name: "Prijzenwacht", url: "https://prijzenwacht.sandsoftwaresolutions.com", color: "#0f766e" },
22
+ blocks: [
23
+ block.heading("Welkom!"),
24
+ "Bevestig je e-mailadres om te beginnen. [Hulp nodig?](https://example.nl/hulp)",
25
+ block.button("Bevestig mijn e-mailadres", verifyUrl),
26
+ block.note("Heb jij dit niet aangevraagd? Dan kun je deze mail negeren."),
27
+ ],
28
+ footer: ["Prijzenwacht · KvK 12345678"],
29
+ }),
30
+ });
31
+ ```
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install @sandsoftwaresolutions/mail-kit
37
+ ```
38
+
39
+ Node 20 or newer. Works with `import` and `require()`, with TypeScript types for both.
40
+
41
+ ## What you get
42
+
43
+ **Templates that work.** `email()` makes table-based HTML with inline styles (Outlook, Gmail and Apple Mail), a phone layout, a dark-mode variant, a hidden preheader, and a **hand-written plain-text version from the same blocks** (not stripped HTML). Blocks: `heading`, paragraph (a plain string), `button`, `code` (verification codes), `list`, `table` (invoice rows), `note`, `divider`, `image`. Text blocks understand `**bold**`, `` `code` `` and `[label](https://url)`. **Text you did not write yourself** (a customer's name) goes through `escapeMarkup(name)` first, otherwise a name like `[click](https://evil)` would become a real link in your mail. Everything is HTML-escaped either way; unsafe links and invalid colors are refused, not passed through.
44
+
45
+ **Transports.**
46
+
47
+ | | |
48
+ |---|---|
49
+ | `smtpTransport({ host, port, user, pass })` | Own SMTP client. Port 465 = TLS from the start; other ports **must** upgrade with STARTTLS or the send is refused (no silent downgrade). Certificates are checked. Refuses to send a password unencrypted. PLAIN and LOGIN. |
50
+ | `resendTransport({ apiKey })` | Resend HTTP API. The message id is the idempotency key, so a retry can never deliver twice. |
51
+ | `memoryTransport()` | Keeps mails in memory for tests. `transport.to("a@b.nl").links[0]` is the verification link. |
52
+ | `failoverTransport([a, b])` | Tries the next one only when the first never accepted the mail. |
53
+
54
+ **The mailer.** `createMailer({ transport, from })` then `mailer.send({ to, subject, html })`:
55
+
56
+ - Retries temporary failures (`retries: 2`, exponential backoff). Permanent rejections are not retried. If the connection breaks *after* the message was sent, the error is `delivery_unknown` and it is **not** retried, so nobody gets two mails.
57
+ - `redirectAllTo: "dev@…"` for staging: every mail goes there, with the real recipients (masked) in the subject.
58
+ - `allowDomains: ["sand.nl"]` blocks any other recipient before anything is sent.
59
+ - `onSent` / `onError` hooks get masked addresses; a hook that throws cannot break sending.
60
+ - `await mailer.verify()` at startup connects and logs in (SMTP) so wrong settings show up at boot.
61
+
62
+ **Safe by default.** Header injection (a line break in a subject, name or address) is refused. Addresses are validated (internationalized domains become punycode). `Bcc` never appears in headers. At most 50 recipients unless you raise it. Attachment names cannot contain path parts. Errors never contain your password or API key.
63
+
64
+ **Smaller things.** `unsubscribe: { url }` adds the one-click `List-Unsubscribe` headers. Attachments and inline images (`cid:`). `Auto-Submitted: auto-generated` on by default so out-of-office replies stay away. `lintEmail(mail)` warns about ALL CAPS subjects, mails over Gmail's 102 KB cut-off, `http://` links, images without alt text. `preview(mail)` returns one HTML page (wide, phone width and text) to open in a browser.
65
+
66
+ ## Zoho
67
+
68
+ ```js
69
+ smtpTransport({ host: "smtp.zoho.eu", port: 465, user: "noreply@yourdomain", pass: "<app password>" })
70
+ ```
71
+
72
+ Use `smtp.zoho.com` for accounts in the US data centre. Create an app-specific password in the Zoho account settings. The `from` address must be the account's address or an alias of it. Zoho signs your mail (DKIM) once the domain's SPF and DKIM records are set; this package does not sign.
73
+
74
+ ## Testing your own app
75
+
76
+ ```js
77
+ const transport = memoryTransport();
78
+ const mailer = createMailer({ transport, from: "app@example.nl" });
79
+ await signUp("klant@example.nl"); // your code, which calls mailer.send(...)
80
+ const link = transport.to("klant@example.nl").links[0]; // follow it in your test
81
+ ```
82
+
83
+ ## Errors
84
+
85
+ Everything thrown is a `MailError` with `code` and `retryable`. Examples: `invalid_address`, `auth_failed`, `recipient_rejected`, `temporary_failure`, `rate_limited`, `timeout`, `tls_failed`, `tls_unavailable`, `too_large`, `delivery_unknown`, `recipient_not_allowed`.
86
+
87
+ ## Not included
88
+
89
+ No DKIM signing (your mail provider does it), no connection pool (one connection per mail; fine for transactional volumes), no bounce handling, no marketing-list management, no OAuth2 for SMTP, no `.mjml`. For mass mailing use a dedicated service.
90
+
91
+ MIT
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ const { domainToASCII } = require("node:url");
3
+
4
+ const { MailError } = require("./errors.cjs");
5
+
6
+ const LOCAL = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
7
+ const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
8
+ const CONTROL = /[\u0000-\u001f\u007f]/;
9
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
10
+
11
+ const bad = (message, value) => new MailError(message, { code: "invalid_address", value: typeof value === "string" ? value.slice(0, 200) : value });
12
+
13
+ /** Checks one bare address and returns it with a lowercased, ASCII (punycode) domain. Throws MailError. */
14
+ function normalizeAddress(address) {
15
+ if (typeof address !== "string") throw bad("Email address must be a string", address);
16
+ const value = address.trim();
17
+ const at = value.lastIndexOf("@");
18
+ if (at < 1 || value.length > 254) throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
19
+ const local = value.slice(0, at);
20
+ const domain = domainToASCII(value.slice(at + 1)).toLowerCase();
21
+ const labels = domain.split(".");
22
+ if (local.length > 64 || !LOCAL.test(local)) throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
23
+ if (labels.length < 2 || domain.length > 253 || !labels.every((l) => LABEL.test(l)) || /^\d+$/.test(labels[labels.length - 1])) {
24
+ throw bad(`Not a valid email address: ${value.slice(0, 80)}`, value);
25
+ }
26
+ return `${local}@${domain}`;
27
+ }
28
+
29
+ function isEmail(value) {
30
+ try {
31
+ normalizeAddress(value);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Accepts `"a@b.nl"`, `"Name <a@b.nl>"` or `{ name, address }` and returns `{ name, address }`. */
39
+ function parseAddress(input) {
40
+ let name = "";
41
+ let address;
42
+ if (input && typeof input === "object") {
43
+ name = input.name ?? "";
44
+ address = input.address;
45
+ } else if (typeof input === "string") {
46
+ const angle = /^([\s\S]*?)<([^<>]*)>\s*$/.exec(input.trim());
47
+ if (angle) {
48
+ name = angle[1].trim().replace(/^"([\s\S]*)"$/, (_, inner) => inner.replace(/\\(.)/g, "$1"));
49
+ address = angle[2];
50
+ } else address = input;
51
+ } else throw bad("Email address must be a string or { name, address }", input);
52
+ if (typeof name !== "string" || CONTROL.test(name)) throw bad("Display name may not contain control characters or line breaks", name);
53
+ return { name: name.trim(), address: normalizeAddress(address) };
54
+ }
55
+
56
+ /** Splits `"A <a@x.nl>, b@y.nl"` on commas outside quotes and angle brackets. */
57
+ function splitList(text) {
58
+ const parts = [];
59
+ let current = "";
60
+ let quoted = false;
61
+ let angle = false;
62
+ for (let i = 0; i < text.length; i++) {
63
+ const ch = text[i];
64
+ if (ch === "\\" && quoted) {
65
+ current += ch + (text[++i] ?? "");
66
+ continue;
67
+ }
68
+ if (ch === '"') quoted = !quoted;
69
+ else if (!quoted && ch === "<") angle = true;
70
+ else if (!quoted && ch === ">") angle = false;
71
+ if (ch === "," && !quoted && !angle) {
72
+ parts.push(current);
73
+ current = "";
74
+ } else current += ch;
75
+ }
76
+ parts.push(current);
77
+ return parts.map((p) => p.trim()).filter(Boolean);
78
+ }
79
+
80
+ /** A string, an object, or an array of those; always returns an array of `{ name, address }` without duplicates. */
81
+ function parseAddressList(input) {
82
+ if (input === undefined || input === null || input === "") return [];
83
+ const items = Array.isArray(input) ? input.flatMap((i) => (typeof i === "string" ? splitList(i) : [i])) : typeof input === "string" ? splitList(input) : [input];
84
+ const seen = new Set();
85
+ const out = [];
86
+ for (const item of items) {
87
+ const parsed = parseAddress(item);
88
+ const key = parsed.address.toLowerCase();
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ out.push(parsed);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Encodes non-ASCII text for a header (RFC 2047), in pieces that stay under 75 characters. ASCII is returned as is. */
97
+ function encodeWords(text) {
98
+ if (PRINTABLE_ASCII.test(text)) return text;
99
+ const words = [];
100
+ let current = "";
101
+ let bytes = 0;
102
+ for (const ch of text) {
103
+ const size = Buffer.byteLength(ch);
104
+ if (bytes + size > 45) {
105
+ words.push(current);
106
+ current = "";
107
+ bytes = 0;
108
+ }
109
+ current += ch;
110
+ bytes += size;
111
+ }
112
+ if (current) words.push(current);
113
+ return words.map((w) => `=?UTF-8?B?${Buffer.from(w, "utf8").toString("base64")}?=`).join("\r\n ");
114
+ }
115
+
116
+ function formatAddress({ name, address }) {
117
+ if (!name) return address;
118
+ if (PRINTABLE_ASCII.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>`;
119
+ return `${encodeWords(name)} <${address}>`;
120
+ }
121
+
122
+ function formatAddressList(list) {
123
+ return list.map(formatAddress).join(",\r\n ");
124
+ }
125
+
126
+ exports.normalizeAddress = normalizeAddress;
127
+ exports.isEmail = isEmail;
128
+ exports.parseAddress = parseAddress;
129
+ exports.parseAddressList = parseAddressList;
130
+ exports.encodeWords = encodeWords;
131
+ exports.formatAddress = formatAddress;
132
+ exports.formatAddressList = formatAddressList;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ /** Every failure of this package is a MailError, so callers can branch on `code` and `retryable`. */
3
+ class MailError extends Error {
4
+ constructor(message, { code = "mail_error", retryable = false, cause, ...extra } = {}) {
5
+ super(message, cause ? { cause } : undefined);
6
+ this.name = "MailError";
7
+ this.code = code;
8
+ this.retryable = retryable;
9
+ Object.assign(this, extra);
10
+ }
11
+ }
12
+
13
+ exports.MailError = MailError;
package/dist/index.cjs ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ const { MailError } = require("./errors.cjs");
3
+ const { encodeWords, formatAddress, isEmail, normalizeAddress, parseAddress, parseAddressList } = require("./address.cjs");
4
+ const { prepareMessage, quotedPrintable } = require("./message.cjs");
5
+ const { escapeHtml, extractLinks, htmlToText, wrapText } = require("./text.cjs");
6
+ const { block, email, escapeMarkup } = require("./template.cjs");
7
+ const { smtpTransport } = require("./smtp.cjs");
8
+ const { failoverTransport, memoryTransport, resendTransport } = require("./transports.cjs");
9
+ const { createMailer } = require("./mailer.cjs");
10
+ const { lintEmail } = require("./lint.cjs");
11
+ const { preview } = require("./preview.cjs");
12
+
13
+ exports.MailError = MailError;
14
+ exports.encodeWords = encodeWords;
15
+ exports.formatAddress = formatAddress;
16
+ exports.isEmail = isEmail;
17
+ exports.normalizeAddress = normalizeAddress;
18
+ exports.parseAddress = parseAddress;
19
+ exports.parseAddressList = parseAddressList;
20
+ exports.prepareMessage = prepareMessage;
21
+ exports.quotedPrintable = quotedPrintable;
22
+ exports.escapeHtml = escapeHtml;
23
+ exports.extractLinks = extractLinks;
24
+ exports.htmlToText = htmlToText;
25
+ exports.wrapText = wrapText;
26
+ exports.block = block;
27
+ exports.email = email;
28
+ exports.escapeMarkup = escapeMarkup;
29
+ exports.smtpTransport = smtpTransport;
30
+ exports.failoverTransport = failoverTransport;
31
+ exports.memoryTransport = memoryTransport;
32
+ exports.resendTransport = resendTransport;
33
+ exports.createMailer = createMailer;
34
+ exports.lintEmail = lintEmail;
35
+ exports.preview = preview;
@@ -0,0 +1,166 @@
1
+ export interface Address { name: string; address: string }
2
+ export type AddressInput = string | { name?: string; address: string };
3
+ export type AddressesInput = AddressInput | AddressInput[];
4
+
5
+ export class MailError extends Error {
6
+ /** invalid_address, invalid_header, invalid_message, invalid_template, invalid_config, no_recipients, auth_failed,
7
+ * recipient_rejected, rejected, temporary_failure, rate_limited, timeout, connection_failed, tls_failed,
8
+ * tls_unavailable, too_large, delivery_unknown, recipient_not_allowed, send_failed, ... */
9
+ code: string;
10
+ /** True when sending the same message again is safe and may work. */
11
+ retryable: boolean;
12
+ /** Set by the mailer: how many tries were made. */
13
+ attempts?: number;
14
+ responseCode?: number;
15
+ response?: string;
16
+ }
17
+
18
+ export interface Attachment {
19
+ filename: string;
20
+ content: Buffer | Uint8Array | string;
21
+ /** "base64" when `content` is a base64 string. */
22
+ encoding?: "base64" | "utf8";
23
+ contentType?: string;
24
+ /** Makes it an inline image: use `<img src="cid:logo">` with `cid: "logo"`. */
25
+ cid?: string;
26
+ }
27
+
28
+ export interface MessageInput {
29
+ from?: AddressInput;
30
+ to?: AddressesInput;
31
+ cc?: AddressesInput;
32
+ bcc?: AddressesInput;
33
+ replyTo?: AddressesInput;
34
+ subject: string;
35
+ html?: string;
36
+ /** Made from the html when left out. */
37
+ text?: string;
38
+ headers?: Record<string, string>;
39
+ attachments?: Attachment[];
40
+ /** Adds List-Unsubscribe and the one-click header. */
41
+ unsubscribe?: { url?: string; mailto?: string };
42
+ /** Default true: adds `Auto-Submitted: auto-generated`, which stops out-of-office replies. */
43
+ autoSubmitted?: boolean;
44
+ messageId?: string;
45
+ date?: Date;
46
+ /** Bounce address, if different from `from`. */
47
+ envelopeFrom?: string;
48
+ /** Default 50. */
49
+ maxRecipients?: number;
50
+ }
51
+
52
+ export interface PreparedMessage {
53
+ from: Address; to: Address[]; cc: Address[]; bcc: Address[]; replyTo: Address[];
54
+ subject: string; html?: string; text: string;
55
+ headers: Record<string, string>;
56
+ attachments: { filename: string; content: Buffer; contentType: string; cid?: string }[];
57
+ messageId: string; date: Date;
58
+ envelope: { from: string; to: string[] };
59
+ /** The complete RFC 5322 message, all CRLF, 7-bit. */
60
+ readonly raw: Buffer;
61
+ }
62
+
63
+ export interface SendResult {
64
+ id: string; messageId: string; accepted: string[]; rejected: string[]; response?: string;
65
+ attempts?: number; transport?: string;
66
+ }
67
+
68
+ export interface Transport {
69
+ name: string;
70
+ send(message: PreparedMessage | MessageInput): Promise<SendResult>;
71
+ verify?(): Promise<unknown>;
72
+ }
73
+
74
+ export function isEmail(value: unknown): boolean;
75
+ export function normalizeAddress(address: string): string;
76
+ export function parseAddress(input: AddressInput): Address;
77
+ export function parseAddressList(input?: AddressesInput): Address[];
78
+ export function encodeWords(text: string): string;
79
+ export function formatAddress(address: Address): string;
80
+ export function prepareMessage(input: MessageInput, defaults?: Partial<MessageInput>): PreparedMessage;
81
+ export function quotedPrintable(text: string): string;
82
+
83
+ export function escapeHtml(value: unknown): string;
84
+ export function htmlToText(html: string): string;
85
+ export function wrapText(text: string, width?: number): string;
86
+ export function extractLinks(content: string): string[];
87
+
88
+ export type Block =
89
+ | { type: "heading"; text: string; level: 1 | 2 }
90
+ | { type: "paragraph" | "note"; text: string }
91
+ | { type: "button"; label: string; url: string }
92
+ | { type: "code"; value: string; caption?: string }
93
+ | { type: "list"; items: string[]; ordered: boolean }
94
+ | { type: "table"; rows: [string, string][]; total: boolean }
95
+ | { type: "divider" }
96
+ | { type: "image"; src: string; alt: string; width?: number };
97
+
98
+ export const block: {
99
+ heading(text: string, level?: 1 | 2): Block;
100
+ paragraph(text: string): Block;
101
+ button(label: string, url: string): Block;
102
+ code(value: string, caption?: string): Block;
103
+ list(items: string[], options?: { ordered?: boolean }): Block;
104
+ table(rows: [string, string][], options?: { total?: boolean }): Block;
105
+ note(text: string): Block;
106
+ divider(): Block;
107
+ image(src: string, alt: string, options?: { width?: number }): Block;
108
+ };
109
+
110
+ export interface EmailOptions {
111
+ subject: string;
112
+ /** Preview text shown next to the subject in the inbox. */
113
+ preheader?: string;
114
+ /** Strings are paragraphs. Text blocks understand **bold**, `code` and [label](https://url). */
115
+ blocks: (Block | string)[];
116
+ footer?: string | string[];
117
+ lang?: string;
118
+ brand?: { name?: string; url?: string; logoUrl?: string; color?: string; buttonTextColor?: string; textColor?: string; background?: string; font?: string };
119
+ }
120
+ export interface Mail { subject: string; html: string; text: string }
121
+ export function email(options: EmailOptions): Mail;
122
+ /** Escape a name or any outside text before putting it in a block, so it cannot turn into a link, bold text or code. */
123
+ export function escapeMarkup(value: unknown): string;
124
+ export function preview(mail: Mail): string;
125
+ export function lintEmail(mail: MessageInput | PreparedMessage): { level: "warning" | "info"; message: string }[];
126
+
127
+ export interface SmtpOptions {
128
+ host: string;
129
+ /** Default 587 (465 when security is "tls"). */
130
+ port?: number;
131
+ /** "tls" = encrypted from the start (port 465), "starttls" = must upgrade or fail (default), "none" = no encryption. */
132
+ security?: "tls" | "starttls" | "none";
133
+ user?: string;
134
+ pass?: string;
135
+ allowInsecureAuth?: boolean;
136
+ timeoutMs?: number;
137
+ clientName?: string;
138
+ tls?: { rejectUnauthorized?: boolean; ca?: string | Buffer | (string | Buffer)[]; servername?: string; minVersion?: string };
139
+ }
140
+ export function smtpTransport(options: SmtpOptions): Transport & { verify(): Promise<true> };
141
+ export function resendTransport(options: { apiKey: string; apiUrl?: string; fetch?: typeof fetch; timeoutMs?: number }): Transport;
142
+ export function memoryTransport(): Transport & {
143
+ sent: (PreparedMessage & { links: string[] })[];
144
+ last(): (PreparedMessage & { links: string[] }) | undefined;
145
+ to(address: string): (PreparedMessage & { links: string[] }) | undefined;
146
+ clear(): void;
147
+ };
148
+ export function failoverTransport(transports: Transport[]): Transport & { verify(): Promise<boolean> };
149
+
150
+ export interface MailerOptions extends Partial<MessageInput> {
151
+ transport: Transport;
152
+ /** Extra tries for temporary failures. Default 2. */
153
+ retries?: number;
154
+ retryDelayMs?: number;
155
+ /** Every mail goes here instead (staging); the real recipients are noted in the subject. */
156
+ redirectAllTo?: AddressesInput;
157
+ /** Only these recipient domains may get mail. */
158
+ allowDomains?: string[];
159
+ onSent?: (info: SendResult & { to: string[]; subject: string }) => void;
160
+ onError?: (error: MailError, info: { to: string[]; subject: string }) => void;
161
+ }
162
+ export function createMailer(options: MailerOptions): {
163
+ send(message: MessageInput): Promise<SendResult>;
164
+ verify(): Promise<unknown>;
165
+ transport: Transport;
166
+ };
package/dist/lint.cjs ADDED
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ const { prepareMessage } = require("./message.cjs");
3
+ const { extractLinks } = require("./text.cjs");
4
+
5
+ /**
6
+ * Looks at a mail the way a spam filter and an inbox would, and returns a list of `{ level, message }` hints.
7
+ * It is advice, not a guarantee of delivery.
8
+ */
9
+ function lintEmail(input) {
10
+ const m = input.raw ? input : prepareMessage({ from: "lint@example.com", to: "lint@example.com", ...input });
11
+ const out = [];
12
+ const warn = (message) => out.push({ level: "warning", message });
13
+ const info = (message) => out.push({ level: "info", message });
14
+ if (m.subject.length > 78) warn(`Subject is ${m.subject.length} characters; inboxes cut it off around 60-78.`);
15
+ if (m.subject.length > 3 && m.subject === m.subject.toUpperCase() && /[A-Z]/.test(m.subject)) warn("Subject is ALL CAPS, which spam filters dislike.");
16
+ if (/!{2,}/.test(m.subject)) warn("Subject has repeated exclamation marks.");
17
+ if (m.html !== undefined) {
18
+ const size = Buffer.byteLength(m.html);
19
+ if (size > 100_000) warn(`HTML is ${Math.round(size / 1000)} KB; Gmail cuts messages off at about 102 KB.`);
20
+ if (/<img\b/i.test(m.html) && !/<img\b[^>]*\balt=/i.test(m.html)) warn("An image has no alt text.");
21
+ if (/<script\b|\son\w+\s*=|javascript:/i.test(m.html)) warn("HTML contains script or event handlers; mail clients strip them.");
22
+ if (/<link\b[^>]*stylesheet|@import/i.test(m.html)) warn("External stylesheets are ignored by most mail clients; use inline styles.");
23
+ const textLength = m.text.replace(/\s+/g, "").length;
24
+ if (textLength < 40 && /<img\b/i.test(m.html)) warn("Almost only images; add real text, it helps delivery and accessibility.");
25
+ }
26
+ const links = [...new Set([...extractLinks(m.html ?? ""), ...extractLinks(m.text)])];
27
+ if (links.some((l) => l.startsWith("http://"))) warn("Contains an http:// link; use https://.");
28
+ if (links.length > 10) info(`Contains ${links.length} links; fewer is safer for delivery.`);
29
+ const hasUnsub = Object.keys(m.headers).some((h) => h.toLowerCase() === "list-unsubscribe");
30
+ if (!hasUnsub && !/^auto-generated$/i.test(m.headers["Auto-Submitted"] ?? "")) info("No List-Unsubscribe header; add one if this is not a transactional mail.");
31
+ return out;
32
+ }
33
+
34
+ exports.lintEmail = lintEmail;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ const { setTimeout: sleep } = require("node:timers/promises");
3
+
4
+ const { parseAddress, parseAddressList } = require("./address.cjs");
5
+ const { MailError } = require("./errors.cjs");
6
+ const { prepareMessage } = require("./message.cjs");
7
+
8
+ const mask = (address) => address.replace(/^(.).*(@.*)$/, "$1***$2");
9
+
10
+ /**
11
+ * The one object an app uses: `mailer.send({ to, subject, html })` checks the message, sends it, retries
12
+ * temporary failures with a pause, and can keep staging or test systems from mailing real people.
13
+ *
14
+ * @param {object} options
15
+ * @param {{ send(message): Promise<object> }} options.transport smtpTransport, resendTransport, memoryTransport, failoverTransport
16
+ * @param {string|{name,address}} options.from default sender
17
+ * @param {string|string[]} [options.redirectAllTo] every mail goes to these addresses instead (staging); the real recipients are put in the subject
18
+ * @param {string[]} [options.allowDomains] only these recipient domains may receive mail; others fail before sending
19
+ */
20
+ function createMailer(options = {}) {
21
+ const { transport, retries = 2, retryDelayMs = 500, onSent, onError, redirectAllTo, allowDomains, ...defaults } = options;
22
+ if (!transport || typeof transport.send !== "function") throw new MailError("createMailer needs a transport", { code: "invalid_config" });
23
+ const redirect = redirectAllTo ? parseAddressList(redirectAllTo) : null;
24
+ const allowed = allowDomains ? new Set(allowDomains.map((d) => String(d).toLowerCase())) : null;
25
+ if (defaults.from !== undefined) parseAddress(defaults.from);
26
+
27
+ const guard = (message) => {
28
+ if (!allowed) return;
29
+ const blocked = message.envelope.to.filter((a) => !allowed.has(a.split("@")[1]));
30
+ if (blocked.length) throw new MailError(`Recipient domain not allowed: ${blocked.map(mask).join(", ")}`, { code: "recipient_not_allowed" });
31
+ };
32
+
33
+ async function send(input) {
34
+ let message = prepareMessage(input, defaults);
35
+ guard(message);
36
+ if (redirect) {
37
+ const original = message.envelope.to.map(mask).join(", ");
38
+ message = prepareMessage({ ...input, to: redirect, cc: undefined, bcc: undefined, subject: `[to: ${original}] ${message.subject}` }, defaults);
39
+ }
40
+ let attempt = 0;
41
+ for (;;) {
42
+ attempt++;
43
+ try {
44
+ const result = await transport.send(message);
45
+ const done = { ...result, attempts: attempt, transport: result.transport ?? transport.name };
46
+ try { onSent?.({ ...done, to: message.envelope.to.map(mask), subject: message.subject }); } catch { /* a logging hook must not break sending */ }
47
+ return done;
48
+ } catch (error) {
49
+ const wrapped = error instanceof MailError ? error : new MailError(`Sending failed: ${error?.message ?? error}`, { code: "send_failed", cause: error });
50
+ if (wrapped.retryable && attempt <= retries) {
51
+ await sleep(Math.round(retryDelayMs * 2 ** (attempt - 1) * (0.75 + Math.random() * 0.5)));
52
+ continue;
53
+ }
54
+ wrapped.attempts = attempt;
55
+ try { onError?.(wrapped, { to: message.envelope.to.map(mask), subject: message.subject }); } catch { /* same */ }
56
+ throw wrapped;
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ send,
63
+ /** Checks the transport settings (connects and logs in for SMTP). Call it once at startup. */
64
+ verify: async () => (typeof transport.verify === "function" ? transport.verify() : true),
65
+ transport,
66
+ };
67
+ }
68
+
69
+ exports.createMailer = createMailer;