@bobfrankston/iflow-direct 0.1.64 → 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
@@ -384,7 +384,16 @@ function decodeImapString(s) {
384
384
  return "";
385
385
  // RFC 2047 §6.2: whitespace between adjacent encoded-words must be ignored
386
386
  const unfolded = s.replace(/\?=\s+=\?/g, "?==?");
387
- 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) => {
388
397
  try {
389
398
  const decoder = decoderFor(charset);
390
399
  if (encoding.toUpperCase() === "B") {
@@ -419,6 +428,10 @@ function decodeImapString(s) {
419
428
  return encoding.toUpperCase() === "Q" ? text.replace(/_/g, " ") : text;
420
429
  }
421
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
422
435
  }
423
436
  /** Tokenize a parenthesized IMAP list (top-level only) */
424
437
  function tokenizeParenList(s) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.64",
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);