@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.
- package/index.d.ts +40 -0
- package/index.js +117 -0
- 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();
|