@bobfrankston/iflow-direct 0.1.65 → 0.1.68

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.65",
3
+ "version": "0.1.68",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",