@bobfrankston/iflow-direct 0.1.64 → 0.1.67
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-compat.d.ts +3 -0
- package/imap-compat.js +1 -1
- package/imap-native.d.ts +5 -1
- package/imap-native.js +48 -12
- package/imap-protocol.js +14 -1
- package/package.json +3 -2
- package/test/encoded-word.test.mjs +51 -0
package/imap-compat.d.ts
CHANGED
|
@@ -166,6 +166,9 @@ export declare class CompatImapClient {
|
|
|
166
166
|
notifySpec?: string;
|
|
167
167
|
onMailboxStatus?: (mailbox: string, data: proto.StatusData) => void;
|
|
168
168
|
onExpunge?: () => void;
|
|
169
|
+
/** A flag changed on a message in the watched mailbox — another
|
|
170
|
+
* client starred, read or unread something. */
|
|
171
|
+
onFlagChange?: (seq: number, flags: string[]) => void;
|
|
169
172
|
}): Promise<() => Promise<void>>;
|
|
170
173
|
/** Copy a message to another server (cross-account) */
|
|
171
174
|
moveMessageToServer(msg: any, fromMailbox: string, targetClient: CompatImapClient, toMailbox: string): Promise<void>;
|
package/imap-compat.js
CHANGED
|
@@ -340,7 +340,7 @@ export class CompatImapClient {
|
|
|
340
340
|
this.native.onMailboxStatus = opts.onMailboxStatus;
|
|
341
341
|
if (opts?.notifySpec)
|
|
342
342
|
await this.native.notify(opts.notifySpec);
|
|
343
|
-
return this.native.startIdle(onNew, opts?.onExpunge);
|
|
343
|
+
return this.native.startIdle(onNew, opts?.onExpunge, opts?.onFlagChange);
|
|
344
344
|
}
|
|
345
345
|
/** Copy a message to another server (cross-account) */
|
|
346
346
|
async moveMessageToServer(msg, fromMailbox, targetClient, toMailbox) {
|
package/imap-native.d.ts
CHANGED
|
@@ -120,6 +120,10 @@ export declare class NativeImapClient {
|
|
|
120
120
|
* a server-side deletion in real time instead of waiting for the next
|
|
121
121
|
* periodic poll. Distinct from `idleCallback` (new mail only). */
|
|
122
122
|
private idleExpungeCallback;
|
|
123
|
+
/** Fired for an unsolicited FETCH carrying FLAGS while parked in IDLE —
|
|
124
|
+
* another client (a phone, Thunderbird, webmail) changed a flag on a
|
|
125
|
+
* message in the selected mailbox. */
|
|
126
|
+
private idleFlagCallback;
|
|
123
127
|
private idleRefreshTimer;
|
|
124
128
|
/** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
|
|
125
129
|
* mailboxes (the server pushes these when the client has issued NOTIFY
|
|
@@ -264,7 +268,7 @@ export declare class NativeImapClient {
|
|
|
264
268
|
* SELECT and BEFORE startIdle — the server holds the spec for the
|
|
265
269
|
* lifetime of the connection. */
|
|
266
270
|
notify(spec: string): Promise<void>;
|
|
267
|
-
startIdle(onNewMail: (count: number) => void, onExpunge?: () => void): Promise<() => Promise<void>>;
|
|
271
|
+
startIdle(onNewMail: (count: number) => void, onExpunge?: () => void, onFlagChange?: (seq: number, flags: string[]) => void): Promise<() => Promise<void>>;
|
|
268
272
|
/**
|
|
269
273
|
* If IDLE is currently active, send DONE and wait for its tagged OK so the
|
|
270
274
|
* connection is free to accept a new command. Saves the active callback so
|
package/imap-native.js
CHANGED
|
@@ -52,6 +52,10 @@ export class NativeImapClient {
|
|
|
52
52
|
* a server-side deletion in real time instead of waiting for the next
|
|
53
53
|
* periodic poll. Distinct from `idleCallback` (new mail only). */
|
|
54
54
|
idleExpungeCallback = null;
|
|
55
|
+
/** Fired for an unsolicited FETCH carrying FLAGS while parked in IDLE —
|
|
56
|
+
* another client (a phone, Thunderbird, webmail) changed a flag on a
|
|
57
|
+
* message in the selected mailbox. */
|
|
58
|
+
idleFlagCallback = null;
|
|
55
59
|
idleRefreshTimer = null;
|
|
56
60
|
/** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
|
|
57
61
|
* mailboxes (the server pushes these when the client has issued NOTIFY
|
|
@@ -579,7 +583,8 @@ export class NativeImapClient {
|
|
|
579
583
|
if (uids.length === 0)
|
|
580
584
|
return [];
|
|
581
585
|
uids.reverse(); // Newest first
|
|
582
|
-
|
|
586
|
+
if (this.verbose)
|
|
587
|
+
console.log(` [fetch] ${uids.length} UIDs since ${sinceUid} (newest first)`);
|
|
583
588
|
if (uids.length <= this.fetchChunkSize) {
|
|
584
589
|
const msgs = await this.fetchMessages(uids.join(","), options);
|
|
585
590
|
if (onChunk)
|
|
@@ -613,14 +618,19 @@ export class NativeImapClient {
|
|
|
613
618
|
before: before || undefined,
|
|
614
619
|
});
|
|
615
620
|
// SEARCH SINCE on Dovecot can take minutes on a cold mailbox while it
|
|
616
|
-
// (re)builds its date index
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
621
|
+
// (re)builds its date index, and unlabelled silence reads as a hang.
|
|
622
|
+
// That is what the sendCommand heartbeat is for — "still waiting for
|
|
623
|
+
// tag N after 30.0s — SEARCH …", unconditional and generic — so this
|
|
624
|
+
// bracket does not have to fire on every call. It used to: one sync
|
|
625
|
+
// pass over ~90 folders wrote two lines per folder per cycle, and on
|
|
626
|
+
// Android every line is an HTTP request to the log server (Bob
|
|
627
|
+
// 2026-08-29: "are we overlogging"). Verbose keeps it for debugging.
|
|
620
628
|
const t0 = Date.now();
|
|
621
|
-
|
|
629
|
+
if (this.verbose)
|
|
630
|
+
console.log(` [search] SINCE ${since.toISOString().slice(0, 10)}${before ? ` BEFORE ${before.toISOString().slice(0, 10)}` : ""} — running...`);
|
|
622
631
|
const uids = await this.search(criteria);
|
|
623
|
-
|
|
632
|
+
if (this.verbose)
|
|
633
|
+
console.log(` [search] returned ${uids.length} UIDs in ${Date.now() - t0}ms`);
|
|
624
634
|
if (uids.length === 0)
|
|
625
635
|
return [];
|
|
626
636
|
// Reverse so newest messages (highest UIDs) come first
|
|
@@ -639,7 +649,8 @@ export class NativeImapClient {
|
|
|
639
649
|
// without spamming. The first-chunk log proves the fetch is alive
|
|
640
650
|
// even when subsequent chunks are slow.
|
|
641
651
|
if (chunkIndex === 1 || chunkIndex % 5 === 0) {
|
|
642
|
-
|
|
652
|
+
if (this.verbose)
|
|
653
|
+
console.log(` [fetch] ${allMessages.length}/${uids.length} messages (chunk ${chunkIndex})`);
|
|
643
654
|
}
|
|
644
655
|
if (onChunk)
|
|
645
656
|
onChunk(msgs);
|
|
@@ -647,7 +658,8 @@ export class NativeImapClient {
|
|
|
647
658
|
if (chunkSize < this.fetchChunkSizeMax)
|
|
648
659
|
chunkSize = Math.min(chunkSize * 4, this.fetchChunkSizeMax);
|
|
649
660
|
}
|
|
650
|
-
|
|
661
|
+
if (this.verbose)
|
|
662
|
+
console.log(` [fetch] done — ${allMessages.length} messages in ${Date.now() - t0}ms`);
|
|
651
663
|
return allMessages;
|
|
652
664
|
}
|
|
653
665
|
/** Fetch the most recent N messages by sequence number — avoids
|
|
@@ -663,7 +675,8 @@ export class NativeImapClient {
|
|
|
663
675
|
const start = Math.max(1, exists - n + 1);
|
|
664
676
|
const range = `${start}:${exists}`;
|
|
665
677
|
const t0 = Date.now();
|
|
666
|
-
|
|
678
|
+
if (this.verbose)
|
|
679
|
+
console.log(` [fetch-latest] ${this.selectedMailbox || "?"}: sequence ${range} (${Math.min(n, exists)} most recent of ${exists})`);
|
|
667
680
|
const items = ["UID", "FLAGS", "ENVELOPE", "RFC822.SIZE", "INTERNALDATE", "BODY.PEEK[HEADER]"];
|
|
668
681
|
if (options.source)
|
|
669
682
|
items.push("BODY.PEEK[]");
|
|
@@ -694,7 +707,8 @@ export class NativeImapClient {
|
|
|
694
707
|
// Reverse so newest-first matches fetchByDate ordering — caller code
|
|
695
708
|
// (mailx) expects that ordering when computing highestUid windows.
|
|
696
709
|
streamed.reverse();
|
|
697
|
-
|
|
710
|
+
if (this.verbose)
|
|
711
|
+
console.log(` [fetch-latest] done — ${streamed.length} messages in ${Date.now() - t0}ms`);
|
|
698
712
|
return streamed;
|
|
699
713
|
}
|
|
700
714
|
/** Fetch a single message by UID */
|
|
@@ -817,9 +831,10 @@ export class NativeImapClient {
|
|
|
817
831
|
}
|
|
818
832
|
}
|
|
819
833
|
// ── IDLE ──
|
|
820
|
-
async startIdle(onNewMail, onExpunge) {
|
|
834
|
+
async startIdle(onNewMail, onExpunge, onFlagChange) {
|
|
821
835
|
this.idleCallback = onNewMail;
|
|
822
836
|
this.idleExpungeCallback = onExpunge ?? null;
|
|
837
|
+
this.idleFlagCallback = onFlagChange ?? null;
|
|
823
838
|
this.idleStopped = false;
|
|
824
839
|
const beginIdleCycle = async () => {
|
|
825
840
|
const tag = proto.nextTag();
|
|
@@ -1416,6 +1431,27 @@ export class NativeImapClient {
|
|
|
1416
1431
|
this.idleExpungeCallback();
|
|
1417
1432
|
continue;
|
|
1418
1433
|
}
|
|
1434
|
+
// A flag changed in the SELECTED mailbox: the server sends an
|
|
1435
|
+
// unsolicited "* 1234 FETCH (FLAGS (\Seen \Flagged))" — this is
|
|
1436
|
+
// how starring a message on a phone reaches another client. It fell
|
|
1437
|
+
// through here and was dropped, so the desktop only learned about
|
|
1438
|
+
// it on its next periodic sync of that folder: measured on Bob's
|
|
1439
|
+
// own log, a median of 5 minutes and up to 20 (2026-08-29).
|
|
1440
|
+
if (this.idleTag && resp.tag === "*" && resp.type === "FETCH" && /FLAGS\s*\(/i.test(resp.text)) {
|
|
1441
|
+
if (this.idleFlagCallback) {
|
|
1442
|
+
const seq = parseInt(resp.text, 10);
|
|
1443
|
+
const inner = resp.text.match(/FLAGS\s*\(([^)]*)\)/i)?.[1] || "";
|
|
1444
|
+
const flags = inner.split(/\s+/).filter(Boolean);
|
|
1445
|
+
try {
|
|
1446
|
+
this.idleFlagCallback(Number.isFinite(seq) ? seq : 0, flags);
|
|
1447
|
+
}
|
|
1448
|
+
catch (err) {
|
|
1449
|
+
if (this.verbose)
|
|
1450
|
+
console.error(` [imap] onFlagChange threw: ${err.message}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1419
1455
|
// RFC 5465 NOTIFY: unsolicited STATUS responses for non-selected
|
|
1420
1456
|
// mailboxes arrive when the server has accepted a NOTIFY SET that
|
|
1421
1457
|
// included a PERSONAL (or other) event group. Only route to the
|
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.67",
|
|
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);
|