@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/index.d.ts ADDED
@@ -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/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { MailError } from "./errors.js";
2
+ import { encodeWords, formatAddress, isEmail, normalizeAddress, parseAddress, parseAddressList } from "./address.js";
3
+ import { prepareMessage, quotedPrintable } from "./message.js";
4
+ import { escapeHtml, extractLinks, htmlToText, wrapText } from "./text.js";
5
+ import { block, email, escapeMarkup } from "./template.js";
6
+ import { smtpTransport } from "./smtp.js";
7
+ import { failoverTransport, memoryTransport, resendTransport } from "./transports.js";
8
+ import { createMailer } from "./mailer.js";
9
+ import { lintEmail } from "./lint.js";
10
+ import { preview } from "./preview.js";
11
+
12
+ export { MailError, encodeWords, formatAddress, isEmail, normalizeAddress, parseAddress, parseAddressList };
13
+ export { prepareMessage, quotedPrintable, escapeHtml, extractLinks, htmlToText, wrapText };
14
+ export { block, email, escapeMarkup, smtpTransport, failoverTransport, memoryTransport, resendTransport, createMailer, lintEmail, preview };
package/src/lint.js ADDED
@@ -0,0 +1,31 @@
1
+ import { prepareMessage } from "./message.js";
2
+ import { extractLinks } from "./text.js";
3
+
4
+ /**
5
+ * Looks at a mail the way a spam filter and an inbox would, and returns a list of `{ level, message }` hints.
6
+ * It is advice, not a guarantee of delivery.
7
+ */
8
+ export function lintEmail(input) {
9
+ const m = input.raw ? input : prepareMessage({ from: "lint@example.com", to: "lint@example.com", ...input });
10
+ const out = [];
11
+ const warn = (message) => out.push({ level: "warning", message });
12
+ const info = (message) => out.push({ level: "info", message });
13
+ if (m.subject.length > 78) warn(`Subject is ${m.subject.length} characters; inboxes cut it off around 60-78.`);
14
+ if (m.subject.length > 3 && m.subject === m.subject.toUpperCase() && /[A-Z]/.test(m.subject)) warn("Subject is ALL CAPS, which spam filters dislike.");
15
+ if (/!{2,}/.test(m.subject)) warn("Subject has repeated exclamation marks.");
16
+ if (m.html !== undefined) {
17
+ const size = Buffer.byteLength(m.html);
18
+ if (size > 100_000) warn(`HTML is ${Math.round(size / 1000)} KB; Gmail cuts messages off at about 102 KB.`);
19
+ if (/<img\b/i.test(m.html) && !/<img\b[^>]*\balt=/i.test(m.html)) warn("An image has no alt text.");
20
+ if (/<script\b|\son\w+\s*=|javascript:/i.test(m.html)) warn("HTML contains script or event handlers; mail clients strip them.");
21
+ if (/<link\b[^>]*stylesheet|@import/i.test(m.html)) warn("External stylesheets are ignored by most mail clients; use inline styles.");
22
+ const textLength = m.text.replace(/\s+/g, "").length;
23
+ if (textLength < 40 && /<img\b/i.test(m.html)) warn("Almost only images; add real text, it helps delivery and accessibility.");
24
+ }
25
+ const links = [...new Set([...extractLinks(m.html ?? ""), ...extractLinks(m.text)])];
26
+ if (links.some((l) => l.startsWith("http://"))) warn("Contains an http:// link; use https://.");
27
+ if (links.length > 10) info(`Contains ${links.length} links; fewer is safer for delivery.`);
28
+ const hasUnsub = Object.keys(m.headers).some((h) => h.toLowerCase() === "list-unsubscribe");
29
+ if (!hasUnsub && !/^auto-generated$/i.test(m.headers["Auto-Submitted"] ?? "")) info("No List-Unsubscribe header; add one if this is not a transactional mail.");
30
+ return out;
31
+ }
package/src/mailer.js ADDED
@@ -0,0 +1,66 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ import { parseAddress, parseAddressList } from "./address.js";
4
+ import { MailError } from "./errors.js";
5
+ import { prepareMessage } from "./message.js";
6
+
7
+ const mask = (address) => address.replace(/^(.).*(@.*)$/, "$1***$2");
8
+
9
+ /**
10
+ * The one object an app uses: `mailer.send({ to, subject, html })` checks the message, sends it, retries
11
+ * temporary failures with a pause, and can keep staging or test systems from mailing real people.
12
+ *
13
+ * @param {object} options
14
+ * @param {{ send(message): Promise<object> }} options.transport smtpTransport, resendTransport, memoryTransport, failoverTransport
15
+ * @param {string|{name,address}} options.from default sender
16
+ * @param {string|string[]} [options.redirectAllTo] every mail goes to these addresses instead (staging); the real recipients are put in the subject
17
+ * @param {string[]} [options.allowDomains] only these recipient domains may receive mail; others fail before sending
18
+ */
19
+ export function createMailer(options = {}) {
20
+ const { transport, retries = 2, retryDelayMs = 500, onSent, onError, redirectAllTo, allowDomains, ...defaults } = options;
21
+ if (!transport || typeof transport.send !== "function") throw new MailError("createMailer needs a transport", { code: "invalid_config" });
22
+ const redirect = redirectAllTo ? parseAddressList(redirectAllTo) : null;
23
+ const allowed = allowDomains ? new Set(allowDomains.map((d) => String(d).toLowerCase())) : null;
24
+ if (defaults.from !== undefined) parseAddress(defaults.from);
25
+
26
+ const guard = (message) => {
27
+ if (!allowed) return;
28
+ const blocked = message.envelope.to.filter((a) => !allowed.has(a.split("@")[1]));
29
+ if (blocked.length) throw new MailError(`Recipient domain not allowed: ${blocked.map(mask).join(", ")}`, { code: "recipient_not_allowed" });
30
+ };
31
+
32
+ async function send(input) {
33
+ let message = prepareMessage(input, defaults);
34
+ guard(message);
35
+ if (redirect) {
36
+ const original = message.envelope.to.map(mask).join(", ");
37
+ message = prepareMessage({ ...input, to: redirect, cc: undefined, bcc: undefined, subject: `[to: ${original}] ${message.subject}` }, defaults);
38
+ }
39
+ let attempt = 0;
40
+ for (;;) {
41
+ attempt++;
42
+ try {
43
+ const result = await transport.send(message);
44
+ const done = { ...result, attempts: attempt, transport: result.transport ?? transport.name };
45
+ try { onSent?.({ ...done, to: message.envelope.to.map(mask), subject: message.subject }); } catch { /* a logging hook must not break sending */ }
46
+ return done;
47
+ } catch (error) {
48
+ const wrapped = error instanceof MailError ? error : new MailError(`Sending failed: ${error?.message ?? error}`, { code: "send_failed", cause: error });
49
+ if (wrapped.retryable && attempt <= retries) {
50
+ await sleep(Math.round(retryDelayMs * 2 ** (attempt - 1) * (0.75 + Math.random() * 0.5)));
51
+ continue;
52
+ }
53
+ wrapped.attempts = attempt;
54
+ try { onError?.(wrapped, { to: message.envelope.to.map(mask), subject: message.subject }); } catch { /* same */ }
55
+ throw wrapped;
56
+ }
57
+ }
58
+ }
59
+
60
+ return {
61
+ send,
62
+ /** Checks the transport settings (connects and logs in for SMTP). Call it once at startup. */
63
+ verify: async () => (typeof transport.verify === "function" ? transport.verify() : true),
64
+ transport,
65
+ };
66
+ }
package/src/message.js ADDED
@@ -0,0 +1,190 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ import { encodeWords, formatAddress, formatAddressList, parseAddress, parseAddressList } from "./address.js";
4
+ import { MailError } from "./errors.js";
5
+ import { htmlToText } from "./text.js";
6
+
7
+ const RESERVED = new Set(["from", "to", "cc", "bcc", "subject", "date", "message-id", "mime-version", "content-type", "content-transfer-encoding", "reply-to", "sender", "return-path"]);
8
+ const MAX_RECIPIENTS = 50;
9
+
10
+ const fail = (message, code = "invalid_message") => new MailError(message, { code });
11
+
12
+ /** Quoted-printable (RFC 2045) of a text: lines of at most 76 characters, CRLF line breaks. */
13
+ export function quotedPrintable(text) {
14
+ const bytes = Buffer.from(String(text).replace(/\r\n?|\n/g, "\n"), "utf8");
15
+ const lines = [];
16
+ let line = "";
17
+ let tokens = [];
18
+ const flush = () => {
19
+ if (tokens.length) {
20
+ const last = tokens[tokens.length - 1];
21
+ if (last === " " || last === "\t") tokens[tokens.length - 1] = "=" + last.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
22
+ }
23
+ let width = 0;
24
+ line = "";
25
+ for (const t of tokens) {
26
+ if (width + t.length > 75) {
27
+ lines.push(line + "=");
28
+ line = "";
29
+ width = 0;
30
+ }
31
+ line += t;
32
+ width += t.length;
33
+ }
34
+ lines.push(line);
35
+ tokens = [];
36
+ };
37
+ for (const b of bytes) {
38
+ if (b === 10) flush();
39
+ else if ((b >= 33 && b <= 126 && b !== 61) || b === 32 || b === 9) tokens.push(String.fromCharCode(b));
40
+ else tokens.push("=" + b.toString(16).toUpperCase().padStart(2, "0"));
41
+ }
42
+ flush();
43
+ return lines.join("\r\n");
44
+ }
45
+
46
+ const base64Lines = (buffer) => (buffer.toString("base64").match(/.{1,76}/g) ?? []).join("\r\n");
47
+ const boundary = () => `=_ss_${randomBytes(12).toString("hex")}`;
48
+ const needsEncoding = (name) => !/^[\x20-\x7e]*$/.test(name);
49
+ const quote = (value) => `"${value.replace(/(["\\])/g, "\\$1")}"`;
50
+
51
+ const extended = (name) => `UTF-8''${encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
52
+ // ASCII names are plain quoted parameters; others use RFC 2231 (`name*=UTF-8''...`), which Gmail, Outlook, Apple Mail and Thunderbird read.
53
+ const param = (key, name) => (needsEncoding(name) ? `${key}*=${extended(name)}` : `${key}=${quote(name)}`);
54
+
55
+ function textPart(contentType, body) {
56
+ return `Content-Type: ${contentType}; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n${quotedPrintable(body)}\r\n`;
57
+ }
58
+
59
+ function multipart(type, parts) {
60
+ const b = boundary();
61
+ return { header: `Content-Type: multipart/${type}; boundary="${b}"`, body: `${parts.map((p) => `--${b}\r\n${p}`).join("")}--${b}--\r\n` };
62
+ }
63
+
64
+ // A child part is a string that starts with its own headers (it is already a full MIME entity).
65
+ const entity = (m) => `${m.header}\r\n\r\n${m.body}`;
66
+
67
+ function attachmentPart(a) {
68
+ const disposition = a.cid ? "inline" : "attachment";
69
+ return (
70
+ `Content-Type: ${a.contentType}; ${param("name", a.filename)}\r\n` +
71
+ `Content-Transfer-Encoding: base64\r\n` +
72
+ `Content-Disposition: ${disposition}; ${param("filename", a.filename)}\r\n` +
73
+ (a.cid ? `Content-ID: <${a.cid}>\r\n` : "") +
74
+ `\r\n${base64Lines(a.content)}\r\n`
75
+ );
76
+ }
77
+
78
+ function checkHeaderValue(name, value) {
79
+ if (typeof value !== "string" || /[\r\n\0]/.test(value)) throw fail(`Header ${name} must be a single line of text`, "invalid_header");
80
+ }
81
+
82
+ function normalizeAttachment(a, index) {
83
+ if (!a || typeof a !== "object") throw fail(`attachments[${index}] must be an object`);
84
+ const content = typeof a.content === "string" ? Buffer.from(a.content, a.encoding === "base64" ? "base64" : "utf8") : a.content;
85
+ if (!Buffer.isBuffer(content) && !(content instanceof Uint8Array)) throw fail(`attachments[${index}].content must be a Buffer or string`);
86
+ const filename = String(a.filename ?? "").replace(/[\r\n\0/\\]/g, "_").trim();
87
+ if (!filename) throw fail(`attachments[${index}].filename is required`);
88
+ const contentType = String(a.contentType ?? "application/octet-stream");
89
+ if (!/^[\w.+-]+\/[\w.+-]+$/.test(contentType)) throw fail(`attachments[${index}].contentType is not valid`);
90
+ const cid = a.cid === undefined ? undefined : String(a.cid);
91
+ if (cid !== undefined && !/^[\w.@+-]{1,200}$/.test(cid)) throw fail(`attachments[${index}].cid is not valid`);
92
+ return { filename, content: Buffer.from(content), contentType, cid };
93
+ }
94
+
95
+ /**
96
+ * Checks and normalizes a message. The result has everything a transport needs, and `raw` (a Buffer with the
97
+ * complete RFC 5322 message) is built on first use.
98
+ */
99
+ export function prepareMessage(input, defaults = {}) {
100
+ if (!input || typeof input !== "object") throw fail("A message must be an object");
101
+ const merged = { ...defaults, ...input, headers: { ...defaults.headers, ...input.headers } };
102
+ const from = parseAddress(merged.from ?? (() => { throw fail("from is required", "missing_from"); })());
103
+ const to = parseAddressList(merged.to);
104
+ const cc = parseAddressList(merged.cc);
105
+ const bcc = parseAddressList(merged.bcc);
106
+ const replyTo = parseAddressList(merged.replyTo);
107
+ const unique = new Map();
108
+ for (const a of [...to, ...cc, ...bcc]) if (!unique.has(a.address.toLowerCase())) unique.set(a.address.toLowerCase(), a.address);
109
+ const total = new Set(unique.values());
110
+ if (!total.size) throw fail("At least one recipient (to, cc or bcc) is required", "no_recipients");
111
+ if (total.size > (merged.maxRecipients ?? MAX_RECIPIENTS)) throw fail(`Too many recipients (${total.size}); send in smaller batches or raise maxRecipients`, "too_many_recipients");
112
+
113
+ const subject = String(merged.subject ?? "").replace(/[\t ]+/g, " ").trim();
114
+ if (!subject) throw fail("subject is required", "missing_subject");
115
+ if (/[\r\n\0]/.test(subject)) throw fail("subject may not contain line breaks", "invalid_header");
116
+
117
+ let html = merged.html === undefined ? undefined : String(merged.html);
118
+ let text = merged.text === undefined ? undefined : String(merged.text);
119
+ if (html === undefined && text === undefined) throw fail("Give the message a text or html body", "empty_body");
120
+ if (text === undefined) text = htmlToText(html);
121
+
122
+ const attachments = (merged.attachments ?? []).map(normalizeAttachment);
123
+ const headers = {};
124
+ for (const [name, value] of Object.entries(merged.headers)) {
125
+ if (value === undefined || value === null) continue;
126
+ if (!/^[A-Za-z0-9-]{1,60}$/.test(name)) throw fail(`Header name not allowed: ${name.slice(0, 40)}`, "invalid_header");
127
+ if (RESERVED.has(name.toLowerCase())) throw fail(`Set ${name} with its own option, not in headers`, "invalid_header");
128
+ checkHeaderValue(name, String(value));
129
+ headers[name] = String(value);
130
+ }
131
+ if (merged.autoSubmitted !== false && !Object.keys(headers).some((n) => n.toLowerCase() === "auto-submitted")) headers["Auto-Submitted"] = "auto-generated";
132
+ if (merged.unsubscribe) {
133
+ const { url, mailto } = merged.unsubscribe;
134
+ const items = [];
135
+ if (url) {
136
+ if (!/^https:\/\/[^\s<>]+$/.test(url)) throw fail("unsubscribe.url must be an https URL", "invalid_header");
137
+ items.push(`<${url}>`);
138
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
139
+ }
140
+ if (mailto) items.push(`<mailto:${parseAddress(mailto).address}>`);
141
+ if (!items.length) throw fail("unsubscribe needs a url or mailto", "invalid_header");
142
+ headers["List-Unsubscribe"] = items.join(", ");
143
+ }
144
+
145
+ const messageId = merged.messageId ?? `<${randomBytes(16).toString("hex")}@${from.address.split("@")[1]}>`;
146
+ if (!/^<[^<>\s@]+@[^<>\s@]+>$/.test(messageId)) throw fail("messageId must look like <id@domain>", "invalid_header");
147
+ const date = merged.date instanceof Date ? merged.date : new Date();
148
+ const envelopeFrom = merged.envelopeFrom ? parseAddress(merged.envelopeFrom).address : from.address;
149
+ const envelopeTo = [...total];
150
+
151
+ const message = {
152
+ from, to, cc, bcc, replyTo, subject, html, text, headers, attachments, messageId, date,
153
+ envelope: { from: envelopeFrom, to: envelopeTo },
154
+ };
155
+ let raw;
156
+ Object.defineProperty(message, "raw", { enumerable: false, get: () => (raw ??= buildRaw(message)) });
157
+ return message;
158
+ }
159
+
160
+ function buildRaw(m) {
161
+ const head = [];
162
+ head.push(`From: ${formatAddress(m.from)}`);
163
+ if (m.to.length) head.push(`To: ${formatAddressList(m.to)}`);
164
+ if (m.cc.length) head.push(`Cc: ${formatAddressList(m.cc)}`);
165
+ if (m.replyTo.length) head.push(`Reply-To: ${formatAddressList(m.replyTo)}`);
166
+ head.push(`Subject: ${encodeWords(m.subject)}`);
167
+ head.push(`Date: ${m.date.toUTCString().replace("GMT", "+0000")}`);
168
+ head.push(`Message-ID: ${m.messageId}`);
169
+ head.push("MIME-Version: 1.0");
170
+ for (const [name, value] of Object.entries(m.headers)) head.push(`${name}: ${encodeWords(value)}`);
171
+
172
+ const inline = m.attachments.filter((a) => a.cid);
173
+ const regular = m.attachments.filter((a) => !a.cid);
174
+ let body;
175
+ if (m.html === undefined) body = { header: null, text: m.text };
176
+ else {
177
+ const htmlEntity = inline.length
178
+ ? multipart("related", [textPart("text/html", m.html), ...inline.map(attachmentPart)])
179
+ : null;
180
+ const htmlPart = htmlEntity ? entity(htmlEntity) + "\r\n" : textPart("text/html", m.html);
181
+ body = multipart("alternative", [textPart("text/plain", m.text), htmlPart]);
182
+ }
183
+ let top;
184
+ if (regular.length) {
185
+ const first = body.header ? entity(body) + "\r\n" : textPart("text/plain", m.text);
186
+ top = multipart("mixed", [first, ...regular.map(attachmentPart)]);
187
+ } else if (body.header) top = body;
188
+ else top = { header: "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable", body: `${quotedPrintable(m.text)}\r\n` };
189
+ return Buffer.from(`${head.join("\r\n")}\r\n${top.header}\r\n\r\n${top.body}`, "latin1");
190
+ }
package/src/preview.js ADDED
@@ -0,0 +1,12 @@
1
+ import { escapeHtml } from "./text.js";
2
+
3
+ /**
4
+ * One HTML page that shows a mail the way you want to review it: subject, the rendered mail (narrow and wide) and
5
+ * the plain-text version. Write it to a file and open it in a browser: `writeFileSync("mail.html", preview(mail))`.
6
+ */
7
+ export function preview(mail) {
8
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Preview: ${escapeHtml(mail.subject)}</title>
9
+ <style>body{font:15px system-ui,sans-serif;margin:0;padding:24px;background:#e7eaee;color:#1f2933}h1{font-size:18px;margin:0 0 4px}.meta{color:#6b7785;margin:0 0 20px}.row{display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start}iframe{border:0;background:#fff;box-shadow:0 1px 4px #0003;height:760px}pre{background:#fff;padding:16px;white-space:pre-wrap;max-width:560px;box-shadow:0 1px 4px #0003}</style></head><body>
10
+ <h1>${escapeHtml(mail.subject)}</h1><p class="meta">HTML (wide and phone width) and the text version</p>
11
+ <div class="row"><iframe style="width:680px" srcdoc="${escapeHtml(mail.html ?? "")}"></iframe><iframe style="width:380px" srcdoc="${escapeHtml(mail.html ?? "")}"></iframe><pre>${escapeHtml(mail.text)}</pre></div></body></html>`;
12
+ }