@bobfrankston/mailx-types 0.1.36 → 0.1.40

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
@@ -10,6 +10,8 @@ export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } fr
10
10
  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
+ export { extractInlineImages } from "./mime-inline-images.js";
14
+ export type { InlineImagePart } from "./mime-inline-images.js";
13
15
  /** Supported authentication methods */
14
16
  export type AuthMethod = "password" | "oauth2";
15
17
  /** Mail account configuration */
@@ -361,6 +363,46 @@ export declare function sanitizeHtml(html: string): {
361
363
  html: string;
362
364
  hasRemoteContent: boolean;
363
365
  };
366
+ /**
367
+ * Encode a header VALUE as RFC 2047 encoded-words when it contains non-ASCII.
368
+ *
369
+ * Why this has to exist: a header carrying raw 8-bit UTF-8 makes the whole
370
+ * message an EAI message (RFC 6530), which can only be relayed over SMTPUTF8
371
+ * (RFC 6531). Plenty of receivers still don't advertise that extension, and a
372
+ * sending MTA that can't downgrade has no choice but to bounce. mailx built
373
+ * every outgoing header with bare interpolation — `Subject: ${msg.subject}` —
374
+ * so ONE curly apostrophe was enough to make a message undeliverable:
375
+ *
376
+ * Subject: Re: … One of Canada’s Fastest Fiber Networks (U+2019, raw UTF-8)
377
+ * → <alland@soundbytesradio.com>: EAI message but server 17.57.152.5
378
+ * does not support SMTPUTF8 … This is a permanent error
379
+ *
380
+ * (Bob 2026-08-08, qmail bounce from gal.iecc.com — iCloud, Zoho and
381
+ * land-com all refused it; herot.com, gmail and bob.ma took it, so it looked
382
+ * like a partial failure.) Reply subjects inherit whatever punctuation the
383
+ * original used, and Word/paste smart quotes do the rest, so this fires
384
+ * constantly and silently.
385
+ *
386
+ * Pure-ASCII values are returned untouched — encoding them would be legal but
387
+ * needlessly unreadable in every mail client's raw-source view.
388
+ *
389
+ * Base64 rather than Q-encoding: it is uniform (no per-character escaping
390
+ * rules that differ between phrase and unstructured context) and never
391
+ * produces a token needing further escaping. Chunks are split so each
392
+ * encoded-word stays inside RFC 2047's 75-character limit, split on CHARACTER
393
+ * boundaries so a multi-byte sequence is never cut in half.
394
+ */
395
+ export declare function encodeHeaderWord(text: string): string;
396
+ /**
397
+ * Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
398
+ * DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
399
+ * would corrupt the address, and a non-ASCII addr-spec is genuinely EAI and
400
+ * cannot be represented any other way.
401
+ *
402
+ * Splits on commas outside angle brackets and quotes so a display name
403
+ * containing a comma ("Frankston, Bob" <x@y>) survives.
404
+ */
405
+ export declare function encodeAddressHeader(value: string): string;
364
406
  /** Encode text as RFC 2045 quoted-printable. */
365
407
  export declare function encodeQuotedPrintable(text: string): string;
366
408
  /** Render an HTML document as a plain-text approximation suitable for the
package/index.js CHANGED
@@ -17,6 +17,9 @@ export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } fr
17
17
  // Reminder dismissed/snoozed cross-device state — one merge implementation
18
18
  // for the desktop service (mailx-settings) and Android/web (mailx-store-web).
19
19
  export { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual, } from "./reminder-state.js";
20
+ // Outgoing-mail inline images: data: URI → cid: extraction shared by both
21
+ // MIME assemblers (desktop send + Android/web send).
22
+ export { extractInlineImages } from "./mime-inline-images.js";
20
23
  // ── Message flag state ──
21
24
  //
22
25
  // External API surface for the IMAP system flags. The literal strings
@@ -103,6 +106,123 @@ export function sanitizeHtml(html) {
103
106
  clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
104
107
  return { html: clean, hasRemoteContent };
105
108
  }
109
+ /**
110
+ * Encode a header VALUE as RFC 2047 encoded-words when it contains non-ASCII.
111
+ *
112
+ * Why this has to exist: a header carrying raw 8-bit UTF-8 makes the whole
113
+ * message an EAI message (RFC 6530), which can only be relayed over SMTPUTF8
114
+ * (RFC 6531). Plenty of receivers still don't advertise that extension, and a
115
+ * sending MTA that can't downgrade has no choice but to bounce. mailx built
116
+ * every outgoing header with bare interpolation — `Subject: ${msg.subject}` —
117
+ * so ONE curly apostrophe was enough to make a message undeliverable:
118
+ *
119
+ * Subject: Re: … One of Canada’s Fastest Fiber Networks (U+2019, raw UTF-8)
120
+ * → <alland@soundbytesradio.com>: EAI message but server 17.57.152.5
121
+ * does not support SMTPUTF8 … This is a permanent error
122
+ *
123
+ * (Bob 2026-08-08, qmail bounce from gal.iecc.com — iCloud, Zoho and
124
+ * land-com all refused it; herot.com, gmail and bob.ma took it, so it looked
125
+ * like a partial failure.) Reply subjects inherit whatever punctuation the
126
+ * original used, and Word/paste smart quotes do the rest, so this fires
127
+ * constantly and silently.
128
+ *
129
+ * Pure-ASCII values are returned untouched — encoding them would be legal but
130
+ * needlessly unreadable in every mail client's raw-source view.
131
+ *
132
+ * Base64 rather than Q-encoding: it is uniform (no per-character escaping
133
+ * rules that differ between phrase and unstructured context) and never
134
+ * produces a token needing further escaping. Chunks are split so each
135
+ * encoded-word stays inside RFC 2047's 75-character limit, split on CHARACTER
136
+ * boundaries so a multi-byte sequence is never cut in half.
137
+ */
138
+ export function encodeHeaderWord(text) {
139
+ if (!text)
140
+ return "";
141
+ // eslint-disable-next-line no-control-regex
142
+ if (!/[^\x00-\x7F]/.test(text))
143
+ return text;
144
+ const encoder = new TextEncoder();
145
+ // "=?UTF-8?B?" + payload + "?=" must be ≤ 75 chars, so the base64 payload
146
+ // gets 75 - 12 = 63 chars, and base64 only splits cleanly every 4 chars →
147
+ // 60 chars of payload = 45 bytes of input per encoded-word.
148
+ const MAX_BYTES_PER_WORD = 45;
149
+ const words = [];
150
+ let chunk = [];
151
+ for (const ch of text) { // iterate by code point
152
+ const bytes = Array.from(encoder.encode(ch));
153
+ if (chunk.length + bytes.length > MAX_BYTES_PER_WORD) {
154
+ words.push(chunk);
155
+ chunk = [];
156
+ }
157
+ chunk.push(...bytes);
158
+ }
159
+ if (chunk.length)
160
+ words.push(chunk);
161
+ // Continuation lines are joined with CRLF + space: consecutive
162
+ // encoded-words separated by folding whitespace are concatenated by the
163
+ // receiver with the whitespace REMOVED (RFC 2047 §6.2), which is what we
164
+ // want — the original text had no space there.
165
+ return words
166
+ .map(b => `=?UTF-8?B?${bytesToBase64(Uint8Array.from(b))}?=`)
167
+ .join("\r\n ");
168
+ }
169
+ /** Base64 without assuming Buffer (this package is browser-capable). */
170
+ function bytesToBase64(bytes) {
171
+ let bin = "";
172
+ for (const b of bytes)
173
+ bin += String.fromCharCode(b);
174
+ // btoa exists in browsers and in Node ≥16.
175
+ return btoa(bin);
176
+ }
177
+ /**
178
+ * Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
179
+ * DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
180
+ * would corrupt the address, and a non-ASCII addr-spec is genuinely EAI and
181
+ * cannot be represented any other way.
182
+ *
183
+ * Splits on commas outside angle brackets and quotes so a display name
184
+ * containing a comma ("Frankston, Bob" <x@y>) survives.
185
+ */
186
+ export function encodeAddressHeader(value) {
187
+ if (!value)
188
+ return "";
189
+ // eslint-disable-next-line no-control-regex
190
+ if (!/[^\x00-\x7F]/.test(value))
191
+ return value;
192
+ const parts = [];
193
+ let cur = "";
194
+ let inAngle = false;
195
+ let inQuote = false;
196
+ for (const ch of value) {
197
+ if (ch === '"')
198
+ inQuote = !inQuote;
199
+ else if (ch === "<" && !inQuote)
200
+ inAngle = true;
201
+ else if (ch === ">" && !inQuote)
202
+ inAngle = false;
203
+ if (ch === "," && !inAngle && !inQuote) {
204
+ parts.push(cur);
205
+ cur = "";
206
+ continue;
207
+ }
208
+ cur += ch;
209
+ }
210
+ if (cur.trim())
211
+ parts.push(cur);
212
+ return parts.map(part => {
213
+ const p = part.trim();
214
+ const m = /^(.*?)\s*(<[^>]*>)\s*$/.exec(p);
215
+ if (!m)
216
+ return p; // bare addr-spec, nothing to encode
217
+ const display = m[1].replace(/^"(.*)"$/s, "$1");
218
+ if (!display)
219
+ return m[2];
220
+ // eslint-disable-next-line no-control-regex
221
+ if (!/[^\x00-\x7F]/.test(display))
222
+ return p; // ASCII name — leave the quoting alone
223
+ return `${encodeHeaderWord(display)} ${m[2]}`;
224
+ }).join(", ");
225
+ }
106
226
  /** Encode text as RFC 2045 quoted-printable. */
107
227
  export function encodeQuotedPrintable(text) {
108
228
  const encoder = new TextEncoder();
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Inline-image extraction for outgoing mail.
3
+ *
4
+ * The compose editors embed pasted/inlined images as `data:` URIs — that's
5
+ * what renders inside a WebView editor with no server round-trip. But a
6
+ * `data:` URI inside the text/html MIME part is NOT interoperable mail:
7
+ * Outlook desktop (Word renderer) doesn't render data: images at all, and
8
+ * Gmail's web sanitizer strips them, so recipients on the two biggest
9
+ * platforms see broken/missing images while Thunderbird/Apple Mail users see
10
+ * the message fine. The interoperable form is RFC 2392 `cid:` references to
11
+ * base64 image parts inside a multipart/related wrapper — what every mail
12
+ * client has emitted since the 90s.
13
+ *
14
+ * This helper rewrites `<img src="data:...;base64,...">` to `cid:` refs and
15
+ * hands back the extracted parts; the platform send() paths (desktop
16
+ * mailx-service and Android/web web-service) wrap them into
17
+ * multipart/related. Shared here (mailx-types, zero deps) so both MIME
18
+ * assemblers can't drift apart.
19
+ *
20
+ * Non-base64 data: URIs (utf8 SVG etc.) are left in place — rare, small,
21
+ * and rewriting them would mean re-encoding; the size/interop problem this
22
+ * solves comes from pasted photos, which are always base64.
23
+ */
24
+ export interface InlineImagePart {
25
+ /** Content-ID value WITHOUT angle brackets (use `<${contentId}>` in the header). */
26
+ contentId: string;
27
+ mime: string;
28
+ base64: string;
29
+ }
30
+ /** Pull base64 `data:` images out of the HTML, replacing each src with a
31
+ * `cid:` reference. Identical data: URIs (the same image pasted twice)
32
+ * collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
33
+ * msg-id-like uniqueness). */
34
+ export declare function extractInlineImages(html: string, idDomain: string): {
35
+ html: string;
36
+ images: InlineImagePart[];
37
+ };
38
+ //# sourceMappingURL=mime-inline-images.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline-image extraction for outgoing mail.
3
+ *
4
+ * The compose editors embed pasted/inlined images as `data:` URIs — that's
5
+ * what renders inside a WebView editor with no server round-trip. But a
6
+ * `data:` URI inside the text/html MIME part is NOT interoperable mail:
7
+ * Outlook desktop (Word renderer) doesn't render data: images at all, and
8
+ * Gmail's web sanitizer strips them, so recipients on the two biggest
9
+ * platforms see broken/missing images while Thunderbird/Apple Mail users see
10
+ * the message fine. The interoperable form is RFC 2392 `cid:` references to
11
+ * base64 image parts inside a multipart/related wrapper — what every mail
12
+ * client has emitted since the 90s.
13
+ *
14
+ * This helper rewrites `<img src="data:...;base64,...">` to `cid:` refs and
15
+ * hands back the extracted parts; the platform send() paths (desktop
16
+ * mailx-service and Android/web web-service) wrap them into
17
+ * multipart/related. Shared here (mailx-types, zero deps) so both MIME
18
+ * assemblers can't drift apart.
19
+ *
20
+ * Non-base64 data: URIs (utf8 SVG etc.) are left in place — rare, small,
21
+ * and rewriting them would mean re-encoding; the size/interop problem this
22
+ * solves comes from pasted photos, which are always base64.
23
+ */
24
+ /** Pull base64 `data:` images out of the HTML, replacing each src with a
25
+ * `cid:` reference. Identical data: URIs (the same image pasted twice)
26
+ * collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
27
+ * msg-id-like uniqueness). */
28
+ export function extractInlineImages(html, idDomain) {
29
+ const images = [];
30
+ const byDataUri = new Map(); // data-uri → contentId
31
+ const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
32
+ const out = html.replace(/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+))\2/gi, (_m, prefix, quote, dataUri, mime, b64) => {
33
+ let cid = byDataUri.get(dataUri);
34
+ if (!cid) {
35
+ cid = `img${images.length + 1}.${stamp}@${idDomain}`;
36
+ byDataUri.set(dataUri, cid);
37
+ // Strip whitespace the editor may have wrapped into the URI;
38
+ // the send path re-wraps at 76 cols per RFC 2045.
39
+ images.push({ contentId: cid, mime, base64: b64.replace(/\s+/g, "") });
40
+ }
41
+ return `${prefix}${quote}cid:${cid}${quote}`;
42
+ });
43
+ return { html: out, images };
44
+ }
45
+ //# sourceMappingURL=mime-inline-images.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.36",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",