@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 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
- return unfolded.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_match, charset, encoding, text) => {
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 cs = charset.toLowerCase().replace(/^utf8$/, "utf-8");
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 new TextDecoder(cs).decode(bytes);
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 new TextDecoder(cs).decode(new Uint8Array(bytes));
421
+ return decoder.decode(new Uint8Array(bytes));
379
422
  }
380
423
  }
381
424
  catch {
382
- return text;
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.63",
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);