@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 +14 -1
- package/package.json +3 -2
- package/test/encoded-word.test.mjs +51 -0
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
|
-
|
|
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.
|
|
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);
|