@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 +42 -0
- package/index.js +118 -0
- package/mime-build.d.ts +74 -0
- package/mime-build.js +160 -0
- package/package.json +1 -1
- package/test/mime-build.test.mjs +200 -0
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();
|
package/mime-build.d.ts
ADDED
|
@@ -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
|
@@ -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`);
|