@bobfrankston/mailx-types 0.1.38 → 0.1.41

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/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { GroupMap, RecipientToken, ExpansionResult } from "./groups.js";
11
11
  export { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual, } from "./reminder-state.js";
12
12
  export type { ReminderState } from "./reminder-state.js";
13
13
  export { extractInlineImages } from "./mime-inline-images.js";
14
+ export { buildMimeMessage } from "./mime-build.js";
15
+ export type { BuildMimeOptions, BuiltMime, MimeAttachment } from "./mime-build.js";
14
16
  export type { InlineImagePart } from "./mime-inline-images.js";
15
17
  /** Supported authentication methods */
16
18
  export type AuthMethod = "password" | "oauth2";
@@ -363,6 +365,46 @@ export declare function sanitizeHtml(html: string): {
363
365
  html: string;
364
366
  hasRemoteContent: boolean;
365
367
  };
368
+ /**
369
+ * Encode a header VALUE as RFC 2047 encoded-words when it contains non-ASCII.
370
+ *
371
+ * Why this has to exist: a header carrying raw 8-bit UTF-8 makes the whole
372
+ * message an EAI message (RFC 6530), which can only be relayed over SMTPUTF8
373
+ * (RFC 6531). Plenty of receivers still don't advertise that extension, and a
374
+ * sending MTA that can't downgrade has no choice but to bounce. mailx built
375
+ * every outgoing header with bare interpolation — `Subject: ${msg.subject}` —
376
+ * so ONE curly apostrophe was enough to make a message undeliverable:
377
+ *
378
+ * Subject: Re: … One of Canada’s Fastest Fiber Networks (U+2019, raw UTF-8)
379
+ * → <alland@soundbytesradio.com>: EAI message but server 17.57.152.5
380
+ * does not support SMTPUTF8 … This is a permanent error
381
+ *
382
+ * (Bob 2026-08-08, qmail bounce from gal.iecc.com — iCloud, Zoho and
383
+ * land-com all refused it; herot.com, gmail and bob.ma took it, so it looked
384
+ * like a partial failure.) Reply subjects inherit whatever punctuation the
385
+ * original used, and Word/paste smart quotes do the rest, so this fires
386
+ * constantly and silently.
387
+ *
388
+ * Pure-ASCII values are returned untouched — encoding them would be legal but
389
+ * needlessly unreadable in every mail client's raw-source view.
390
+ *
391
+ * Base64 rather than Q-encoding: it is uniform (no per-character escaping
392
+ * rules that differ between phrase and unstructured context) and never
393
+ * produces a token needing further escaping. Chunks are split so each
394
+ * encoded-word stays inside RFC 2047's 75-character limit, split on CHARACTER
395
+ * boundaries so a multi-byte sequence is never cut in half.
396
+ */
397
+ export declare function encodeHeaderWord(text: string): string;
398
+ /**
399
+ * Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
400
+ * DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
401
+ * would corrupt the address, and a non-ASCII addr-spec is genuinely EAI and
402
+ * cannot be represented any other way.
403
+ *
404
+ * Splits on commas outside angle brackets and quotes so a display name
405
+ * containing a comma ("Frankston, Bob" <x@y>) survives.
406
+ */
407
+ export declare function encodeAddressHeader(value: string): string;
366
408
  /** Encode text as RFC 2045 quoted-printable. */
367
409
  export declare function encodeQuotedPrintable(text: string): string;
368
410
  /** Render an HTML document as a plain-text approximation suitable for the
package/index.js CHANGED
@@ -20,6 +20,7 @@ export { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, remin
20
20
  // Outgoing-mail inline images: data: URI → cid: extraction shared by both
21
21
  // MIME assemblers (desktop send + Android/web send).
22
22
  export { extractInlineImages } from "./mime-inline-images.js";
23
+ export { buildMimeMessage } from "./mime-build.js";
23
24
  // ── Message flag state ──
24
25
  //
25
26
  // External API surface for the IMAP system flags. The literal strings
@@ -106,6 +107,123 @@ export function sanitizeHtml(html) {
106
107
  clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
107
108
  return { html: clean, hasRemoteContent };
108
109
  }
110
+ /**
111
+ * Encode a header VALUE as RFC 2047 encoded-words when it contains non-ASCII.
112
+ *
113
+ * Why this has to exist: a header carrying raw 8-bit UTF-8 makes the whole
114
+ * message an EAI message (RFC 6530), which can only be relayed over SMTPUTF8
115
+ * (RFC 6531). Plenty of receivers still don't advertise that extension, and a
116
+ * sending MTA that can't downgrade has no choice but to bounce. mailx built
117
+ * every outgoing header with bare interpolation — `Subject: ${msg.subject}` —
118
+ * so ONE curly apostrophe was enough to make a message undeliverable:
119
+ *
120
+ * Subject: Re: … One of Canada’s Fastest Fiber Networks (U+2019, raw UTF-8)
121
+ * → <alland@soundbytesradio.com>: EAI message but server 17.57.152.5
122
+ * does not support SMTPUTF8 … This is a permanent error
123
+ *
124
+ * (Bob 2026-08-08, qmail bounce from gal.iecc.com — iCloud, Zoho and
125
+ * land-com all refused it; herot.com, gmail and bob.ma took it, so it looked
126
+ * like a partial failure.) Reply subjects inherit whatever punctuation the
127
+ * original used, and Word/paste smart quotes do the rest, so this fires
128
+ * constantly and silently.
129
+ *
130
+ * Pure-ASCII values are returned untouched — encoding them would be legal but
131
+ * needlessly unreadable in every mail client's raw-source view.
132
+ *
133
+ * Base64 rather than Q-encoding: it is uniform (no per-character escaping
134
+ * rules that differ between phrase and unstructured context) and never
135
+ * produces a token needing further escaping. Chunks are split so each
136
+ * encoded-word stays inside RFC 2047's 75-character limit, split on CHARACTER
137
+ * boundaries so a multi-byte sequence is never cut in half.
138
+ */
139
+ export function encodeHeaderWord(text) {
140
+ if (!text)
141
+ return "";
142
+ // eslint-disable-next-line no-control-regex
143
+ if (!/[^\x00-\x7F]/.test(text))
144
+ return text;
145
+ const encoder = new TextEncoder();
146
+ // "=?UTF-8?B?" + payload + "?=" must be ≤ 75 chars, so the base64 payload
147
+ // gets 75 - 12 = 63 chars, and base64 only splits cleanly every 4 chars →
148
+ // 60 chars of payload = 45 bytes of input per encoded-word.
149
+ const MAX_BYTES_PER_WORD = 45;
150
+ const words = [];
151
+ let chunk = [];
152
+ for (const ch of text) { // iterate by code point
153
+ const bytes = Array.from(encoder.encode(ch));
154
+ if (chunk.length + bytes.length > MAX_BYTES_PER_WORD) {
155
+ words.push(chunk);
156
+ chunk = [];
157
+ }
158
+ chunk.push(...bytes);
159
+ }
160
+ if (chunk.length)
161
+ words.push(chunk);
162
+ // Continuation lines are joined with CRLF + space: consecutive
163
+ // encoded-words separated by folding whitespace are concatenated by the
164
+ // receiver with the whitespace REMOVED (RFC 2047 §6.2), which is what we
165
+ // want — the original text had no space there.
166
+ return words
167
+ .map(b => `=?UTF-8?B?${bytesToBase64(Uint8Array.from(b))}?=`)
168
+ .join("\r\n ");
169
+ }
170
+ /** Base64 without assuming Buffer (this package is browser-capable). */
171
+ function bytesToBase64(bytes) {
172
+ let bin = "";
173
+ for (const b of bytes)
174
+ bin += String.fromCharCode(b);
175
+ // btoa exists in browsers and in Node ≥16.
176
+ return btoa(bin);
177
+ }
178
+ /**
179
+ * Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
180
+ * DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
181
+ * would corrupt the address, and a non-ASCII addr-spec is genuinely EAI and
182
+ * cannot be represented any other way.
183
+ *
184
+ * Splits on commas outside angle brackets and quotes so a display name
185
+ * containing a comma ("Frankston, Bob" <x@y>) survives.
186
+ */
187
+ export function encodeAddressHeader(value) {
188
+ if (!value)
189
+ return "";
190
+ // eslint-disable-next-line no-control-regex
191
+ if (!/[^\x00-\x7F]/.test(value))
192
+ return value;
193
+ const parts = [];
194
+ let cur = "";
195
+ let inAngle = false;
196
+ let inQuote = false;
197
+ for (const ch of value) {
198
+ if (ch === '"')
199
+ inQuote = !inQuote;
200
+ else if (ch === "<" && !inQuote)
201
+ inAngle = true;
202
+ else if (ch === ">" && !inQuote)
203
+ inAngle = false;
204
+ if (ch === "," && !inAngle && !inQuote) {
205
+ parts.push(cur);
206
+ cur = "";
207
+ continue;
208
+ }
209
+ cur += ch;
210
+ }
211
+ if (cur.trim())
212
+ parts.push(cur);
213
+ return parts.map(part => {
214
+ const p = part.trim();
215
+ const m = /^(.*?)\s*(<[^>]*>)\s*$/.exec(p);
216
+ if (!m)
217
+ return p; // bare addr-spec, nothing to encode
218
+ const display = m[1].replace(/^"(.*)"$/s, "$1");
219
+ if (!display)
220
+ return m[2];
221
+ // eslint-disable-next-line no-control-regex
222
+ if (!/[^\x00-\x7F]/.test(display))
223
+ return p; // ASCII name — leave the quoting alone
224
+ return `${encodeHeaderWord(display)} ${m[2]}`;
225
+ }).join(", ");
226
+ }
109
227
  /** Encode text as RFC 2045 quoted-printable. */
110
228
  export function encodeQuotedPrintable(text) {
111
229
  const encoder = new TextEncoder();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Assemble an RFC 5322 / MIME message. The ONE place that does this.
3
+ *
4
+ * Why it exists: four live code paths built messages by hand — desktop send
5
+ * and draft (`mailx-service`), Android/web send and draft (`mailx-store-web`)
6
+ * — 72% identical line-for-line, plus four more copies in packages nothing
7
+ * could reach. Every fix to the shape had to be found and applied N times,
8
+ * and the N-th was routinely missed:
9
+ *
10
+ * - v1.2.166 envelope scraped quoted headers — "SAME shape in 3 files"
11
+ * - v1.2.179 inline images data: → cid: — had to land in BOTH send paths
12
+ * - v1.2.226 RFC 2047 encoded-words — 7 sites by hand, and the Android
13
+ * path still got only Subject: its From/To/Cc/Bcc were raw
14
+ * interpolation until this unification (2026-08-09), so an
15
+ * accented display name sent from Android was still an EAI
16
+ * message that non-SMTPUTF8 receivers bounce
17
+ *
18
+ * The shape produced is unchanged from what desktop send has emitted since
19
+ * v1.2.179 — this is an extraction, verified byte-for-byte against the old
20
+ * code over a matrix of body/attachment/inline-image/header combinations
21
+ * (see `test/mime-build.test.mjs`), not a rewrite.
22
+ *
23
+ * Structure it emits, narrowest form that carries the content:
24
+ *
25
+ * text only ............... text/plain
26
+ * html .................... multipart/alternative (plain derived + html)
27
+ * html + pasted images .... multipart/related [ alternative, image parts ]
28
+ * any of those + files .... multipart/mixed [ the above, attachments ]
29
+ * draft (simpleHtml) ...... a single text/html part, no alternative
30
+ */
31
+ export interface MimeAttachment {
32
+ filename?: string;
33
+ mimeType?: string;
34
+ dataBase64?: string;
35
+ }
36
+ export interface BuildMimeOptions {
37
+ /** Already-formatted address strings ("Name <a@b>", comma-separated). */
38
+ from: string;
39
+ to?: string;
40
+ cc?: string;
41
+ bcc?: string;
42
+ subject: string;
43
+ bodyHtml?: string;
44
+ bodyText?: string;
45
+ attachments?: MimeAttachment[];
46
+ inReplyTo?: string;
47
+ references?: string[];
48
+ /** Supply to reuse an existing id; omit and one is generated from `domain`. */
49
+ messageId?: string;
50
+ /** Domain for the generated Message-ID and for inline-image cid: ids. */
51
+ domain: string;
52
+ /** Extra header lines inserted before MIME-Version (e.g. X-Mailx-Draft-ID). */
53
+ extraHeaders?: string[];
54
+ /** Emit no Message-ID header at all — the draft paths never carried one. */
55
+ omitMessageId?: boolean;
56
+ /** Draft mode: one text/html part, no alternative / related / mixed and no
57
+ * inline-image extraction. A draft is re-parsed by the compose window, not
58
+ * delivered, so the multipart machinery would only get in the way. */
59
+ simpleHtml?: boolean;
60
+ /** Injectable for tests; defaults to `new Date()`. */
61
+ now?: Date;
62
+ /** Injectable for tests so boundaries and ids are reproducible. */
63
+ unique?: () => string;
64
+ }
65
+ export interface BuiltMime {
66
+ /** The complete message, CRLF line endings, ready for SMTP or APPEND. */
67
+ raw: string;
68
+ /** The Message-ID actually used (empty when `omitMessageId`). */
69
+ messageId: string;
70
+ /** How many data: images became cid: parts — worth logging. */
71
+ inlineImageCount: number;
72
+ }
73
+ export declare function buildMimeMessage(opts: BuildMimeOptions): BuiltMime;
74
+ //# sourceMappingURL=mime-build.d.ts.map
package/mime-build.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Assemble an RFC 5322 / MIME message. The ONE place that does this.
3
+ *
4
+ * Why it exists: four live code paths built messages by hand — desktop send
5
+ * and draft (`mailx-service`), Android/web send and draft (`mailx-store-web`)
6
+ * — 72% identical line-for-line, plus four more copies in packages nothing
7
+ * could reach. Every fix to the shape had to be found and applied N times,
8
+ * and the N-th was routinely missed:
9
+ *
10
+ * - v1.2.166 envelope scraped quoted headers — "SAME shape in 3 files"
11
+ * - v1.2.179 inline images data: → cid: — had to land in BOTH send paths
12
+ * - v1.2.226 RFC 2047 encoded-words — 7 sites by hand, and the Android
13
+ * path still got only Subject: its From/To/Cc/Bcc were raw
14
+ * interpolation until this unification (2026-08-09), so an
15
+ * accented display name sent from Android was still an EAI
16
+ * message that non-SMTPUTF8 receivers bounce
17
+ *
18
+ * The shape produced is unchanged from what desktop send has emitted since
19
+ * v1.2.179 — this is an extraction, verified byte-for-byte against the old
20
+ * code over a matrix of body/attachment/inline-image/header combinations
21
+ * (see `test/mime-build.test.mjs`), not a rewrite.
22
+ *
23
+ * Structure it emits, narrowest form that carries the content:
24
+ *
25
+ * text only ............... text/plain
26
+ * html .................... multipart/alternative (plain derived + html)
27
+ * html + pasted images .... multipart/related [ alternative, image parts ]
28
+ * any of those + files .... multipart/mixed [ the above, attachments ]
29
+ * draft (simpleHtml) ...... a single text/html part, no alternative
30
+ */
31
+ import { encodeQuotedPrintable, encodeHeaderWord, encodeAddressHeader, htmlToPlainText } from "./index.js";
32
+ import { extractInlineImages } from "./mime-inline-images.js";
33
+ /** Base64 must be folded at ≤76 chars per RFC 2045. */
34
+ function wrap76(s) {
35
+ return s.match(/.{1,76}/g)?.join("\r\n") || s;
36
+ }
37
+ export function buildMimeMessage(opts) {
38
+ const now = opts.now || new Date();
39
+ const unique = opts.unique
40
+ || (() => `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`);
41
+ const newBoundary = () => `mailx_${unique()}`;
42
+ const messageId = opts.omitMessageId
43
+ ? ""
44
+ : (opts.messageId || `<${Date.now()}.${Math.random().toString(36).slice(2)}@${opts.domain}>`);
45
+ const hasHtml = !!opts.bodyHtml;
46
+ // ── headers ──
47
+ // RFC 2047 on every header that can carry a display name or free text.
48
+ // Raw 8-bit here makes the whole message EAI (RFC 6530), which a receiver
49
+ // without SMTPUTF8 (RFC 6531) must refuse — one curly apostrophe in a
50
+ // Subject cost three recipients on 2026-08-08.
51
+ const headers = [
52
+ `From: ${encodeAddressHeader(opts.from)}`,
53
+ opts.to ? `To: ${encodeAddressHeader(opts.to)}` : null,
54
+ opts.cc ? `Cc: ${encodeAddressHeader(opts.cc)}` : null,
55
+ // Bcc belongs IN a draft — the SEND path strips it, so that a saved
56
+ // draft reopened for editing still shows who was on Bcc.
57
+ opts.bcc ? `Bcc: ${encodeAddressHeader(opts.bcc)}` : null,
58
+ `Subject: ${encodeHeaderWord(opts.subject)}`,
59
+ `Date: ${now.toUTCString()}`,
60
+ messageId ? `Message-ID: ${messageId}` : null,
61
+ opts.inReplyTo ? `In-Reply-To: ${opts.inReplyTo}` : null,
62
+ opts.references?.length ? `References: ${opts.references.join(" ")}` : null,
63
+ ...(opts.extraHeaders || []),
64
+ `MIME-Version: 1.0`,
65
+ ];
66
+ const commonHeaders = headers.filter((h) => h !== null);
67
+ // ── draft: a single text/html part and nothing else ──
68
+ if (opts.simpleHtml) {
69
+ const body = opts.bodyHtml || opts.bodyText || "";
70
+ const raw = [
71
+ ...commonHeaders,
72
+ `Content-Type: text/html; charset=UTF-8`,
73
+ `Content-Transfer-Encoding: quoted-printable`,
74
+ ].join("\r\n") + `\r\n\r\n` + encodeQuotedPrintable(body);
75
+ return { raw, messageId, inlineImageCount: 0 };
76
+ }
77
+ // Pasted images live in the editor HTML as data: URIs — fine in a WebView,
78
+ // broken for recipients on Outlook desktop (won't render data: images) and
79
+ // Gmail web (sanitizer strips them). Extract to cid:-referenced parts.
80
+ const extracted = hasHtml
81
+ ? extractInlineImages(opts.bodyHtml, opts.domain)
82
+ : { html: "", images: [] };
83
+ const htmlBody = extracted.html;
84
+ const inlineImages = extracted.images;
85
+ // HTML-bodied mail carries a text/plain alternative too: spam filters score
86
+ // HTML-only mail 1–2 points worse, and plain-text readers still exist.
87
+ const textBody = opts.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
88
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
89
+ const textEncoded = encodeQuotedPrintable(textBody);
90
+ /** The body part that carries the message text, without envelope headers,
91
+ * so it can stand alone OR become the first part of a multipart/mixed. */
92
+ const makeInner = () => {
93
+ if (hasHtml) {
94
+ const altBoundary = newBoundary();
95
+ const body = `--${altBoundary}\r\n` +
96
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
97
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
98
+ `${textEncoded}\r\n` +
99
+ `--${altBoundary}\r\n` +
100
+ `Content-Type: text/html; charset=UTF-8\r\n` +
101
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
102
+ `${htmlEncoded}\r\n` +
103
+ `--${altBoundary}--\r\n`;
104
+ const alt = {
105
+ headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`],
106
+ body,
107
+ };
108
+ if (inlineImages.length === 0)
109
+ return alt;
110
+ const relBoundary = newBoundary();
111
+ const relParts = [
112
+ `--${relBoundary}\r\n` + alt.headers.join("\r\n") + `\r\n\r\n` + alt.body,
113
+ ...inlineImages.map(im => `--${relBoundary}\r\n` +
114
+ `Content-Type: ${im.mime}\r\n` +
115
+ `Content-ID: <${im.contentId}>\r\n` +
116
+ `Content-Disposition: inline\r\n` +
117
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
118
+ `${wrap76(im.base64)}\r\n`),
119
+ ];
120
+ return {
121
+ headers: [`Content-Type: multipart/related; boundary="${relBoundary}"; type="multipart/alternative"`],
122
+ body: relParts.join("") + `--${relBoundary}--\r\n`,
123
+ };
124
+ }
125
+ return {
126
+ headers: [
127
+ `Content-Type: text/plain; charset=UTF-8`,
128
+ `Content-Transfer-Encoding: quoted-printable`,
129
+ ],
130
+ body: textEncoded,
131
+ };
132
+ };
133
+ const attachments = opts.attachments || [];
134
+ let raw;
135
+ if (attachments.length > 0) {
136
+ const mixedBoundary = newBoundary();
137
+ const inner = makeInner();
138
+ const parts = [
139
+ `--${mixedBoundary}\r\n` + inner.headers.join("\r\n") + `\r\n\r\n` + inner.body,
140
+ ];
141
+ for (const att of attachments) {
142
+ // A quote or newline in a filename would break out of the header.
143
+ const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
144
+ const mime = att.mimeType || "application/octet-stream";
145
+ parts.push(`--${mixedBoundary}\r\n` +
146
+ `Content-Type: ${mime}; name="${filename}"\r\n` +
147
+ `Content-Disposition: attachment; filename="${filename}"\r\n` +
148
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
149
+ `${wrap76(att.dataBase64 || "")}\r\n`);
150
+ }
151
+ raw = [...commonHeaders, `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`].join("\r\n")
152
+ + `\r\n\r\n` + parts.join("") + `--${mixedBoundary}--\r\n`;
153
+ }
154
+ else {
155
+ const inner = makeInner();
156
+ raw = [...commonHeaders, ...inner.headers].join("\r\n") + `\r\n\r\n` + inner.body;
157
+ }
158
+ return { raw, messageId, inlineImageCount: inlineImages.length };
159
+ }
160
+ //# sourceMappingURL=mime-build.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.38",
3
+ "version": "0.1.41",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,200 @@
1
+ /**
2
+ * buildMimeMessage must be a pure EXTRACTION of the send/draft assembly that
3
+ * lived in mailx-service and mailx-store-web — same bytes out, not "close
4
+ * enough". This compares it against a verbatim copy of the pre-2026-08-09
5
+ * desktop code (below, `legacyBuild`) across the shapes the app actually
6
+ * sends. Volatile parts (boundary strings, Message-ID, Date) are injected or
7
+ * normalised so a diff means a real structural difference.
8
+ *
9
+ * Run: node packages/mailx-types/test/mime-build.test.mjs
10
+ */
11
+ import assert from "node:assert";
12
+ import {
13
+ buildMimeMessage, encodeQuotedPrintable, encodeHeaderWord,
14
+ encodeAddressHeader, htmlToPlainText, extractInlineImages,
15
+ } from "../index.js";
16
+
17
+ // ── the code as it stood in mailx-service/index.ts before the unification ──
18
+ function legacyBuild(o) {
19
+ const hasHtml = !!o.bodyHtml;
20
+ const domain = o.domain;
21
+ const messageId = o.messageId;
22
+ const extracted = hasHtml ? extractInlineImages(o.bodyHtml, domain) : { html: "", images: [] };
23
+ const htmlBody = extracted.html;
24
+ const inlineImages = extracted.images;
25
+ const textBody = o.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
26
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
27
+ const textEncoded = encodeQuotedPrintable(textBody);
28
+ const hasAttachments = Array.isArray(o.attachments) && o.attachments.length > 0;
29
+ const commonHeaders = [
30
+ `From: ${encodeAddressHeader(o.from)}`, `To: ${encodeAddressHeader(o.to)}`,
31
+ o.cc ? `Cc: ${encodeAddressHeader(o.cc)}` : null,
32
+ o.bcc ? `Bcc: ${encodeAddressHeader(o.bcc)}` : null,
33
+ `Subject: ${encodeHeaderWord(o.subject)}`, `Date: ${o.now.toUTCString()}`,
34
+ `Message-ID: ${messageId}`,
35
+ o.inReplyTo ? `In-Reply-To: ${o.inReplyTo}` : null,
36
+ o.references?.length ? `References: ${o.references.join(" ")}` : null,
37
+ `MIME-Version: 1.0`,
38
+ ].filter(h => h !== null);
39
+
40
+ let rawMessage;
41
+ let n = 0;
42
+ const newBoundary = () => `mailx_B${n++}`;
43
+ const wrap76 = (s) => s.match(/.{1,76}/g)?.join("\r\n") || s;
44
+ const makeInner = () => {
45
+ if (hasHtml) {
46
+ const altBoundary = newBoundary();
47
+ const body =
48
+ `--${altBoundary}\r\n` +
49
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
50
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
51
+ `${textEncoded}\r\n` +
52
+ `--${altBoundary}\r\n` +
53
+ `Content-Type: text/html; charset=UTF-8\r\n` +
54
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
55
+ `${htmlEncoded}\r\n` +
56
+ `--${altBoundary}--\r\n`;
57
+ const alt = { headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`], body };
58
+ if (inlineImages.length === 0) return alt;
59
+ const relBoundary = newBoundary();
60
+ const relParts = [
61
+ `--${relBoundary}\r\n` + alt.headers.join("\r\n") + `\r\n\r\n` + alt.body,
62
+ ...inlineImages.map(im =>
63
+ `--${relBoundary}\r\n` +
64
+ `Content-Type: ${im.mime}\r\n` +
65
+ `Content-ID: <${im.contentId}>\r\n` +
66
+ `Content-Disposition: inline\r\n` +
67
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
68
+ `${wrap76(im.base64)}\r\n`),
69
+ ];
70
+ return {
71
+ headers: [`Content-Type: multipart/related; boundary="${relBoundary}"; type="multipart/alternative"`],
72
+ body: relParts.join("") + `--${relBoundary}--\r\n`,
73
+ };
74
+ }
75
+ return {
76
+ headers: [`Content-Type: text/plain; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`],
77
+ body: textEncoded,
78
+ };
79
+ };
80
+ if (hasAttachments) {
81
+ const mixedBoundary = newBoundary();
82
+ const inner = makeInner();
83
+ const parts = [`--${mixedBoundary}\r\n` + inner.headers.join("\r\n") + `\r\n\r\n` + inner.body];
84
+ for (const att of o.attachments) {
85
+ const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
86
+ const mime = att.mimeType || "application/octet-stream";
87
+ parts.push(
88
+ `--${mixedBoundary}\r\n` +
89
+ `Content-Type: ${mime}; name="${filename}"\r\n` +
90
+ `Content-Disposition: attachment; filename="${filename}"\r\n` +
91
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
92
+ `${wrap76(att.dataBase64 || "")}\r\n`
93
+ );
94
+ }
95
+ rawMessage = [...commonHeaders, `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`].join("\r\n")
96
+ + `\r\n\r\n` + parts.join("") + `--${mixedBoundary}--\r\n`;
97
+ } else {
98
+ const inner = makeInner();
99
+ rawMessage = [...commonHeaders, ...inner.headers].join("\r\n") + `\r\n\r\n` + inner.body;
100
+ }
101
+ return rawMessage;
102
+ }
103
+
104
+ // ── the legacy DRAFT assembly, verbatim ──
105
+ function legacyDraft(o) {
106
+ const body = o.bodyHtml || o.bodyText || "";
107
+ return [
108
+ `From: ${encodeAddressHeader(o.from)}`,
109
+ o.to ? `To: ${encodeAddressHeader(o.to)}` : null,
110
+ o.cc ? `Cc: ${encodeAddressHeader(o.cc)}` : null,
111
+ o.bcc ? `Bcc: ${encodeAddressHeader(o.bcc)}` : null,
112
+ `Subject: ${encodeHeaderWord(o.subject)}`, `Date: ${o.now.toUTCString()}`,
113
+ `X-Mailx-Draft-ID: ${o.draftId}`,
114
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`,
115
+ `Content-Transfer-Encoding: quoted-printable`,
116
+ ].filter(h => h !== null).join("\r\n") + `\r\n\r\n` + encodeQuotedPrintable(body);
117
+ }
118
+
119
+ const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
120
+ const now = new Date("Sat, 09 Aug 2026 13:30:00 GMT");
121
+ const messageId = "<fixed.id@bob.ma>";
122
+ const base = { from: "Bob <bob@bob.ma>", to: "A <a@x.com>", subject: "hello", domain: "bob.ma", now, messageId };
123
+
124
+ const cases = [
125
+ ["plain text only", { ...base, bodyText: "just text\nwith a line" }],
126
+ ["html (alternative)", { ...base, bodyHtml: "<p>hi <b>there</b></p>" }],
127
+ ["html + explicit text", { ...base, bodyHtml: "<p>hi</p>", bodyText: "hi" }],
128
+ ["html + inline image (related)", { ...base, bodyHtml: `<p>see</p><img src="data:image/png;base64,${PNG}">` }],
129
+ ["text + attachment (mixed)", { ...base, bodyText: "see file", attachments: [{ filename: "a.txt", mimeType: "text/plain", dataBase64: "aGVsbG8=" }] }],
130
+ ["html + inline image + 2 attachments", {
131
+ ...base, bodyHtml: `<p>x</p><img src="data:image/png;base64,${PNG}">`,
132
+ attachments: [
133
+ { filename: 'we"ird\nname.pdf', mimeType: "application/pdf", dataBase64: PNG },
134
+ { dataBase64: "AAAA" },
135
+ ],
136
+ }],
137
+ ["cc + bcc + threading headers", {
138
+ ...base, bodyText: "t", cc: "C <c@x.com>", bcc: "D <d@x.com>",
139
+ inReplyTo: "<prev@x.com>", references: ["<a@x.com>", "<b@x.com>"],
140
+ }],
141
+ ["non-ASCII display name and subject", {
142
+ ...base, from: "Bøb Frankstøn <bob@bob.ma>", to: "Renée <r@x.com>",
143
+ subject: "Canada’s fastest — really", bodyText: "t",
144
+ }],
145
+ ["long base64 folding", { ...base, bodyText: "t", attachments: [{ filename: "big.bin", dataBase64: "Q".repeat(500) }] }],
146
+ ];
147
+
148
+ /** extractInlineImages mints a random cid: id per call, so two runs over the
149
+ * same input differ there by design. Normalise it for the comparison — what
150
+ * must hold is that the SAME id appears in the <img src> and in that part's
151
+ * Content-ID, which is asserted separately below. */
152
+ const normCid = (s) => s.replace(/img\d+\.[a-z0-9]+@/g, "img.CID@");
153
+
154
+ let pass = 0;
155
+ for (const [name, o] of cases) {
156
+ let n = 0;
157
+ const got = buildMimeMessage({ ...o, unique: () => `B${n++}` }).raw;
158
+ const want = legacyBuild(o);
159
+ assert.strictEqual(normCid(got), normCid(want),
160
+ `MISMATCH in "${name}"\n--- new ---\n${got}\n--- old ---\n${want}`);
161
+ // cid self-consistency: every id occurs exactly twice (src + Content-ID)
162
+ const ids = [...got.matchAll(/img\d+\.[a-z0-9]+@[a-z0-9.]+/g)].map(m => m[0]);
163
+ for (const id of new Set(ids)) {
164
+ assert.strictEqual(ids.filter(x => x === id).length, 2,
165
+ `cid ${id} should appear in both the <img src> and its Content-ID in "${name}"`);
166
+ }
167
+ pass++;
168
+ }
169
+
170
+ // draft path
171
+ for (const [name, o] of [
172
+ ["draft html", { ...base, bodyHtml: "<p>draft</p>", draftId: "mailx-draft-1" }],
173
+ ["draft with bcc + alias From", { ...base, from: "Alias <alias@bob.ma>", bodyHtml: "<p>d</p>", cc: "c@x.com", bcc: "b@x.com", draftId: "mailx-draft-2" }],
174
+ ["draft non-ASCII recipient", { ...base, to: "Renée <r@x.com>", bodyHtml: "<p>d</p>", draftId: "mailx-draft-3" }],
175
+ ]) {
176
+ const got = buildMimeMessage({
177
+ ...o, simpleHtml: true, omitMessageId: true,
178
+ extraHeaders: [`X-Mailx-Draft-ID: ${o.draftId}`],
179
+ }).raw;
180
+ const want = legacyDraft(o);
181
+ assert.strictEqual(got, want, `MISMATCH in "${name}"\n--- new ---\n${got}\n--- old ---\n${want}`);
182
+ pass++;
183
+ }
184
+
185
+ // Structural guarantees that must hold regardless of the legacy comparison.
186
+ const withImg = buildMimeMessage({ ...base, bodyHtml: `<img src="data:image/png;base64,${PNG}">`, unique: (() => { let i = 0; return () => `B${i++}`; })() });
187
+ assert.match(withImg.raw, /multipart\/related/, "inline image must produce multipart/related");
188
+ assert.match(withImg.raw, /Content-ID: </, "inline image must carry a Content-ID");
189
+ assert.strictEqual(withImg.inlineImageCount, 1);
190
+ assert.ok(!/data:image\/png;base64/.test(withImg.raw), "no data: URI may survive into the wire format");
191
+ pass++;
192
+
193
+ const eai = buildMimeMessage({ ...base, from: "Bøb <bob@bob.ma>", to: "Renée <r@x.com>", subject: "Canada’s", bodyText: "t" });
194
+ for (const line of eai.raw.split("\r\n")) {
195
+ if (!line) break; // headers end at the blank line
196
+ assert.ok(!/[€-￿]/.test(line), `8-bit byte survived in header: ${line}`);
197
+ }
198
+ pass++;
199
+
200
+ console.log(`mime-build: ${pass} checks passed`);