@bobfrankston/mailx-types 0.1.38 → 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.
Files changed (3) hide show
  1. package/index.d.ts +40 -0
  2. package/index.js +117 -0
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -363,6 +363,46 @@ export declare function sanitizeHtml(html: string): {
363
363
  html: string;
364
364
  hasRemoteContent: boolean;
365
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;
366
406
  /** Encode text as RFC 2045 quoted-printable. */
367
407
  export declare function encodeQuotedPrintable(text: string): string;
368
408
  /** Render an HTML document as a plain-text approximation suitable for the
package/index.js CHANGED
@@ -106,6 +106,123 @@ export function sanitizeHtml(html) {
106
106
  clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
107
107
  return { html: clean, hasRemoteContent };
108
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
+ }
109
226
  /** Encode text as RFC 2045 quoted-printable. */
110
227
  export function encodeQuotedPrintable(text) {
111
228
  const encoder = new TextEncoder();
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.40",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",