@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 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
- console.log(` [fetch] ${uids.length} UIDs since ${sinceUid} (newest first)`);
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. Without a "search starting" log, mailx
617
- // shows nothing for the entire SEARCH windowthe user thinks
618
- // nothing is happening. Bracket the SEARCH with logs so the silence
619
- // is at least labeled.
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
- console.log(` [search] SINCE ${since.toISOString().slice(0, 10)}${before ? ` BEFORE ${before.toISOString().slice(0, 10)}` : ""} — running...`);
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
- console.log(` [search] returned ${uids.length} UIDs in ${Date.now() - t0}ms`);
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
- console.log(` [fetch] ${allMessages.length}/${uids.length} messages (chunk ${chunkIndex})`);
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
- console.log(` [fetch] done — ${allMessages.length} messages in ${Date.now() - t0}ms`);
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
- console.log(` [fetch-latest] ${this.selectedMailbox || "?"}: sequence ${range} (${Math.min(n, exists)} most recent of ${exists})`);
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
- console.log(` [fetch-latest] done — ${streamed.length} messages in ${Date.now() - t0}ms`);
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
- 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.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);