@bobfrankston/rmfmail 1.0.570 → 1.0.590

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.
Files changed (54) hide show
  1. package/TODO.md +18 -3
  2. package/bin/build-rmfmailto-exe.js +83 -0
  3. package/bin/mailto.js +130 -0
  4. package/bin/mailto.js.map +1 -0
  5. package/bin/mailto.ts +122 -0
  6. package/bin/mailx.js +199 -3
  7. package/bin/mailx.js.map +1 -1
  8. package/bin/mailx.ts +188 -3
  9. package/bin/rmfmailto-src/Cargo.lock +92 -0
  10. package/bin/rmfmailto-src/Cargo.toml +20 -0
  11. package/bin/rmfmailto-src/build.rs +27 -0
  12. package/bin/rmfmailto-src/src/main.rs +143 -0
  13. package/bin/rmfmailto.exe +0 -0
  14. package/client/app.js +157 -0
  15. package/client/app.js.map +1 -1
  16. package/client/app.ts +157 -0
  17. package/client/components/alarms.js +5 -2
  18. package/client/components/alarms.js.map +1 -1
  19. package/client/components/alarms.ts +4 -2
  20. package/client/components/folder-tree.js +43 -2
  21. package/client/components/folder-tree.js.map +1 -1
  22. package/client/components/folder-tree.ts +40 -2
  23. package/client/lib/api-client.js +7 -0
  24. package/client/lib/api-client.js.map +1 -1
  25. package/client/lib/api-client.ts +11 -0
  26. package/client/lib/mailxapi.js +3 -0
  27. package/client/styles/components.css +40 -0
  28. package/package.json +14 -14
  29. package/packages/mailx-imap/index.d.ts +46 -14
  30. package/packages/mailx-imap/index.d.ts.map +1 -1
  31. package/packages/mailx-imap/index.js +527 -187
  32. package/packages/mailx-imap/index.js.map +1 -1
  33. package/packages/mailx-imap/index.ts +518 -172
  34. package/packages/mailx-imap/package-lock.json +2 -2
  35. package/packages/mailx-imap/package.json +1 -1
  36. package/packages/mailx-server/index.js +4 -4
  37. package/packages/mailx-server/index.js.map +1 -1
  38. package/packages/mailx-server/index.ts +4 -4
  39. package/packages/mailx-service/index.d.ts +14 -0
  40. package/packages/mailx-service/index.d.ts.map +1 -1
  41. package/packages/mailx-service/index.js +61 -8
  42. package/packages/mailx-service/index.js.map +1 -1
  43. package/packages/mailx-service/index.ts +59 -8
  44. package/packages/mailx-service/jsonrpc.js +2 -0
  45. package/packages/mailx-service/jsonrpc.js.map +1 -1
  46. package/packages/mailx-service/jsonrpc.ts +2 -0
  47. package/packages/mailx-service/reconciler.d.ts.map +1 -1
  48. package/packages/mailx-service/reconciler.js +16 -0
  49. package/packages/mailx-service/reconciler.js.map +1 -1
  50. package/packages/mailx-service/reconciler.ts +15 -0
  51. package/packages/mailx-store/db.js +2 -2
  52. package/packages/mailx-store/db.js.map +1 -1
  53. package/packages/mailx-store/db.ts +2 -2
  54. package/packages/mailx-store/package.json +1 -1
@@ -28,7 +28,12 @@ type OpsTask = () => Promise<void>;
28
28
  interface OpsQueue {
29
29
  fast: OpsTask[];
30
30
  slow: OpsTask[];
31
- running: boolean;
31
+ /** Whether the fast lane is currently draining a task. Independent of
32
+ * `runningSlow` — both lanes can drain concurrently because each uses
33
+ * its own connection (fastClient vs. opsClient). C123: the second
34
+ * connection is lazy-allocated on the first fast-lane task. */
35
+ runningFast: boolean;
36
+ runningSlow: boolean;
32
37
  }
33
38
  interface HostSemaphore {
34
39
  permits: number;
@@ -389,11 +394,18 @@ export class ImapManager extends EventEmitter {
389
394
  // No semaphore, no pool, no per-operation connect/disconnect.
390
395
  // IDLE uses a separate connection (see startWatching).
391
396
 
392
- /** Persistent operational connections — one per account, reused for all operations.
393
- * Body fetch, sync, prefetch, outbox-append, flag/move all serialize through
394
- * this single client per account via withConnection(). The priority lane
395
- * in the queue lets interactive clicks jump ahead of background prefetch. */
397
+ /** Persistent slow-lane operational connection per account. Used by sync,
398
+ * prefetch, outbox-append, large backfills, and any other "this might
399
+ * take a while" operation. */
396
400
  private opsClients = new Map<string, any>();
401
+ /** Lazy-allocated fast-lane client per account (C123). Lives alongside
402
+ * `opsClients` so click-time body fetches and flag toggles don't have
403
+ * to wait behind a multi-minute prefetch / backfill on the slow client.
404
+ * Created on the first fast-lane `withConnection` call; reused while
405
+ * alive; recreated on stale-detect or error-discard like opsClients.
406
+ * Costs +1 IMAP socket per active account, well under any reasonable
407
+ * per-user-IP cap (Dovecot's default is 20). */
408
+ private fastClients = new Map<string, any>();
397
409
  /** Two-lane operation queue per account — interactive ops (body fetch on
398
410
  * click, flag toggle) drain before background ops (sync, prefetch). FIFO
399
411
  * within each lane. The single ops connection means there's never a race
@@ -407,10 +419,15 @@ export class ImapManager extends EventEmitter {
407
419
  private hostSemaphores = new Map<string, HostSemaphore>();
408
420
  private static readonly HOST_PERMITS = 4;
409
421
 
410
- /** Get (or create) the persistent operational connection for an account.
411
- * logout() is wrapped as a no-op so legacy callers don't close it. */
412
- private async getOpsClient(accountId: string): Promise<any> {
413
- let client = this.opsClients.get(accountId);
422
+ /** Get (or create) a persistent connection for an account on the named
423
+ * lane. Two lanes today: `slow` (the original `opsClients` map sync,
424
+ * prefetch, backfill, outbox) and `fast` (the C123 `fastClients` map —
425
+ * click-time body fetch, flag toggles, anything user-driven). Same
426
+ * stale-detect + reconnect semantics on both. logout() is wrapped as a
427
+ * no-op so legacy callers don't close the persistent client. */
428
+ private async getLaneClient(accountId: string, lane: "fast" | "slow"): Promise<any> {
429
+ const map = lane === "fast" ? this.fastClients : this.opsClients;
430
+ let client = map.get(accountId);
414
431
  if (client) {
415
432
  // C38: health-check the cached client before returning. If the
416
433
  // underlying socket is dead (Dovecot silently dropped IDLE after
@@ -422,20 +439,27 @@ export class ImapManager extends EventEmitter {
422
439
  const dead = sock?.destroyed || sock?.readyState === "closed" || client?._dead;
423
440
  if (!dead) return client;
424
441
  try { await (client._realLogout || client.logout)(); } catch { /* */ }
425
- this.opsClients.delete(accountId);
426
- console.log(` [conn] ${accountId}: stale ops client detected in getOpsClient — reconnecting`);
442
+ map.delete(accountId);
443
+ console.log(` [conn] ${accountId}: stale ${lane} client detected — reconnecting`);
427
444
  client = undefined as any;
428
445
  }
429
- client = await this.newClient(accountId, "ops");
446
+ client = await this.newClient(accountId, lane === "fast" ? "fast" : "ops");
430
447
  // Wrap logout as no-op — this is a persistent connection. The
431
448
  // newClient wrapper's close-counter runs on `_realLogout`.
432
449
  const realLogout = client.logout.bind(client);
433
450
  client.logout = async () => { /* no-op — persistent connection */ };
434
451
  client._realLogout = realLogout;
435
- this.opsClients.set(accountId, client);
452
+ map.set(accountId, client);
436
453
  return client;
437
454
  }
438
455
 
456
+ /** Backwards-compat shim — callers outside the queue still ask for the
457
+ * ops client by historical name. New code should prefer withConnection
458
+ * with `slow:true` for slow operations. */
459
+ private async getOpsClient(accountId: string): Promise<any> {
460
+ return this.getLaneClient(accountId, "slow");
461
+ }
462
+
439
463
  /** Run an operation on the account's connection — queued, sequential, no concurrency */
440
464
  /** Run an operation against the account's single ops connection. Tasks
441
465
  * queue strictly sequentially per account — only one IMAP command in
@@ -457,12 +481,13 @@ export class ImapManager extends EventEmitter {
457
481
  ): Promise<T> {
458
482
  let queue = this.opsQueues.get(accountId);
459
483
  if (!queue) {
460
- queue = { fast: [], slow: [], running: false };
484
+ queue = { fast: [], slow: [], runningFast: false, runningSlow: false };
461
485
  this.opsQueues.set(accountId, queue);
462
486
  }
487
+ const lane: "fast" | "slow" = opts.slow ? "slow" : "fast";
463
488
  // Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
464
489
  // half-open, server stalled mid-FETCH) keeps the queue's running flag
465
- // set forever and every subsequent fast-lane task — including the
490
+ // set forever and every subsequent same-lane task — including the
466
491
  // retry button the user just hit — waits behind it. Default is
467
492
  // generous; callers driving user-visible reads pass a tighter value.
468
493
  const timeoutMs = opts.timeoutMs ?? 90_000;
@@ -470,23 +495,26 @@ export class ImapManager extends EventEmitter {
470
495
  const task: OpsTask = async () => {
471
496
  let timer: any;
472
497
  try {
473
- const client = await this.getOpsClient(accountId);
498
+ const client = await this.getLaneClient(accountId, lane);
474
499
  const result = await Promise.race<T>([
475
500
  fn(client),
476
501
  new Promise<T>((_, rej) => {
477
- timer = setTimeout(() => rej(new Error(`ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
502
+ timer = setTimeout(() => rej(new Error(`${lane}-ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
478
503
  }),
479
504
  ]);
480
505
  clearTimeout(timer);
481
506
  resolve(result);
482
507
  } catch (e: any) {
483
508
  clearTimeout(timer);
484
- // Discard client on any error — keeping a half-broken
485
- // socket poisoned every subsequent request. Destroy
486
- // synchronously kills the in-flight command's socket so
487
- // the underlying promise rejects and stops holding state.
488
- const stale = this.opsClients.get(accountId);
489
- this.opsClients.delete(accountId);
509
+ // Discard the lane's client on any error — a half-broken
510
+ // socket poisons every subsequent request on that lane.
511
+ // Don't touch the OTHER lane's client; the lanes are
512
+ // independent. Destroy synchronously kills the in-flight
513
+ // command's socket so the underlying promise rejects and
514
+ // stops holding state.
515
+ const map = lane === "fast" ? this.fastClients : this.opsClients;
516
+ const stale = map.get(accountId);
517
+ map.delete(accountId);
490
518
  if (stale) {
491
519
  try { await (stale._realLogout || stale.logout)(); } catch { /* */ }
492
520
  try { stale.destroy?.(); } catch { /* */ }
@@ -494,24 +522,31 @@ export class ImapManager extends EventEmitter {
494
522
  reject(e);
495
523
  }
496
524
  };
497
- (opts.slow ? queue!.slow : queue!.fast).push(task);
525
+ queue![lane].push(task);
498
526
  this.drainOpsQueue(accountId);
499
527
  });
500
528
  }
501
529
 
502
- /** Run the next queued task. Fast lane drains before slow.
503
- * Idempotentsafe to call after each task completes; the running
504
- * flag prevents reentrant draining. */
530
+ /** Run the next queued task on each lane. Fast and slow lanes drain
531
+ * CONCURRENTLYeach on its own connection (C123). FIFO within each
532
+ * lane. The running flag per lane prevents reentrant draining of the
533
+ * same lane. */
505
534
  private drainOpsQueue(accountId: string): void {
506
535
  const queue = this.opsQueues.get(accountId);
507
- if (!queue || queue.running) return;
508
- const next = queue.fast.shift() || queue.slow.shift();
509
- if (!next) return;
510
- queue.running = true;
511
- next().finally(() => {
512
- queue.running = false;
513
- this.drainOpsQueue(accountId);
514
- });
536
+ if (!queue) return;
537
+ const drainLane = (lane: "fast" | "slow"): void => {
538
+ const runningKey = lane === "fast" ? "runningFast" : "runningSlow";
539
+ if (queue[runningKey]) return;
540
+ const next = queue[lane].shift();
541
+ if (!next) return;
542
+ queue[runningKey] = true;
543
+ next().finally(() => {
544
+ queue[runningKey] = false;
545
+ drainLane(lane);
546
+ });
547
+ };
548
+ drainLane("fast");
549
+ drainLane("slow");
515
550
  }
516
551
 
517
552
  /** Acquire one slot of the per-host connection semaphore. Returns a release
@@ -559,7 +594,15 @@ export class ImapManager extends EventEmitter {
559
594
  const releaseHostSlot = await this.acquireHostSlot(host);
560
595
  let client: any;
561
596
  try {
562
- client = new CompatImapClient(config, this.transportFactory);
597
+ // Verbose IMAP wire trace for ops connections only — that's the
598
+ // lane where commands have been hanging silently with no
599
+ // heartbeat / wall-clock fire / reject. Need to see the actual
600
+ // commands sent and bytes received to pinpoint where the
601
+ // pendingCommand is getting lost. Fast lane (C123) shares the
602
+ // verbose treatment so click-time wedges show too. Other lanes
603
+ // (idle, quickCheck) stay quiet so the log doesn't drown.
604
+ const cfgWithVerbose = (purpose === "ops" || purpose === "fast") ? { ...config, verbose: true } : config;
605
+ client = new CompatImapClient(cfgWithVerbose, this.transportFactory);
563
606
  } catch (e) {
564
607
  releaseHostSlot();
565
608
  throw e;
@@ -593,14 +636,17 @@ export class ImapManager extends EventEmitter {
593
636
  return client;
594
637
  }
595
638
 
596
- /** Force-close every IMAP socket for an account — ops + any lingering
597
- * ones in openClients (e.g. an IDLE watcher in flight). Used during
598
- * account removal and disconnectOps so the server's connection slots
599
- * free immediately rather than waiting for socket idle timeouts. */
639
+ /** Force-close every IMAP socket for an account — both lane clients
640
+ * (ops + fast) plus any lingering ones in openClients (e.g. an IDLE
641
+ * watcher in flight). Used during account removal and disconnectOps
642
+ * so the server's connection slots free immediately rather than
643
+ * waiting for socket idle timeouts. */
600
644
  async closeAllClients(accountId: string): Promise<void> {
601
- const ops = this.opsClients.get(accountId);
602
- this.opsClients.delete(accountId);
603
- if (ops) { try { await (ops._realLogout || ops.logout)(); } catch { /* */ } try { ops.destroy?.(); } catch { /* */ } }
645
+ for (const map of [this.opsClients, this.fastClients]) {
646
+ const c = map.get(accountId);
647
+ map.delete(accountId);
648
+ if (c) { try { await (c._realLogout || c.logout)(); } catch { /* */ } try { c.destroy?.(); } catch { /* */ } }
649
+ }
604
650
  const open = this.openClients.get(accountId);
605
651
  if (open) {
606
652
  for (const c of Array.from(open)) {
@@ -611,19 +657,22 @@ export class ImapManager extends EventEmitter {
611
657
  }
612
658
  }
613
659
 
614
- /** Disconnect the persistent operational connection for an account */
660
+ /** Disconnect the persistent operational connections (both lanes) for
661
+ * an account. */
615
662
  async disconnectOps(accountId: string): Promise<void> {
616
- const client = this.opsClients.get(accountId);
617
- this.opsClients.delete(accountId);
618
- if (client) {
619
- // Force-close: don't wait for LOGOUT on a possibly dead socket
620
- try {
621
- const timeout = new Promise(r => setTimeout(r, 2000));
622
- await Promise.race([(client._realLogout || client.logout)(), timeout]);
623
- } catch { /* */ }
624
- // Destroy underlying socket if still open
625
- try { client.destroy?.(); } catch { /* */ }
626
- console.log(` [conn] ${accountId}: disconnected`);
663
+ for (const [name, map] of [["ops", this.opsClients], ["fast", this.fastClients]] as const) {
664
+ const client = map.get(accountId);
665
+ map.delete(accountId);
666
+ if (client) {
667
+ // Force-close: don't wait for LOGOUT on a possibly dead socket
668
+ try {
669
+ const timeout = new Promise(r => setTimeout(r, 2000));
670
+ await Promise.race([(client._realLogout || client.logout)(), timeout]);
671
+ } catch { /* */ }
672
+ // Destroy underlying socket if still open
673
+ try { client.destroy?.(); } catch { /* */ }
674
+ console.log(` [conn] ${accountId} (${name}): disconnected`);
675
+ }
627
676
  }
628
677
  }
629
678
 
@@ -915,6 +964,54 @@ export class ImapManager extends EventEmitter {
915
964
  return stored;
916
965
  }
917
966
 
967
+ /** Insert a local row for a message we just APPENDed (Sent / Drafts).
968
+ * Uses the source bytes we already have plus the UID returned by the
969
+ * server's APPENDUID response to build the local row directly — no
970
+ * IMAP SELECT/SEARCH/FETCH round-trip required. This is the user-
971
+ * visible fix for "I sent a message but it doesn't show up in mailx
972
+ * Sent until the broad sync finally completes" (which on slow servers
973
+ * can be minutes — and which Thunderbird sidesteps entirely because
974
+ * it inserts its own row at APPEND time too).
975
+ *
976
+ * Fires the same emits as a normal sync so the UI updates. */
977
+ async insertLocalRowFromSource(
978
+ accountId: string,
979
+ folder: { id: number; path: string },
980
+ uid: number,
981
+ source: string,
982
+ flags: string[],
983
+ ): Promise<void> {
984
+ const { simpleParser } = await import("mailparser");
985
+ const parsed = await simpleParser(source);
986
+ // Coerce mailparser AddressObject(s) into the flat `{name, address}[]`
987
+ // shape storeMessages's downstream toEmailAddresses expects.
988
+ const flat = (a: any): { name: string; address: string }[] => {
989
+ if (!a) return [];
990
+ if (Array.isArray(a)) return a.flatMap(x => x?.value || []);
991
+ return a.value || [];
992
+ };
993
+ const msg: any = {
994
+ uid,
995
+ messageId: parsed.messageId || "",
996
+ inReplyTo: parsed.inReplyTo || "",
997
+ date: parsed.date || new Date(),
998
+ subject: parsed.subject || "",
999
+ from: flat(parsed.from),
1000
+ to: flat(parsed.to),
1001
+ cc: flat(parsed.cc),
1002
+ seen: flags.includes("\\Seen"),
1003
+ flagged: flags.includes("\\Flagged"),
1004
+ answered: flags.includes("\\Answered"),
1005
+ draft: flags.includes("\\Draft"),
1006
+ source,
1007
+ size: source.length,
1008
+ };
1009
+ await this.storeMessages(accountId, folder.id, folder, [msg], 0);
1010
+ this.db.recalcFolderCounts(folder.id);
1011
+ this.emit("folderCountsChanged", accountId, {});
1012
+ console.log(` [local-insert] ${folder.path} UID ${uid}: ${parsed.subject || "(no subject)"} (no IMAP roundtrip)`);
1013
+ }
1014
+
918
1015
  /** Sync messages for a specific folder */
919
1016
  async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
920
1017
  if (!client) client = await this.getOpsClient(accountId);
@@ -926,19 +1023,75 @@ export class ImapManager extends EventEmitter {
926
1023
 
927
1024
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
928
1025
 
1026
+ // Top-of-syncFolder breadcrumb — fires BEFORE any async I/O so a
1027
+ // hang in STATUS or SELECT can be located by its absence of a
1028
+ // following completion log. Without this, "Sent never showed up
1029
+ // in the log" was indistinguishable from "Sent hadn't started"
1030
+ // versus "Sent's STATUS call is hung."
1031
+ const __sfStart = Date.now();
1032
+ console.log(` [sync-enter] ${accountId}/${folder.path} (folderId=${folderId})`);
1033
+
929
1034
  // Get the highest UID we already have for this folder. IMAP UIDs are
930
1035
  // monotonically increasing within a UIDVALIDITY (RFC 3501); a
931
1036
  // high-water mark is the right anchor for incremental fetch.
932
1037
  const highestUid = this.db.getHighestUid(accountId, folderId);
1038
+ const localCount = this.db.getMessageCount(accountId, folderId);
1039
+
1040
+ // STATUS-before-SELECT — fast O(1) command that returns UIDNEXT and
1041
+ // MESSAGES without touching the mailbox SELECT state. RFC 3501 §6.3.10.
1042
+ // This is the difference between "INBOX works, Sent doesn't" on this
1043
+ // server: INBOX's quickImapCheck uses STATUS first; non-INBOX folders
1044
+ // were going straight to SELECT every cycle, and SELECT on Sent has
1045
+ // been observed wedging this server's ops connection for minutes
1046
+ // (Thunderbird, which uses STATUS-before-SELECT, doesn't see this).
1047
+ // When the server's UIDNEXT-1 ≤ our highestUid AND MESSAGES count
1048
+ // matches our local count, NOTHING changed — skip SELECT entirely.
1049
+ // For first sync (highestUid = 0) skip the STATUS check and let the
1050
+ // existing first-sync path (sequence-FETCH the latest N) run.
1051
+ if (highestUid > 0) {
1052
+ try {
1053
+ console.log(` [sync-status] ${accountId}/${folder.path}: calling STATUS...`);
1054
+ const __statusT0 = Date.now();
1055
+ const status: any = await (client as any).native?.getStatus?.(folder.path)
1056
+ ?? await (client as any).getStatus?.(folder.path);
1057
+ console.log(` [sync-status] ${accountId}/${folder.path}: STATUS returned in ${Date.now() - __statusT0}ms`);
1058
+ if (status && typeof status.uidNext === "number") {
1059
+ const serverHighest = status.uidNext - 1;
1060
+ const noNewUids = serverHighest <= highestUid;
1061
+ const countsMatch = typeof status.messages === "number" && status.messages === localCount;
1062
+ if (noNewUids && countsMatch) {
1063
+ // Nothing new server-side AND counts match — full
1064
+ // sync would be a no-op. Skip SELECT/SEARCH/FETCH
1065
+ // entirely. This is the path Sent takes most of the
1066
+ // time after the optimistic-APPEND-insert (1.0.573)
1067
+ // already filed the just-sent row locally.
1068
+ console.log(` [sync] ${accountId}/${folder.path}: STATUS clean (uidNext=${status.uidNext} messages=${status.messages}) — skipping SELECT`);
1069
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1070
+ return 0;
1071
+ }
1072
+ // Counts differ or server has new UIDs — fall through to
1073
+ // the full sync path below, which knows how to reconcile.
1074
+ console.log(` [sync] ${accountId}/${folder.path}: STATUS uidNext=${status.uidNext} messages=${status.messages} local=${localCount}/${highestUid} — proceeding to SELECT`);
1075
+ }
1076
+ } catch (statusErr: any) {
1077
+ // STATUS failed — fall through to SELECT (some servers don't
1078
+ // support STATUS on the currently-SELECTed mailbox; per
1079
+ // RFC 3501 §6.3.10 it's actually allowed everywhere but a
1080
+ // misbehaving server might refuse). The fallback is the
1081
+ // pre-1.0.574 behavior, so worst case we lose the speedup.
1082
+ console.log(` [sync] ${accountId}/${folder.path}: STATUS failed (${statusErr?.message || statusErr}) — falling back to SELECT`);
1083
+ }
1084
+ }
933
1085
 
934
- // STATUS-before-SELECT was here. Removed — added a round-trip per
935
- // folder with no measured benefit on Bob's link, and the speculative
936
- // "skip SELECT when nothing changed" optimization didn't actually
937
- // skip anything in practice (server count vs local count nearly
938
- // always differs slightly because of in-flight deletes/moves).
939
1086
  console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
940
1087
 
941
1088
  let messages: any[];
1089
+ // Cache the server-UID list across set-diff and deletion-recon so we
1090
+ // don't pay for two `UID SEARCH` round-trips (the second one was
1091
+ // `UID SEARCH ALL` which on a 134k-message INBOX hangs for minutes
1092
+ // on this server). Set-diff populates this; deletion-recon reuses.
1093
+ let serverUidsCached: number[] | null = null;
1094
+ let serverUidsAreDateBounded = false;
942
1095
  const firstSync = highestUid === 0;
943
1096
  const historyDays = getHistoryDays(accountId);
944
1097
  // historyDays=0 means "all". On first sync we still cap at 30 days
@@ -957,58 +1110,130 @@ export class ImapManager extends EventEmitter {
957
1110
  // Filter out the last known message (IMAP * always returns at least one)
958
1111
  messages = fetched.filter((m: any) => m.uid > highestUid);
959
1112
 
960
- // Gap detection: check for missing UIDs within the range we've already synced
961
- // Only reconcile between our lowest and highest UID — don't try to fetch the entire folder history
1113
+ // Reconciliation by SET DIFFERENCE, not high-water-mark.
1114
+ //
1115
+ // The high-water-mark approach (fetch UIDs > highestUid) only
1116
+ // catches NEW arrivals. It cannot see anything below the local
1117
+ // lowest UID — so messages that exist on the server but predate
1118
+ // the user's first sync (or any other gap) are invisible forever.
1119
+ // The previous gap-fill clamped reconciliation to [lowestUid,
1120
+ // highestUid] which made that gap permanent.
1121
+ //
1122
+ // The right model: server UID set is the truth. Anything on
1123
+ // the server that's not local → fetch. Anything local that's
1124
+ // not on the server → reconcile-delete (handled separately by
1125
+ // the deferred-delete path, with grace + 50% safeguards).
1126
+ //
1127
+ // Bounded by a 5000-cap so a misbehaving server response or
1128
+ // brand-new account doesn't try to pull tens of thousands of
1129
+ // historical messages in one cycle. Hit the cap and the next
1130
+ // sync picks up the rest.
962
1131
  const existingUids = this.db.getUidsForFolder(accountId, folderId);
963
- if (existingUids.length > 0) {
964
- try {
965
- const lowestUid = Math.min(...existingUids);
966
- // Fetch UIDs in our known range from IMAP
967
- const rangeUids = await (client as any).getUids(folder.path);
968
- const rangeInScope = rangeUids.filter((uid: number) => uid >= lowestUid && uid <= highestUid);
969
- const existingSet = new Set(existingUids);
970
- const newSet = new Set(messages.map(m => m.uid));
971
- const missingUids = rangeInScope.filter((uid: number) => !existingSet.has(uid) && !newSet.has(uid));
972
- if (missingUids.length > 0 && missingUids.length <= 5000) {
973
- console.log(` ${folder.path}: gap detected ${missingUids.length} missing UIDs in range ${lowestUid}..${highestUid}`);
974
- const chunkSize = 500;
975
- let recoveredTotal = 0;
976
- for (let i = 0; i < missingUids.length; i += chunkSize) {
977
- const chunk = missingUids.slice(i, i + chunkSize);
978
- const range = chunk.join(",");
979
- const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
980
- messages.push(...recovered);
981
- recoveredTotal += recovered.length;
982
- console.log(` ${folder.path}: gap-fill ${recoveredTotal}/${missingUids.length}`);
983
- }
984
- } else if (missingUids.length > 5000) {
985
- console.log(` ${folder.path}: ${missingUids.length} missing UIDs too many, skipping reconciliation (delete DB to force full re-sync)`);
1132
+ try {
1133
+ // Date-bound the server UID query when we have a history
1134
+ // window. UID SEARCH ALL on a 134k-message INBOX returns
1135
+ // 134k integers and triggers a heavyweight full-folder
1136
+ // scan on the server; UID SEARCH SINCE <date> returns only
1137
+ // UIDs in the window we actually care about. For
1138
+ // historyDays=0 ("keep everything") we still bound to the
1139
+ // last 5 years on incremental syncs — enough that we never
1140
+ // miss an in-flight message, without enumerating decades of
1141
+ // archive every cycle. First sync gets all UIDs (no anchor
1142
+ // yet so we have to compare against the empty local set
1143
+ // anyway).
1144
+ const SINCE_DAYS = effectiveDays > 0 ? effectiveDays : 1825;
1145
+ const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1146
+ console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0,10)}...`);
1147
+ const __uidsT0 = Date.now();
1148
+ const allServerUids = typeof (client as any).getUidsSince === "function"
1149
+ ? await (client as any).getUidsSince(folder.path, sinceDate)
1150
+ : await (client as any).getUids(folder.path);
1151
+ console.log(` [sync-uids] ${accountId}/${folder.path}: ${allServerUids.length} UIDs in ${Date.now() - __uidsT0}ms`);
1152
+ // Stash for the deletion-reconciliation block below — we
1153
+ // already have the date-bounded server UID list, no point
1154
+ // hitting the server a second time with UID SEARCH ALL.
1155
+ serverUidsCached = allServerUids;
1156
+ serverUidsAreDateBounded = true;
1157
+ const existingSet = new Set(existingUids);
1158
+ const newSet = new Set(messages.map(m => m.uid));
1159
+ const missingUids = allServerUids.filter((uid: number) =>
1160
+ !existingSet.has(uid) && !newSet.has(uid)
1161
+ );
1162
+ // Backfill chunk size: ops queue yields between chunks so
1163
+ // a click-time body fetch can interleave. 100 UIDs per
1164
+ // chunk balances throughput (one FETCH command per chunk)
1165
+ // against latency (a chunk takes 1-3s on Dovecot, which
1166
+ // is the worst-case wait an interactive click endures).
1167
+ // The 500-chunk version held the ops queue for the
1168
+ // entire backfill — Bob 2026-05-08 saw a click-to-render
1169
+ // wait of 100+ minutes on a busy backfill of the IP
1170
+ // folder.
1171
+ const BACKFILL_CHUNK_SIZE = 100;
1172
+ if (missingUids.length > 0 && missingUids.length <= 5000) {
1173
+ // For the log line we report a count; computing
1174
+ // min/max via a spread (`Math.min(...arr)`) blows V8's
1175
+ // argument limit on folders with tens of thousands of
1176
+ // UIDs. Use a manual reduce.
1177
+ let minU = existingUids[0] ?? 0;
1178
+ for (let i = 1; i < existingUids.length; i++) if (existingUids[i] < minU) minU = existingUids[i];
1179
+ console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
1180
+ let recoveredTotal = 0;
1181
+ for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1182
+ const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1183
+ const range = chunk.join(",");
1184
+ // Each chunk gets its own withConnection slow-lane
1185
+ // turn so any fast-lane click queued in the
1186
+ // meantime gets serviced between chunks. The
1187
+ // outer `client` param is bypassed here; the
1188
+ // queue-managed client is the same persistent
1189
+ // ops client (getOpsClient).
1190
+ const recovered = await this.withConnection(accountId, async (c) =>
1191
+ await (c as any).fetchMessages(folder.path, range, { source: false }),
1192
+ { slow: true },
1193
+ );
1194
+ messages.push(...recovered);
1195
+ recoveredTotal += recovered.length;
1196
+ console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
986
1197
  }
987
- } catch (e: any) {
988
- console.error(` ${folder.path}: gap detection failed: ${e.message}`);
989
- }
990
- }
991
-
992
- // Backfill: if the history window reaches further back than our
993
- // oldest local message, fetch the gap. Chunk 90 days per sync
994
- // cycle so historyDays=0 catches up incrementally instead of
995
- // asking Dovecot for SEARCH SINCE 1970 in one go.
996
- const oldestDate = this.db.getOldestDate(accountId, folderId);
997
- if (oldestDate > 0 && startDate.getTime() < oldestDate) {
998
- try {
999
- const CHUNK_MS = 90 * 86400000;
1000
- const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
1001
- const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
1002
- const backfill = await client.fetchMessageByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
1003
- const newBackfill = backfill.filter((m: any) => !existingUids.has(m.uid));
1004
- if (newBackfill.length > 0) {
1005
- console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0,10)} → ${new Date(oldestDate).toISOString().slice(0,10)})`);
1006
- messages.push(...newBackfill);
1198
+ } else if (missingUids.length > 5000) {
1199
+ console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
1200
+ // ASSUMPTION: vanilla IMAP under stable UIDVALIDITY,
1201
+ // higher UID = later assignment ≈ more recent message.
1202
+ // True for Dovecot / Cyrus / standard IMAP, which is the
1203
+ // only place this code path runs (Gmail-API mode goes
1204
+ // through syncAccountViaApi and doesn't reach here
1205
+ // its synthesized hash-UIDs have no temporal meaning).
1206
+ // If we ever wire this for a non-monotonic UID source,
1207
+ // sort by date instead — but that means an extra
1208
+ // fetch-of-INTERNALDATE round-trip, which we avoid for
1209
+ // free here under the IMAP guarantee.
1210
+ const cappedSlice = missingUids.sort((a: number, b: number) => b - a).slice(0, 5000);
1211
+ let recoveredTotal = 0;
1212
+ for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1213
+ const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
1214
+ const recovered = await this.withConnection(accountId, async (c) =>
1215
+ await (c as any).fetchMessages(folder.path, chunk.join(","), { source: false }),
1216
+ { slow: true },
1217
+ );
1218
+ messages.push(...recovered);
1219
+ recoveredTotal += recovered.length;
1220
+ console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
1007
1221
  }
1008
- } catch (e: any) {
1009
- console.error(` ${folder.path}: backfill failed: ${e.message}`);
1010
1222
  }
1223
+ } catch (e: any) {
1224
+ console.error(` ${folder.path}: reconciliation failed: ${e.message}`);
1011
1225
  }
1226
+
1227
+ // Date-based backfill via SEARCH SINCE was here. Removed —
1228
+ // set-diff above already covers the same case (any server UID
1229
+ // not in our local set gets fetched, regardless of whether it's
1230
+ // above or below our local highest/lowest). SEARCH SINCE on a
1231
+ // large Dovecot folder walks INTERNALDATE on every message and
1232
+ // can take many minutes, which was wedging the whole syncFolder
1233
+ // call AFTER set-diff had succeeded — preventing syncAccount
1234
+ // from ever moving past INBOX to Sent / other folders. Don't
1235
+ // need both, and date-based-with-high-water-mark is exactly
1236
+ // the brittle model the set-diff replaces.
1012
1237
  } else {
1013
1238
  // First sync: fetch in chunks, store each chunk immediately for instant UI
1014
1239
  let totalStored = 0;
@@ -1134,6 +1359,16 @@ export class ImapManager extends EventEmitter {
1134
1359
  [folderId]: { total: folderInfo?.totalCount || 0, unread: folderInfo?.unreadCount || 0 }
1135
1360
  });
1136
1361
  }
1362
+
1363
+ // Yield to the event loop between batches so a pending IPC
1364
+ // (e.g. user clicked a message) gets dispatched. Without this
1365
+ // yield, a 5000-message store loop blocked Node's event loop
1366
+ // for ~5s of synchronous SQLite work — clicks fired during
1367
+ // that window felt frozen because the getMessage IPC sat in
1368
+ // stdin until the loop finished. setImmediate runs after I/O
1369
+ // callbacks, which means stdin readable events fire first and
1370
+ // get serviced.
1371
+ await new Promise<void>(r => setImmediate(r));
1137
1372
  }
1138
1373
  if (newCount > 0) console.log(` stored ${newCount} new messages`);
1139
1374
 
@@ -1150,12 +1385,30 @@ export class ImapManager extends EventEmitter {
1150
1385
  let deletedCount = 0;
1151
1386
  if (!firstSync) {
1152
1387
  try {
1153
- const serverUidsArr = await client.getUids(folder.path);
1388
+ // Reuse the server UID list set-diff already fetched.
1389
+ // Without this we made TWO `UID SEARCH` calls per folder
1390
+ // per sync — the first date-bounded (set-diff), the second
1391
+ // `UID SEARCH ALL` (this block). The second call hung
1392
+ // indefinitely on Bob's 134k-message INBOX, blocking the
1393
+ // ops worker and preventing Sent / other folders from
1394
+ // ever getting their turn.
1395
+ const serverUidsArr = serverUidsCached ?? await client.getUids(folder.path);
1154
1396
  const serverUids = new Set(serverUidsArr);
1155
- const localUids = this.db.getUidsForFolder(accountId, folderId);
1397
+ const localUidsAll = this.db.getUidsForFolder(accountId, folderId);
1398
+ // When the server-UID list is date-bounded, we can only
1399
+ // reason about deletions for local UIDs in the same window.
1400
+ // UIDs older than the window's lowest server UID may or may
1401
+ // not still be on the server — we never asked. Treat them
1402
+ // as out-of-scope rather than deletion candidates.
1403
+ let localUids = localUidsAll;
1404
+ if (serverUidsAreDateBounded && serverUidsArr.length > 0) {
1405
+ let minServerUid = serverUidsArr[0];
1406
+ for (let i = 1; i < serverUidsArr.length; i++) if (serverUidsArr[i] < minServerUid) minServerUid = serverUidsArr[i];
1407
+ localUids = localUidsAll.filter(u => u >= minServerUid);
1408
+ }
1156
1409
  const toDelete = localUids.filter(uid => !serverUids.has(uid));
1157
- if (serverUidsArr.length === 0 && localUids.length > 0) {
1158
- console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUids.length} (treating as transient)`);
1410
+ if (serverUidsArr.length === 0 && localUidsAll.length > 0) {
1411
+ console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
1159
1412
  } else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1160
1413
  console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1161
1414
  } else if (toDelete.length > 0) {
@@ -1357,14 +1610,24 @@ export class ImapManager extends EventEmitter {
1357
1610
  const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
1358
1611
  const highestUid = this.db.getHighestUid(accountId, folder.id);
1359
1612
  if (isTrashChild && highestUid === 0) return;
1613
+ let fresh: any = null;
1360
1614
  try {
1361
- const fresh = await this.getOpsClient(accountId);
1615
+ fresh = await this.getOpsClient(accountId);
1362
1616
  await Promise.race([
1363
1617
  this.syncFolder(accountId, folder.id, fresh),
1364
- new Promise((_, reject) => setTimeout(
1365
- () => reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}`)),
1366
- PER_FOLDER_TIMEOUT_MS
1367
- )),
1618
+ new Promise((_, reject) => setTimeout(() => {
1619
+ // C120: pull TCP transport diagnostics into the
1620
+ // per-folder timeout error so the [sync] log
1621
+ // distinguishes "server stopped responding"
1622
+ // (sinceLastRead high) from "we never finished
1623
+ // writing" (writes climbing without reads). Same
1624
+ // shape iflow-direct emits on its own timeouts.
1625
+ const d = (fresh as any)?.transport?.diagnostics;
1626
+ const diag = d
1627
+ ? ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms]`
1628
+ : "";
1629
+ reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}${diag}`));
1630
+ }, PER_FOLDER_TIMEOUT_MS)),
1368
1631
  ]);
1369
1632
  } catch (e: any) {
1370
1633
  if (e.responseText?.includes("doesn't exist")) {
@@ -2255,13 +2518,18 @@ export class ImapManager extends EventEmitter {
2255
2518
  }
2256
2519
  }
2257
2520
  await Promise.all(pending);
2258
- // CRITICAL: only prune as "server-deleted" when the batch
2259
- // actually completed. If the batch threw (403, 429, network
2260
- // error, etc.) NOTHING was received, and treating every
2261
- // requested UID as deleted silently wipes 100 messages per
2262
- // batch. That's a data-loss bug. Earlier version did this
2263
- // and pruned 296 messages on a 403 auth error.
2264
- if (batchSucceeded) {
2521
+ // Prune missing UIDs only when the batch actually
2522
+ // completed AND returned at least one body. Earlier
2523
+ // guards (don't-prune-on-thrown-batch) handled 403 /
2524
+ // 429 / network errors but not the "successful but
2525
+ // empty" case a Gmail batch endpoint that returns
2526
+ // an HTTP 200 with no inner messages, a parser miss,
2527
+ // or a transient that didn't bubble. Same data-loss
2528
+ // pattern: 0 received → all UIDs pruned. The
2529
+ // set-diff reconcile in syncFolder/syncAccountViaApi
2530
+ // owns deletion via a 30-min grace; defer to it.
2531
+ const someReceived = received.size > 0;
2532
+ if (batchSucceeded && someReceived) {
2265
2533
  for (const uid of uidsInFolder) {
2266
2534
  if (received.has(uid)) continue;
2267
2535
  try {
@@ -2271,6 +2539,8 @@ export class ImapManager extends EventEmitter {
2271
2539
  madeProgress = true;
2272
2540
  } catch { /* ignore */ }
2273
2541
  }
2542
+ } else if (batchSucceeded && !someReceived) {
2543
+ console.error(` [prefetch] ${accountId}/${folder.path}: Gmail batch returned 0/${uidsInFolder.length} bodies — NOT pruning (set-diff reconcile owns deletion). UIDs: ${uidsInFolder.slice(0, 5).join(",")}${uidsInFolder.length > 5 ? "..." : ""}`);
2274
2544
  }
2275
2545
  if (counters.errors >= ERROR_BUDGET) break;
2276
2546
  }
@@ -2303,6 +2573,17 @@ export class ImapManager extends EventEmitter {
2303
2573
  const bi = bf?.specialUse === "inbox" ? 0 : 1;
2304
2574
  return ai - bi;
2305
2575
  });
2576
+ // PREFETCH_CHUNK_SIZE: how many UIDs prefetch holds the ops
2577
+ // connection for before yielding. Smaller = interactive
2578
+ // clicks see less wait when prefetch is busy; larger =
2579
+ // fewer round-trips and less per-chunk SELECT overhead.
2580
+ // 25 sits at the knee of that curve for Dovecot — one
2581
+ // FETCH command per chunk (iflow's `fetchChunkSize: 10`
2582
+ // makes that 3 sub-FETCHes), then we release the queue.
2583
+ // Bob 2026-05-08: a 33-UID prefetch held the queue for
2584
+ // 100 minutes during which every click waited; chunking
2585
+ // gives clicks a window roughly every 5-30 seconds.
2586
+ const PREFETCH_CHUNK_SIZE = 25;
2306
2587
  for (const [folderId, uids] of orderedFolders) {
2307
2588
  const folder = folders.find(f => f.id === folderId);
2308
2589
  if (!folder) continue;
@@ -2310,51 +2591,72 @@ export class ImapManager extends EventEmitter {
2310
2591
  console.log(` [prefetch] ${accountId}: skipping ${folder.path} (recent timeouts — cooling down)`);
2311
2592
  continue;
2312
2593
  }
2313
- const received = new Set<number>();
2314
- let batchSucceeded = false;
2315
- try {
2316
- // Slow lane: prefetch is the textbook "this might take
2317
- // a while" case — let interactive ops slip ahead.
2318
- await this.withConnection(accountId, async (client) => {
2319
- const pending: Promise<void>[] = [];
2320
- await client.fetchBodiesBatch(folder.path, uids, (uid: number, source: string) => {
2321
- received.add(uid);
2322
- pending.push((async () => {
2323
- try {
2324
- const raw = Buffer.from(source, "utf-8");
2325
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2326
- this.db.updateBodyPath(accountId, uid, bodyPath);
2327
- this.emit("bodyCached", accountId, uid);
2328
- counters.totalFetched++;
2329
- madeProgress = true;
2330
- } catch (e: any) {
2331
- console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
2332
- }
2333
- })());
2334
- });
2335
- await Promise.all(pending);
2336
- }, { slow: true });
2337
- batchSucceeded = true;
2338
- this.clearFolderErrors(accountId, folder.path);
2339
- } catch (e: any) {
2340
- const msg = String(e?.message || "");
2341
- console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${msg}`);
2342
- counters.errors++;
2343
- this.recordFolderError(accountId, folder.path);
2344
- if (counters.errors >= ERROR_BUDGET) break;
2345
- }
2346
- // CRITICAL: only prune when the batch actually completed.
2347
- // A thrown batch means NOTHING was received; treating
2348
- // absence as server-deletion lost 296 messages once.
2349
- if (batchSucceeded) for (const uid of uids) {
2350
- if (received.has(uid)) continue;
2594
+ for (let chunkStart = 0; chunkStart < uids.length; chunkStart += PREFETCH_CHUNK_SIZE) {
2595
+ const chunk = uids.slice(chunkStart, chunkStart + PREFETCH_CHUNK_SIZE);
2596
+ const received = new Set<number>();
2597
+ let batchSucceeded = false;
2351
2598
  try {
2352
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2353
- this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID assumed deleted", "mailx-imap prefetchBodies (IMAP batch)");
2354
- counters.deleted++;
2355
- madeProgress = true;
2356
- } catch { /* ignore */ }
2599
+ // Slow lane: prefetch is the textbook "this
2600
+ // might take a while" caselet interactive
2601
+ // ops slip ahead. Each chunk is its own
2602
+ // withConnection so the queue drains the
2603
+ // fast lane between chunks instead of holding
2604
+ // the connection through the whole folder.
2605
+ await this.withConnection(accountId, async (client) => {
2606
+ const pending: Promise<void>[] = [];
2607
+ await client.fetchBodiesBatch(folder.path, chunk, (uid: number, source: string) => {
2608
+ received.add(uid);
2609
+ pending.push((async () => {
2610
+ try {
2611
+ const raw = Buffer.from(source, "utf-8");
2612
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2613
+ this.db.updateBodyPath(accountId, uid, bodyPath);
2614
+ this.emit("bodyCached", accountId, uid);
2615
+ counters.totalFetched++;
2616
+ madeProgress = true;
2617
+ } catch (e: any) {
2618
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
2619
+ }
2620
+ })());
2621
+ });
2622
+ await Promise.all(pending);
2623
+ }, { slow: true });
2624
+ batchSucceeded = true;
2625
+ this.clearFolderErrors(accountId, folder.path);
2626
+ } catch (e: any) {
2627
+ const msg = String(e?.message || "");
2628
+ console.error(` [prefetch] ${accountId} folder ${folder.path} chunk ${chunkStart / PREFETCH_CHUNK_SIZE}: batch fetch failed: ${msg}`);
2629
+ counters.errors++;
2630
+ this.recordFolderError(accountId, folder.path);
2631
+ if (counters.errors >= ERROR_BUDGET) break;
2632
+ }
2633
+ // Prune missing UIDs only when the batch actually
2634
+ // completed AND returned at least one body. A
2635
+ // "successful but empty" batch (FETCH parser
2636
+ // missed every literal, wrong-folder selected,
2637
+ // mid-stream connection hiccup that didn't
2638
+ // throw) is indistinguishable from "all UIDs
2639
+ // were deleted server-side" without that signal
2640
+ // — and erring toward delete cost Bob ~66
2641
+ // messages on 2026-05-08 (`prefetch] bobma: 0
2642
+ // bodies cached, 66 stale rows pruned`). The
2643
+ // set-diff reconcile in syncFolder is the
2644
+ // authoritative deletion path with a 30-min
2645
+ // grace window; prefetch defers to it.
2646
+ const someReceived = received.size > 0;
2647
+ if (batchSucceeded && someReceived) for (const uid of chunk) {
2648
+ if (received.has(uid)) continue;
2649
+ try {
2650
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2651
+ this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID — assumed deleted", "mailx-imap prefetchBodies (IMAP batch)");
2652
+ counters.deleted++;
2653
+ madeProgress = true;
2654
+ } catch { /* ignore */ }
2655
+ } else if (batchSucceeded && !someReceived) {
2656
+ console.error(` [prefetch] ${accountId}/${folder.path}: chunk ${chunkStart}-${chunkStart + chunk.length - 1} returned 0/${chunk.length} bodies — NOT pruning (set-diff reconcile owns deletion). UIDs: ${chunk.slice(0, 5).join(",")}${chunk.length > 5 ? "..." : ""}`);
2657
+ }
2357
2658
  }
2659
+ if (counters.errors >= ERROR_BUDGET) break;
2358
2660
  }
2359
2661
  if (counters.errors >= ERROR_BUDGET) {
2360
2662
  console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
@@ -3019,6 +3321,20 @@ export class ImapManager extends EventEmitter {
3019
3321
  // retry. Foreign hosts are left alone — we have no way to know if their
3020
3322
  // process is alive. Cross-host stale recovery is the IMAP-folder path's
3021
3323
  // job (sweeper looks at server-side claim flags, not local files).
3324
+ // Stale-claim recovery. A claim is "stale" if any of:
3325
+ // (a) the PID is dead — original owner crashed mid-send
3326
+ // (b) the PID is alive BUT it's not us, and the file mtime is
3327
+ // older than STALE_CLAIM_MS — the OS recycled the PID for
3328
+ // some other process. `process.kill(pid, 0)` returning success
3329
+ // only proves *some* process owns that PID, not that it's
3330
+ // our long-dead mailx daemon. Without the age guard, a
3331
+ // claim survives forever as soon as any other Node process
3332
+ // (statusline, msger, npm) gets the recycled PID. Bob saw
3333
+ // this exact case: `.sending-rmf39-63196` sat in the queue
3334
+ // for 7+ hours because PID 63196 was now an unrelated Node.
3335
+ // (c) it's our PID — never sweep our own claim.
3336
+ const STALE_CLAIM_MS = 3600_000;
3337
+ const myPid = process.pid;
3022
3338
  for (const dir of [outboxDir, queuedDir]) {
3023
3339
  if (!fs.existsSync(dir)) continue;
3024
3340
  for (const f of fs.readdirSync(dir)) {
@@ -3027,12 +3343,19 @@ export class ImapManager extends EventEmitter {
3027
3343
  const [, original, host, pidStr] = m;
3028
3344
  if (host !== this.hostname) continue;
3029
3345
  const pid = parseInt(pidStr);
3346
+ if (pid === myPid) continue; // it's us
3030
3347
  let alive = false;
3031
3348
  try { process.kill(pid, 0); alive = true; } catch { /* dead */ }
3032
- if (alive) continue; // live claim — owner (sibling or self) still has it
3349
+ let ageMs = Infinity;
3350
+ try { ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs; } catch { /* */ }
3351
+ // Live PID + recent mtime → assume genuine sibling owner.
3352
+ // Live PID + ancient mtime → PID got recycled, sweep it.
3353
+ // Dead PID → sweep regardless of age.
3354
+ if (alive && ageMs < STALE_CLAIM_MS) continue;
3033
3355
  try {
3034
3356
  fs.renameSync(path.join(dir, f), path.join(dir, original));
3035
- console.log(` [outbox] Recovered stale claim ${f} ${original}`);
3357
+ const reason = alive ? `recycled PID, mtime ${Math.round(ageMs / 60_000)}m old` : "dead PID";
3358
+ console.log(` [outbox] Recovered stale claim ${f} → ${original} (${reason})`);
3036
3359
  } catch { /* ignore */ }
3037
3360
  }
3038
3361
  }
@@ -3324,14 +3647,31 @@ export class ImapManager extends EventEmitter {
3324
3647
  await client.deleteMessageByUid(outboxFolder.path, uid);
3325
3648
  }, { slow: true });
3326
3649
  if (sentFolder) {
3650
+ let appendedSentUid: number | null = null;
3327
3651
  try {
3328
3652
  await this.withConnection(accountId, async (client) => {
3329
- await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
3653
+ appendedSentUid = await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
3330
3654
  }, { slow: true });
3331
- this.syncFolder(accountId, sentFolder.id).catch(() => {});
3332
3655
  } catch (sentErr: any) {
3333
3656
  console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
3334
3657
  }
3658
+ if (appendedSentUid != null) {
3659
+ // The server's APPENDUID response gave us the exact UID
3660
+ // the message landed at in Sent. Insert the local row
3661
+ // directly from the source we already have — no IMAP
3662
+ // round-trip, no SELECT-then-FETCH dance. The Sent
3663
+ // folder view shows the message immediately. The next
3664
+ // periodic sync will see this UID already present and
3665
+ // no-op. Critical: this means a slow/stuck Sent SELECT
3666
+ // never blocks "show me what I just sent" — that was
3667
+ // the user-visible "where's my sent message?" bug.
3668
+ await this.insertLocalRowFromSource(accountId, sentFolder, appendedSentUid, source, ["\\Seen"])
3669
+ .catch((e: any) => console.error(` [outbox] Local Sent row insert failed: ${e?.message || e} — falling back to broad sync`));
3670
+ } else {
3671
+ // No APPENDUID — server doesn't support UIDPLUS, or
3672
+ // APPEND itself failed. Fall back to a broad sync.
3673
+ this.syncFolder(accountId, sentFolder.id).catch(() => {});
3674
+ }
3335
3675
  this.syncFolder(accountId, outboxFolder.id).catch(() => {});
3336
3676
  }
3337
3677
  } catch (e: any) {
@@ -3683,8 +4023,14 @@ export class ImapManager extends EventEmitter {
3683
4023
  this.stopPeriodicSync();
3684
4024
  this.stopOutboxWorker();
3685
4025
  await this.stopWatching();
3686
- // Disconnect all persistent operational connections
3687
- for (const [accountId] of this.opsClients) {
4026
+ // Disconnect persistent connections on both lanes. Use a Set
4027
+ // because fastClients can hold an entry an account doesn't have
4028
+ // in opsClients (e.g. body fetches happened but no sync did).
4029
+ const accountIds = new Set<string>([
4030
+ ...this.opsClients.keys(),
4031
+ ...this.fastClients.keys(),
4032
+ ]);
4033
+ for (const accountId of accountIds) {
3688
4034
  await this.disconnectOps(accountId);
3689
4035
  }
3690
4036
  }