@bobfrankston/iflow-direct 0.1.63 → 0.1.65
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/imap-protocol.js +55 -5
- package/package.json +3 -2
- package/test/encoded-word.test.mjs +51 -0
package/imap-protocol.js
CHANGED
|
@@ -344,21 +344,64 @@ function unquote(s) {
|
|
|
344
344
|
return s.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
345
345
|
return s;
|
|
346
346
|
}
|
|
347
|
+
/** Build a TextDecoder for an encoded-word charset label, trying progressively
|
|
348
|
+
* looser readings of the label before giving up on UTF-8.
|
|
349
|
+
*
|
|
350
|
+
* TextDecoder throws RangeError on a label it doesn't know, and the caller's
|
|
351
|
+
* catch then returned the RAW encoded text — so ONE unrecognized label put
|
|
352
|
+
* `London_Startup_Is_Selling_Permanent=2C_Painless=2C_=22Stick-On=22` in the
|
|
353
|
+
* user's subject line (Bob 2026-08-02, NNSquad via mailman). Labels seen in
|
|
354
|
+
* the wild that TextDecoder rejects:
|
|
355
|
+
* `en_US.UTF-8` — a POSIX LOCALE (language_TERRITORY.CODESET), not a
|
|
356
|
+
* charset; the real charset is the codeset after the dot.
|
|
357
|
+
* `utf-8*en` — RFC 2231 language suffix appended to the charset.
|
|
358
|
+
* `utf8` — no hyphen.
|
|
359
|
+
* The raw label is tried FIRST so legitimate dotted names (`ansi_x3.4-1968`)
|
|
360
|
+
* are never mangled by the locale rule. Last resort is UTF-8, which decodes
|
|
361
|
+
* leniently (U+FFFD for bad bytes) rather than throwing — a couple of
|
|
362
|
+
* replacement characters beat a subject full of quoted-printable. */
|
|
363
|
+
function decoderFor(label) {
|
|
364
|
+
const raw = label.trim().toLowerCase();
|
|
365
|
+
const candidates = [raw];
|
|
366
|
+
const noLang = raw.split("*")[0]; // RFC 2231 suffix
|
|
367
|
+
if (noLang !== raw)
|
|
368
|
+
candidates.push(noLang);
|
|
369
|
+
const locale = noLang.match(/^[a-z]{1,8}(?:_[a-z0-9]{1,8})?\.(.+)$/);
|
|
370
|
+
if (locale)
|
|
371
|
+
candidates.push(locale[1]); // POSIX codeset
|
|
372
|
+
candidates.push(noLang.replace(/^utf[_]?8$/, "utf-8"));
|
|
373
|
+
for (const cs of candidates) {
|
|
374
|
+
try {
|
|
375
|
+
return new TextDecoder(cs);
|
|
376
|
+
}
|
|
377
|
+
catch { /* unknown label — try the next reading */ }
|
|
378
|
+
}
|
|
379
|
+
return new TextDecoder("utf-8");
|
|
380
|
+
}
|
|
347
381
|
/** Decode IMAP encoded-word (=?charset?encoding?text?=) */
|
|
348
382
|
function decodeImapString(s) {
|
|
349
383
|
if (!s)
|
|
350
384
|
return "";
|
|
351
385
|
// RFC 2047 §6.2: whitespace between adjacent encoded-words must be ignored
|
|
352
386
|
const unfolded = s.replace(/\?=\s+=\?/g, "?==?");
|
|
353
|
-
|
|
387
|
+
// Some mailers FOLD INSIDE an encoded-word, stranding the closing `?=` on
|
|
388
|
+
// the next line ("=?utf-8?Q?…_has_Shipped\r\n\t?="). RFC 2047 forbids it,
|
|
389
|
+
// but a JavaMail/hybris sender does it on every order mail in Bob's
|
|
390
|
+
// archive; the unterminated word then matched nothing at all and the raw
|
|
391
|
+
// `=?utf-8?Q?=EF=BB=BFYour_Order_has_Shipped` became the subject line.
|
|
392
|
+
// `[^?]*?` can't cross a `?`, so this only rejoins words whose terminator
|
|
393
|
+
// was separated by folding whitespace — a properly closed encoded-word
|
|
394
|
+
// never has whitespace before its `?=`.
|
|
395
|
+
const rejoined = unfolded.replace(/(=\?[^?]+\?[BQ]\?[^?]*?)\s+\?=/gi, "$1?=");
|
|
396
|
+
const decodedWords = rejoined.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_match, charset, encoding, text) => {
|
|
354
397
|
try {
|
|
355
|
-
const
|
|
398
|
+
const decoder = decoderFor(charset);
|
|
356
399
|
if (encoding.toUpperCase() === "B") {
|
|
357
400
|
const raw = atob(text);
|
|
358
401
|
const bytes = new Uint8Array(raw.length);
|
|
359
402
|
for (let j = 0; j < raw.length; j++)
|
|
360
403
|
bytes[j] = raw.charCodeAt(j);
|
|
361
|
-
return
|
|
404
|
+
return decoder.decode(bytes);
|
|
362
405
|
}
|
|
363
406
|
else {
|
|
364
407
|
// Quoted-printable: collect bytes then decode with charset
|
|
@@ -375,13 +418,20 @@ function decodeImapString(s) {
|
|
|
375
418
|
i++;
|
|
376
419
|
}
|
|
377
420
|
}
|
|
378
|
-
return
|
|
421
|
+
return decoder.decode(new Uint8Array(bytes));
|
|
379
422
|
}
|
|
380
423
|
}
|
|
381
424
|
catch {
|
|
382
|
-
|
|
425
|
+
// Only reachable now for malformed base64 (atob throws). Strip the
|
|
426
|
+
// Q-encoding's underscore-for-space at least, so the fallback isn't
|
|
427
|
+
// more wrong than it has to be.
|
|
428
|
+
return encoding.toUpperCase() === "Q" ? text.replace(/_/g, " ") : text;
|
|
383
429
|
}
|
|
384
430
|
});
|
|
431
|
+
// A BOM inside the encoded-word (`=EF=BB=BF…`) is an encoding marker the
|
|
432
|
+
// sender leaked into the text, not content — invisible, but it still sorts,
|
|
433
|
+
// searches and truncates as a character. Drop it.
|
|
434
|
+
return decodedWords.replace(/\uFEFF/g, ""); // U+FEFF
|
|
385
435
|
}
|
|
386
436
|
/** Tokenize a parenthesized IMAP list (top-level only) */
|
|
387
437
|
function tokenizeParenList(s) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/iflow-direct",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.ts",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
10
|
"watch": "tsc -watch",
|
|
11
|
-
"check": "tsc --noEmit"
|
|
11
|
+
"check": "tsc --noEmit",
|
|
12
|
+
"test": "node test/encoded-word.test.mjs"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"imap",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// RFC 2047 encoded-word decoding, exercised through parseEnvelope — an
|
|
2
|
+
// ENVELOPE response is what a client actually stores subjects from.
|
|
3
|
+
//
|
|
4
|
+
// Regression origin (Bob 2026-08-02): a mailman sender labelled its charset
|
|
5
|
+
// `en_US.UTF-8` — a POSIX locale, not a charset. TextDecoder rejected the
|
|
6
|
+
// label, the catch returned the RAW encoded text, and the subject line read
|
|
7
|
+
// `A London_Startup_Is_Selling_Permanent=2C_Painless=2C_=22Stick-On=22…`.
|
|
8
|
+
//
|
|
9
|
+
// Run: node test/encoded-word.test.mjs
|
|
10
|
+
import { parseEnvelope } from "../imap-protocol.js";
|
|
11
|
+
|
|
12
|
+
// IMAP quoting: only " and \ are escaped. JSON.stringify would ALSO escape the
|
|
13
|
+
// tab a folded header carries, and unquote() would then hand the decoder a
|
|
14
|
+
// literal "t" — the fixture has to look like what the wire really delivers.
|
|
15
|
+
const imapQuote = (s) => `"${s.replace(/([\\"])/g, "\\$1")}"`;
|
|
16
|
+
const env = (subject) =>
|
|
17
|
+
`("Sat, 1 Aug 2026 22:32:00 -0400" ${imapQuote(subject)} ` +
|
|
18
|
+
`(("Lauren Weinstein" NIL "lauren" "vortex.com")) NIL NIL ` +
|
|
19
|
+
`(("NNSquad" NIL "nnsquad" "vortex.com")) NIL NIL NIL "<x@y>")`;
|
|
20
|
+
|
|
21
|
+
const cases = [
|
|
22
|
+
// The reported one: POSIX locale as charset, three adjacent encoded-words.
|
|
23
|
+
["en_US.UTF-8 locale, adjacent words",
|
|
24
|
+
"[ NNSquad ] What could go wrong? - A =?en_US.UTF-8?Q?L?= =?en_US.UTF-8?Q?ondon_Startup_Is_Selling_Permanent=2C_Painless=2C_=22Stic?= =?en_US.UTF-8?Q?k-On=22_Tattoos=E2=80=94and?= It Wants to Do Mail Order",
|
|
25
|
+
"[ NNSquad ] What could go wrong? - A London Startup Is Selling Permanent, Painless, \"Stick-On\" Tattoos—and It Wants to Do Mail Order"],
|
|
26
|
+
["plain utf-8 Q", "=?utf-8?Q?caf=C3=A9_r=C3=A9sum=C3=A9?=", "café résumé"],
|
|
27
|
+
["utf8 (no hyphen)", "=?utf8?Q?na=C3=AFve?=", "naïve"],
|
|
28
|
+
["RFC 2231 language suffix", "=?utf-8*en?Q?hello_world?=", "hello world"],
|
|
29
|
+
["iso-8859-1 B", "=?iso-8859-1?B?SmVhbi1S6Q==?=", "Jean-Ré"],
|
|
30
|
+
["windows-1252 Q", "=?windows-1252?Q?=93quoted=94?=", "“quoted”"],
|
|
31
|
+
// Dotted label that is a REAL charset — the locale rule must not mangle it.
|
|
32
|
+
["dotted real charset (ansi_x3.4-1968)", "=?ansi_x3.4-1968?Q?plain_ascii?=", "plain ascii"],
|
|
33
|
+
["koi8-r B", "=?koi8-r?B?89DBzQ==?=", "Спам"],
|
|
34
|
+
// Fold INSIDE an encoded-word: the closing ?= sits on the next line, and
|
|
35
|
+
// the transport's literal handler drops the newline but keeps the leading
|
|
36
|
+
// whitespace — so the decoder sees "…Shipped\t?=". Also checks the BOM the
|
|
37
|
+
// sender encoded into the text is not left in the subject.
|
|
38
|
+
["terminator folded onto next line", "=?utf-8?Q?=EF=BB=BFYour_Order_has_Shipped\t?=", "Your Order has Shipped"],
|
|
39
|
+
["second word terminator folded", "=?UTF-8?Q?=EF=BB=BFThanks!_We_got_your_order_16536?=\t=?UTF-8?Q?3791 ?=", "Thanks! We got your order 165363791"],
|
|
40
|
+
["no encoded words", "just a plain subject", "just a plain subject"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
let fail = 0;
|
|
44
|
+
for (const [name, raw, want] of cases) {
|
|
45
|
+
const got = parseEnvelope(env(raw)).subject;
|
|
46
|
+
const ok = got === want;
|
|
47
|
+
if (!ok) fail++;
|
|
48
|
+
console.log(`${ok ? "✓" : "✗"} ${name}${ok ? "" : `\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`}`);
|
|
49
|
+
}
|
|
50
|
+
console.log(fail ? `\n${fail} FAILED` : `\nall ${cases.length} passed`);
|
|
51
|
+
process.exit(fail ? 1 : 0);
|