@sdxc/mail 0.0.0-pre.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,81 @@
1
+ /**
2
+ * In-memory transport that captures every delivery for assertions. As a full
3
+ * implementation of the transport contract, it lets tests assert on the message
4
+ * a caller actually produced — including the email object it came from, and
5
+ * the assembled MIME when it is asked for one — using state held in memory.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Result } from "@sdxc/result";
11
+ import type { MailError } from "./errors.js";
12
+ import type { NormalizedMessage, SentMessage, Transport } from "./types.js";
13
+ /** Configuration for what a memory transport records beyond the message itself. */
14
+ export interface MemoryTransportOptions {
15
+ /**
16
+ * Assemble and record the raw MIME message for every delivery. Off by default,
17
+ * because most tests assert on the normalized message, and MIME only matters to
18
+ * the transports whose provider takes it.
19
+ */
20
+ mime?: boolean;
21
+ }
22
+ /** One recorded delivery: the message a provider would have received, and its wire form. */
23
+ export interface RecordedDelivery {
24
+ /** The normalized message the transport was handed. */
25
+ message: NormalizedMessage;
26
+ /** The assembled MIME message, present only when MIME recording is enabled. */
27
+ mime?: string;
28
+ }
29
+ /**
30
+ * Transport that keeps deliveries in memory for assertions.
31
+ *
32
+ * Recorded messages are the normalized ones a provider receives; `message.email`
33
+ * keeps the original when a send came from one, so matching by type survives a copy.
34
+ *
35
+ * @example
36
+ * let transport = new MemoryTransport();
37
+ * await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite));
38
+ * expect(transport.find((message) => message.email instanceof TeamInviteEmail)).toBeDefined();
39
+ *
40
+ * @example
41
+ * let transport = new MemoryTransport({ mime: true });
42
+ * await new Mailer({ transport, from: SENDER }).send(message);
43
+ * expect(transport.lastMime).toContain("Content-Type: multipart/alternative");
44
+ */
45
+ export declare class MemoryTransport implements Transport {
46
+ #private;
47
+ /**
48
+ * Creates the transport.
49
+ *
50
+ * @param options - What to record; see {@link MemoryTransportOptions}.
51
+ */
52
+ constructor(options?: MemoryTransportOptions);
53
+ /** Every recorded delivery with its wire form, oldest first. */
54
+ get deliveries(): readonly RecordedDelivery[];
55
+ /** Every recorded delivery, oldest first. */
56
+ get messages(): readonly NormalizedMessage[];
57
+ /** Most recent delivery, or `undefined` when nothing has been sent yet. */
58
+ get last(): NormalizedMessage | undefined;
59
+ /**
60
+ * Raw MIME message of the most recent delivery. It is `undefined` when nothing has
61
+ * been sent, and also when the transport was not asked to record MIME, since
62
+ * assembling it for every test that never looks at it is wasted work.
63
+ */
64
+ get lastMime(): string | undefined;
65
+ /**
66
+ * Finds the first recorded delivery matching a predicate.
67
+ *
68
+ * @param predicate - Test applied to each message in delivery order.
69
+ * @returns The first matching message, or `undefined` when none matches.
70
+ */
71
+ find(predicate: (message: NormalizedMessage) => boolean): NormalizedMessage | undefined;
72
+ /** Forgets every recorded delivery, so one instance can be reused between tests. */
73
+ clear(): void;
74
+ /**
75
+ * Records the message and reports success.
76
+ *
77
+ * @param message - The normalized message to record.
78
+ * @returns Success carrying the message's own `Message-ID`, since no provider assigned one.
79
+ */
80
+ send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
81
+ }
package/dist/memory.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * In-memory transport that captures every delivery for assertions. As a full
3
+ * implementation of the transport contract, it lets tests assert on the message
4
+ * a caller actually produced — including the email object it came from, and
5
+ * the assembled MIME when it is asked for one — using state held in memory.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { success } from "@sdxc/result";
11
+ import { buildMimeMessage } from "./mime.js";
12
+ /**
13
+ * Transport that keeps deliveries in memory for assertions.
14
+ *
15
+ * Recorded messages are the normalized ones a provider receives; `message.email`
16
+ * keeps the original when a send came from one, so matching by type survives a copy.
17
+ *
18
+ * @example
19
+ * let transport = new MemoryTransport();
20
+ * await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite));
21
+ * expect(transport.find((message) => message.email instanceof TeamInviteEmail)).toBeDefined();
22
+ *
23
+ * @example
24
+ * let transport = new MemoryTransport({ mime: true });
25
+ * await new Mailer({ transport, from: SENDER }).send(message);
26
+ * expect(transport.lastMime).toContain("Content-Type: multipart/alternative");
27
+ */
28
+ export class MemoryTransport {
29
+ /** Deliveries recorded so far, oldest first. */
30
+ #deliveries = [];
31
+ /** Whether each delivery is recorded with its assembled MIME message. */
32
+ #mime;
33
+ /**
34
+ * Creates the transport.
35
+ *
36
+ * @param options - What to record; see {@link MemoryTransportOptions}.
37
+ */
38
+ constructor(options = {}) {
39
+ this.#mime = options.mime ?? false;
40
+ }
41
+ /** Every recorded delivery with its wire form, oldest first. */
42
+ get deliveries() {
43
+ return this.#deliveries;
44
+ }
45
+ /** Every recorded delivery, oldest first. */
46
+ get messages() {
47
+ return this.#deliveries.map((delivery) => delivery.message);
48
+ }
49
+ /** Most recent delivery, or `undefined` when nothing has been sent yet. */
50
+ get last() {
51
+ return this.#deliveries.at(-1)?.message;
52
+ }
53
+ /**
54
+ * Raw MIME message of the most recent delivery. It is `undefined` when nothing has
55
+ * been sent, and also when the transport was not asked to record MIME, since
56
+ * assembling it for every test that never looks at it is wasted work.
57
+ */
58
+ get lastMime() {
59
+ return this.#deliveries.at(-1)?.mime;
60
+ }
61
+ /**
62
+ * Finds the first recorded delivery matching a predicate.
63
+ *
64
+ * @param predicate - Test applied to each message in delivery order.
65
+ * @returns The first matching message, or `undefined` when none matches.
66
+ */
67
+ find(predicate) {
68
+ return this.messages.find(predicate);
69
+ }
70
+ /** Forgets every recorded delivery, so one instance can be reused between tests. */
71
+ clear() {
72
+ this.#deliveries.length = 0;
73
+ }
74
+ /**
75
+ * Records the message and reports success.
76
+ *
77
+ * @param message - The normalized message to record.
78
+ * @returns Success carrying the message's own `Message-ID`, since no provider assigned one.
79
+ */
80
+ async send(message) {
81
+ this.#deliveries.push({ message, mime: this.#mime ? buildMimeMessage(message) : undefined });
82
+ return success({ messageId: message.messageId });
83
+ }
84
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Remix fetch-router middleware that builds a mailer for the request and
3
+ * publishes it on the request context, so handlers send mail by calling
4
+ * `context.email`, using whichever transport the app configured. Messages
5
+ * queued with `later()` flush after the handler returns, and each outcome is
6
+ * recorded on the invocation's log.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Middleware, RequestContext } from "remix/router";
12
+ import type { Address, Transport } from "./types.js";
13
+ import { Mailer } from "./mailer.js";
14
+ /**
15
+ * Declared here, in an imported module rather than an ambient .d.ts, so the
16
+ * augmentation is applied in consuming projects that import the middleware.
17
+ */
18
+ declare module "remix/router" {
19
+ interface RequestContext {
20
+ /**
21
+ * The object that sends mail for the current request, configured by the
22
+ * mail middleware.
23
+ */
24
+ email: Mailer;
25
+ }
26
+ }
27
+ /** Options that configure the mail middleware. */
28
+ export interface MailMiddlewareOptions {
29
+ /**
30
+ * Transport used for delivery, or a factory that resolves one per request. A
31
+ * transport built from a module-level binding needs no factory; one resolved
32
+ * through a service container uses the factory form.
33
+ */
34
+ transport: Transport | ((context: RequestContext) => Transport);
35
+ /** Sender identity for the app, applied to every message that does not set one. */
36
+ from: Address;
37
+ /** Where replies go by default; a message or email may override it. */
38
+ replyTo?: Address | Address[];
39
+ /** Headers added to every message the request sends. */
40
+ headers?: Record<string, string>;
41
+ }
42
+ /**
43
+ * Creates a middleware that publishes a request-scoped mailer as `context.email`,
44
+ * resolving a container-based transport once per request. The `later()` queue
45
+ * flushes in a `finally` after `next()`; the response is already decided by then,
46
+ * so a failed deferred send is a warning on the invocation's log rather than an error.
47
+ *
48
+ * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
49
+ * @returns A middleware that populates `context.email`.
50
+ * @example
51
+ * router.use(mail({ transport: new CloudflareTransport(env.EMAIL), from: SENDER }));
52
+ */
53
+ export default function mail(options: MailMiddlewareOptions): Middleware;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Remix fetch-router middleware that builds a mailer for the request and
3
+ * publishes it on the request context, so handlers send mail by calling
4
+ * `context.email`, using whichever transport the app configured. Messages
5
+ * queued with `later()` flush after the handler returns, and each outcome is
6
+ * recorded on the invocation's log.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { currentLog } from "@sdxc/logger";
12
+ import { isFailure } from "@sdxc/result";
13
+ import { Mailer } from "./mailer.js";
14
+ /**
15
+ * Creates a middleware that publishes a request-scoped mailer as `context.email`,
16
+ * resolving a container-based transport once per request. The `later()` queue
17
+ * flushes in a `finally` after `next()`; the response is already decided by then,
18
+ * so a failed deferred send is a warning on the invocation's log rather than an error.
19
+ *
20
+ * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
21
+ * @returns A middleware that populates `context.email`.
22
+ * @example
23
+ * router.use(mail({ transport: new CloudflareTransport(env.EMAIL), from: SENDER }));
24
+ */
25
+ export default function mail(options) {
26
+ return async (context, next) => {
27
+ let transport = typeof options.transport === "function" ? options.transport(context) : options.transport;
28
+ let mailer = new Mailer({
29
+ transport,
30
+ from: options.from,
31
+ replyTo: options.replyTo,
32
+ headers: options.headers,
33
+ });
34
+ context.email = mailer;
35
+ try {
36
+ return await next();
37
+ }
38
+ finally {
39
+ let log = currentLog();
40
+ for (let result of await mailer.flush()) {
41
+ if (isFailure(result))
42
+ log?.warn("mail.send_failed", { error: result.error.message });
43
+ else
44
+ log
45
+ ?.set({ mail: { sent: true } })
46
+ .note("mail.sent", { message_id: result.data.messageId });
47
+ }
48
+ }
49
+ };
50
+ }
package/dist/mime.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Assembles a normalized message into the raw RFC 5322 message a transport whose
3
+ * provider takes MIME has to hand over: folded headers, encoded words for
4
+ * non-ASCII text, a `multipart/alternative` body when both parts exist, and CRLF
5
+ * line endings throughout. It lives at the package root so any raw-MIME transport
6
+ * can reuse it and so its tests need no runtime-specific import.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { NormalizedMessage } from "./types.js";
12
+ /**
13
+ * Assembles a normalized message into a raw RFC 5322 message, ordering plain text
14
+ * before HTML per RFC 2046 when both parts exist; with neither part, it yields an
15
+ * empty text part, since validation guarantees that case never reaches a transport.
16
+ *
17
+ * @param message - The normalized message a transport received.
18
+ * @returns The complete message, ready to hand to a provider that takes raw MIME.
19
+ * @example let raw = buildMimeMessage(message); // "From: ...\r\n\r\n..."
20
+ */
21
+ export declare function buildMimeMessage(message: NormalizedMessage): string;
package/dist/mime.js ADDED
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Assembles a normalized message into the raw RFC 5322 message a transport whose
3
+ * provider takes MIME has to hand over: folded headers, encoded words for
4
+ * non-ASCII text, a `multipart/alternative` body when both parts exist, and CRLF
5
+ * line endings throughout. It lives at the package root so any raw-MIME transport
6
+ * can reuse it and so its tests need no runtime-specific import.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { formatAddress } from "./lib/address.js";
12
+ /** Line ending every part of a MIME message uses, regardless of the host platform. */
13
+ const CRLF = "\r\n";
14
+ /** Length a header line should not exceed before it is folded, per RFC 5322. */
15
+ const MAX_HEADER_LINE_LENGTH = 78;
16
+ /** Length an encoded body line may reach, counting the soft break a wrap adds. */
17
+ const MAX_ENCODED_LINE_LENGTH = 76;
18
+ /** Opening of a base64 encoded word; UTF-8 because that is what the bodies are. */
19
+ const ENCODED_WORD_PREFIX = "=?UTF-8?B?";
20
+ /** Closing of an encoded word. */
21
+ const ENCODED_WORD_SUFFIX = "?=";
22
+ /** Longest an encoded word may be, delimiters included, per RFC 2047. */
23
+ const MAX_ENCODED_WORD_LENGTH = 75;
24
+ /** Characters an encoded word spends on its own delimiters rather than on content. */
25
+ const ENCODED_WORD_OVERHEAD = ENCODED_WORD_PREFIX.length + ENCODED_WORD_SUFFIX.length;
26
+ /** Smallest chunk a word may carry, so an unusually cramped line still makes progress. */
27
+ const MIN_ENCODED_WORD_BYTES = 6;
28
+ /** Printable ASCII, the only range a header value may hold without encoding. */
29
+ const PRINTABLE_ASCII = /^[\x20-\x7E]*$/;
30
+ /** Any of the three line endings a caller's body might arrive with. */
31
+ const NEWLINE = /\r\n|\r|\n/g;
32
+ /**
33
+ * Share of bytes that may need escaping before base64 becomes the cheaper
34
+ * encoding: quoted-printable spends 3 characters per escaped byte against
35
+ * base64's 4 per 3 bytes, so the two break even at one escaped byte in six.
36
+ */
37
+ const QUOTED_PRINTABLE_ESCAPE_BUDGET = 1 / 6;
38
+ /** Token a generated boundary is built on, using only characters RFC 2046 allows. */
39
+ const BOUNDARY_PREFIX = "=_Part_";
40
+ /** Day names in `Date#getUTCDay()` order; the header requires the English abbreviations. */
41
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
42
+ /** Month names in `Date#getUTCMonth()` order, for the same reason as the day names. */
43
+ const MONTH_NAMES = [
44
+ "Jan",
45
+ "Feb",
46
+ "Mar",
47
+ "Apr",
48
+ "May",
49
+ "Jun",
50
+ "Jul",
51
+ "Aug",
52
+ "Sep",
53
+ "Oct",
54
+ "Nov",
55
+ "Dec",
56
+ ];
57
+ /**
58
+ * Headers this builder derives from the message itself. A custom header repeating
59
+ * one of these names is dropped rather than emitted twice, because a duplicate
60
+ * `From` or `Content-Type` is what makes a message get filed as spam.
61
+ */
62
+ const RESERVED_HEADERS = new Set([
63
+ "bcc",
64
+ "cc",
65
+ "content-transfer-encoding",
66
+ "content-type",
67
+ "date",
68
+ "from",
69
+ "message-id",
70
+ "mime-version",
71
+ "reply-to",
72
+ "subject",
73
+ "to",
74
+ ]);
75
+ /** Byte value of the horizontal tab, which may travel literally except at the end of a line. */
76
+ const TAB = 9;
77
+ /** Byte value of the line feed, which is message structure rather than part content. */
78
+ const LINE_FEED = 10;
79
+ /** Byte value of the carriage return, paired with the line feed in every break. */
80
+ const CARRIAGE_RETURN = 13;
81
+ /** Byte value of the space character, under the same end-of-line rule as the tab. */
82
+ const SPACE = 32;
83
+ /** Byte value of `-`, escaped at the start of a line so no body line can look like a boundary. */
84
+ const HYPHEN = 45;
85
+ /** Byte value of `=`, which quoted-printable always escapes because it is the escape character. */
86
+ const EQUALS = 61;
87
+ /** Highest byte quoted-printable may pass through literally. */
88
+ const LAST_PRINTABLE = 126;
89
+ /** Pads a number to the two digits the date header's fields are written with. */
90
+ function pad(value) {
91
+ return String(value).padStart(2, "0");
92
+ }
93
+ /**
94
+ * Formats a date as an RFC 5322 `date-time`, always in UTC, using the numeric
95
+ * `+0000` zone, since some filters treat the obsolete `GMT` spelling as a sign of
96
+ * a hand-rolled sender.
97
+ */
98
+ function formatDate(date) {
99
+ let day = DAY_NAMES[date.getUTCDay()] ?? DAY_NAMES[0];
100
+ let month = MONTH_NAMES[date.getUTCMonth()] ?? MONTH_NAMES[0];
101
+ let time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
102
+ return `${day}, ${pad(date.getUTCDate())} ${month} ${date.getUTCFullYear()} ${time} +0000`;
103
+ }
104
+ /** Base64-encodes bytes as one continuous string; callers below wrap it into lines. */
105
+ function toBase64(bytes) {
106
+ let binary = "";
107
+ for (let byte of bytes)
108
+ binary += String.fromCharCode(byte);
109
+ return btoa(binary);
110
+ }
111
+ /** Wraps a chunk of bytes in the encoded-word delimiters RFC 2047 defines. */
112
+ function toEncodedWord(bytes) {
113
+ return `${ENCODED_WORD_PREFIX}${toBase64(Uint8Array.from(bytes))}${ENCODED_WORD_SUFFIX}`;
114
+ }
115
+ /**
116
+ * Bytes one encoded word may carry without pushing a line already holding
117
+ * `reserved` characters past the limit; encoded words cannot be folded. Base64
118
+ * turns 3 bytes into 4 characters, so this is the largest multiple of 3 that fits.
119
+ */
120
+ function encodedWordBytes(reserved) {
121
+ let available = Math.min(MAX_ENCODED_WORD_LENGTH, MAX_HEADER_LINE_LENGTH - reserved);
122
+ return Math.max(MIN_ENCODED_WORD_BYTES, Math.floor((available - ENCODED_WORD_OVERHEAD) / 4) * 3);
123
+ }
124
+ /**
125
+ * Encodes a header value as one or more RFC 2047 encoded words. Multi-byte
126
+ * characters are never split across words, since each decodes independently, and
127
+ * spaces travel inside the base64 because decoders drop whitespace between words.
128
+ *
129
+ * @param value - The text to encode.
130
+ * @param reserved - Characters the words must share their line with, such as `Subject: `.
131
+ */
132
+ function encodeWords(value, reserved) {
133
+ let encoder = new TextEncoder();
134
+ let limit = encodedWordBytes(reserved);
135
+ let words = [];
136
+ let chunk = [];
137
+ for (let character of value) {
138
+ let bytes = encoder.encode(character);
139
+ if (chunk.length > 0 && chunk.length + bytes.length > limit) {
140
+ words.push(toEncodedWord(chunk));
141
+ chunk = [];
142
+ }
143
+ for (let byte of bytes)
144
+ chunk.push(byte);
145
+ }
146
+ if (chunk.length > 0 || words.length === 0)
147
+ words.push(toEncodedWord(chunk));
148
+ return words.join(" ");
149
+ }
150
+ /**
151
+ * Reports whether a header value can travel as written. A value that already
152
+ * looks like an encoded word is encoded anyway, so text a person typed as `=?x?=`
153
+ * reaches the receiving client exactly as typed.
154
+ */
155
+ function isHeaderSafe(value) {
156
+ return PRINTABLE_ASCII.test(value) && !value.includes("=?");
157
+ }
158
+ /**
159
+ * Encodes unstructured header text, such as a subject, only when it has to be.
160
+ *
161
+ * @param name - Header name, whose length is what the first line has to give up.
162
+ * @param value - The value a caller wrote.
163
+ * @returns The value unchanged when it is plain ASCII, encoded words otherwise.
164
+ */
165
+ function encodeHeaderText(name, value) {
166
+ if (isHeaderSafe(value))
167
+ return value;
168
+ return encodeWords(value, name.length + 2);
169
+ }
170
+ /**
171
+ * Formats a mailbox for a header, encoding a non-ASCII display name. Encoding
172
+ * replaces quoting, since a quoted encoded word reaches the reader literally, and
173
+ * reserves space for the address, fold indent, and list separator on that line.
174
+ */
175
+ function encodeAddressHeader(address) {
176
+ let name = address.name?.trim();
177
+ if (!name || isHeaderSafe(name))
178
+ return formatAddress(address);
179
+ return `${encodeWords(name, address.email.length + 5)} <${address.email}>`;
180
+ }
181
+ /**
182
+ * Folds one header onto as many lines as it needs. The first line skips the colon's
183
+ * space, avoiding a bare `Name:` line, and a run with no space within the limit
184
+ * stays long, protecting the token it would otherwise split.
185
+ */
186
+ function foldHeader(name, value) {
187
+ let lines = [];
188
+ let rest = `${name}: ${value}`;
189
+ let earliest = name.length + 1;
190
+ while (rest.length > MAX_HEADER_LINE_LENGTH) {
191
+ let breakAt = rest.lastIndexOf(" ", MAX_HEADER_LINE_LENGTH);
192
+ if (breakAt <= earliest)
193
+ breakAt = rest.indexOf(" ", MAX_HEADER_LINE_LENGTH);
194
+ if (breakAt === -1)
195
+ break;
196
+ lines.push(rest.slice(0, breakAt));
197
+ rest = rest.slice(breakAt);
198
+ earliest = 1;
199
+ }
200
+ lines.push(rest);
201
+ return lines.join(CRLF);
202
+ }
203
+ /** Rewrites every line ending as CRLF, which is the only one MIME allows. */
204
+ function normalizeNewlines(value) {
205
+ return value.replace(NEWLINE, CRLF);
206
+ }
207
+ /**
208
+ * Reports whether quoted-printable has to escape a byte instead of passing it
209
+ * through. Line breaks are excluded: both encodings carry them structurally, so
210
+ * counting them would push short-lined plain text toward base64 for no gain.
211
+ */
212
+ function requiresEscape(byte) {
213
+ if (byte === EQUALS)
214
+ return true;
215
+ if (byte > LAST_PRINTABLE)
216
+ return true;
217
+ if (byte === TAB || byte === LINE_FEED || byte === CARRIAGE_RETURN)
218
+ return false;
219
+ return byte < SPACE;
220
+ }
221
+ /** Escapes a byte as the `=XX` form quoted-printable uses, with the uppercase hex it prescribes. */
222
+ function escapeByte(byte) {
223
+ return `=${byte.toString(16).toUpperCase().padStart(2, "0")}`;
224
+ }
225
+ /**
226
+ * Encodes one byte for quoted-printable output. A leading `-` is escaped even
227
+ * though printable, so no encoded line can begin with a boundary's `--`; trailing
228
+ * whitespace is escaped too, since a relaying server may strip it and alter the body.
229
+ */
230
+ function quotedPrintableToken(byte, atLineStart, atLineEnd) {
231
+ if (byte === HYPHEN && atLineStart)
232
+ return escapeByte(byte);
233
+ if ((byte === SPACE || byte === TAB) && atLineEnd)
234
+ return escapeByte(byte);
235
+ if (requiresEscape(byte))
236
+ return escapeByte(byte);
237
+ return String.fromCharCode(byte);
238
+ }
239
+ /**
240
+ * Encodes a single source line, soft-wrapping it as often as the length limit
241
+ * needs. A soft break is the decoder's signal to rejoin the line, so wrapping
242
+ * never changes the content it carries.
243
+ */
244
+ function encodeQuotedPrintableLine(line) {
245
+ let bytes = new TextEncoder().encode(line);
246
+ let lines = [];
247
+ let current = "";
248
+ let lastIndex = bytes.length - 1;
249
+ for (let [index, byte] of bytes.entries()) {
250
+ let token = quotedPrintableToken(byte, current.length === 0, index === lastIndex);
251
+ if (current.length + token.length > MAX_ENCODED_LINE_LENGTH - 1) {
252
+ lines.push(`${current}=`);
253
+ current = "";
254
+ token = quotedPrintableToken(byte, true, index === lastIndex);
255
+ }
256
+ current += token;
257
+ }
258
+ lines.push(current);
259
+ return lines.join(CRLF);
260
+ }
261
+ /** Encodes a whole body as quoted-printable, keeping its hard line breaks intact. */
262
+ function toQuotedPrintable(content) {
263
+ return normalizeNewlines(content).split(CRLF).map(encodeQuotedPrintableLine).join(CRLF);
264
+ }
265
+ /** Encodes a whole body as base64, wrapped to the line limit. */
266
+ function toBase64Body(content) {
267
+ let base64 = toBase64(new TextEncoder().encode(normalizeNewlines(content)));
268
+ let lines = [];
269
+ for (let index = 0; index < base64.length; index += MAX_ENCODED_LINE_LENGTH) {
270
+ lines.push(base64.slice(index, index + MAX_ENCODED_LINE_LENGTH));
271
+ }
272
+ return lines.join(CRLF);
273
+ }
274
+ /**
275
+ * Encodes a body part with whichever transfer encoding costs less. Quoted-printable
276
+ * wins for ordinary text because the result stays readable in a raw message, and
277
+ * base64 takes over once escaping would inflate the body more than base64 does.
278
+ */
279
+ function encodePart(contentType, content) {
280
+ let normalized = normalizeNewlines(content);
281
+ let bytes = new TextEncoder().encode(normalized);
282
+ let escaped = 0;
283
+ for (let byte of bytes)
284
+ if (requiresEscape(byte))
285
+ escaped++;
286
+ if (escaped > bytes.length * QUOTED_PRINTABLE_ESCAPE_BUDGET) {
287
+ return { contentType, encoding: "base64", body: toBase64Body(normalized) };
288
+ }
289
+ return { contentType, encoding: "quoted-printable", body: toQuotedPrintable(normalized) };
290
+ }
291
+ /**
292
+ * Picks a boundary no part can be confused with: the token carries a random UUID,
293
+ * and neither encoding can emit a line starting with `--` (base64 excludes `-`;
294
+ * quoted-printable escapes a leading one), checked directly against the bodies too.
295
+ */
296
+ function selectBoundary(parts) {
297
+ let boundary = `${BOUNDARY_PREFIX}${crypto.randomUUID()}`;
298
+ while (parts.some((part) => part.body.includes(boundary))) {
299
+ boundary = `${BOUNDARY_PREFIX}${crypto.randomUUID()}`;
300
+ }
301
+ return boundary;
302
+ }
303
+ /** Wraps a message identifier in the angle brackets the header requires. */
304
+ function toMessageIdHeader(messageId) {
305
+ let value = messageId.trim();
306
+ if (value.startsWith("<") && value.endsWith(">"))
307
+ return value;
308
+ return `<${value.replace(/^<|>$/g, "")}>`;
309
+ }
310
+ /**
311
+ * Builds the headers every message carries, in the order a reader of a raw message
312
+ * expects them. `Bcc` recipients are addressed through the envelope alone, since
313
+ * writing them into the message would expose them to everyone else.
314
+ */
315
+ function buildHeaders(message) {
316
+ let headers = [
317
+ foldHeader("From", encodeAddressHeader(message.from)),
318
+ foldHeader("To", message.to.map(encodeAddressHeader).join(", ")),
319
+ ];
320
+ if (message.cc.length > 0) {
321
+ headers.push(foldHeader("Cc", message.cc.map(encodeAddressHeader).join(", ")));
322
+ }
323
+ if (message.replyTo.length > 0) {
324
+ headers.push(foldHeader("Reply-To", message.replyTo.map(encodeAddressHeader).join(", ")));
325
+ }
326
+ headers.push(foldHeader("Subject", encodeHeaderText("Subject", message.subject)), foldHeader("Date", formatDate(message.date)), foldHeader("Message-ID", toMessageIdHeader(message.messageId)), foldHeader("MIME-Version", "1.0"));
327
+ for (let [name, value] of Object.entries(message.headers)) {
328
+ if (RESERVED_HEADERS.has(name.toLowerCase()))
329
+ continue;
330
+ headers.push(foldHeader(name, encodeHeaderText(name, value)));
331
+ }
332
+ return headers;
333
+ }
334
+ /** Writes the `Content-*` headers that describe one part's type and encoding. */
335
+ function buildContentHeaders(part) {
336
+ return [
337
+ foldHeader("Content-Type", `${part.contentType}; charset=utf-8`),
338
+ foldHeader("Content-Transfer-Encoding", part.encoding),
339
+ ];
340
+ }
341
+ /**
342
+ * Assembles a normalized message into a raw RFC 5322 message, ordering plain text
343
+ * before HTML per RFC 2046 when both parts exist; with neither part, it yields an
344
+ * empty text part, since validation guarantees that case never reaches a transport.
345
+ *
346
+ * @param message - The normalized message a transport received.
347
+ * @returns The complete message, ready to hand to a provider that takes raw MIME.
348
+ * @example let raw = buildMimeMessage(message); // "From: ...\r\n\r\n..."
349
+ */
350
+ export function buildMimeMessage(message) {
351
+ let text = message.text ? encodePart("text/plain", message.text) : undefined;
352
+ let html = message.html ? encodePart("text/html", message.html) : undefined;
353
+ let headers = buildHeaders(message);
354
+ if (text && html) {
355
+ let boundary = selectBoundary([text, html]);
356
+ return [
357
+ ...headers,
358
+ foldHeader("Content-Type", `multipart/alternative; boundary="${boundary}"`),
359
+ "",
360
+ `--${boundary}`,
361
+ ...buildContentHeaders(text),
362
+ "",
363
+ text.body,
364
+ `--${boundary}`,
365
+ ...buildContentHeaders(html),
366
+ "",
367
+ html.body,
368
+ `--${boundary}--`,
369
+ "",
370
+ ].join(CRLF);
371
+ }
372
+ let part = text ?? html ?? encodePart("text/plain", "");
373
+ return [...headers, ...buildContentHeaders(part), "", part.body, ""].join(CRLF);
374
+ }