@bobfrankston/mailx-types 0.1.40 → 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";
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
@@ -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.40",
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`);