@bobfrankston/mailx-imap 0.1.104 → 0.1.106

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 (3) hide show
  1. package/index.d.ts +44 -44
  2. package/index.js +144 -133
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -135,63 +135,62 @@ export declare class ImapManager extends EventEmitter {
135
135
  * forbids nesting under Trash. Self-contained on the worker. */
136
136
  moveFolderToTrashViaServer(accountId: string, folderId: number, folderPath: string, targetPath: string, trashId: number, trashPath: string, delim: string): Promise<void>;
137
137
  private spillFolderToTrashServer;
138
- /** Persistent slow-lane operational connection per account. Used by sync,
139
- * prefetch, outbox-append, large backfills, and any other "this might
140
- * take a while" operation. */
141
- private opsClients;
142
- /** Lazy-allocated fast-lane client per account (C123). Lives alongside
143
- * `opsClients` so click-time body fetches and flag toggles don't have
144
- * to wait behind a multi-minute prefetch / backfill on the slow client.
145
- * Created on the first fast-lane `withConnection` call; reused while
146
- * alive; recreated on stale-detect or error-discard like opsClients.
147
- * Costs +1 IMAP socket per active account, well under any reasonable
148
- * per-user-IP cap (Dovecot's default is 20). */
149
- private fastClients;
150
- /** Two-lane operation queue per account interactive ops (body fetch on
151
- * click, flag toggle) drain before background ops (sync, prefetch). FIFO
152
- * within each lane. The single ops connection means there's never a race
153
- * on which folder is SELECTed; commands run strictly sequentially. */
154
- private opsQueues;
138
+ /** Per-workload connection lanes. Each (lane, account) gets its OWN
139
+ * persistent IMAP connection so a slow op on one lane can't block another:
140
+ * a 90s `SELECT Sent` on "outbox" or a giant `UID FETCH` on "prefetch" no
141
+ * longer wedges INBOX sync/backfill on "sync". This is the root fix for
142
+ * the "whole sync worker goes silent" wedge (Bob 2026-06-20): before, all
143
+ * background work shared ONE slow connection and serialized behind the
144
+ * slowest command (a 90s SELECT Sent / a giant prefetch FETCH stalled
145
+ * INBOX backfill behind it). Lanes in use:
146
+ * fast — interactive: body fetch on click, flag toggle, move
147
+ * slow — folder sync + set-diff backfill (the `{slow:true}` default)
148
+ * prefetch background body download (heaviest, longest)
149
+ * outbox — send + sent-sweep + Outbox/Sent SELECT/APPEND
150
+ * Per account that's ≤4 persistent sockets + 1 IDLE, under Dovecot's 20.
151
+ * laneName (accountId persistent client). */
152
+ private laneClients;
153
+ /** Per-account, per-lane FIFO task queues. accountId (laneName → queue).
154
+ * All lanes for an account drain concurrently (each on its own socket);
155
+ * within a lane, strictly sequential — no SELECT race. */
156
+ private laneQueues;
155
157
  /** Per-host semaphore — caps simultaneous IMAP socket opens to one server.
156
- * Defensive guardrail: with the single-ops-per-account model an individual
157
- * user's mailx never hits more than (#accounts × 2) sockets per host, well
158
- * under any reasonable server cap. Exists for the multi-account-on-one-host
159
- * case (e.g. bobma + bobma2 both on imap.iecc.com). */
158
+ * Defensive guardrail for the multi-account-on-one-host case (e.g. bobma +
159
+ * bobma2 both on imap.iecc.com). NOTE: the permit is held for the
160
+ * connection's LIFETIME (released on logout/destroy), not just the open —
161
+ * so this also bounds the number of live persistent connections per host.
162
+ * Each account now keeps up to 4 persistent lanes (fast/slow/prefetch/
163
+ * outbox) + 1 IDLE (IDLE bypasses the semaphore). 8 permits covers two
164
+ * accounts' lanes with headroom, still far under Dovecot's ~20/user+IP. */
160
165
  private hostSemaphores;
161
166
  private static readonly HOST_PERMITS;
162
- /** Get (or create) a persistent connection for an account on the named
163
- * lane. Two lanes today: `slow` (the original `opsClients` map — sync,
164
- * prefetch, backfill, outbox) and `fast` (the C123 `fastClients` map —
165
- * click-time body fetch, flag toggles, anything user-driven). Same
166
- * stale-detect + reconnect semantics on both. logout() is wrapped as a
167
- * no-op so legacy callers don't close the persistent client. */
167
+ /** The (accountId → client) map for a lane, created on first use. */
168
+ private laneClientMap;
168
169
  private getLaneClient;
169
170
  /** Backwards-compat shim — callers outside the queue still ask for the
170
171
  * ops client by historical name. New code should prefer withConnection
171
172
  * with `slow:true` for slow operations. */
172
173
  private getOpsClient;
173
- /** Run an operation on the account's connection — queued, sequential, no concurrency */
174
- /** Run an operation against the account's single ops connection. Tasks
175
- * queue strictly sequentially per account only one IMAP command in
176
- * flight at a time. This eliminates the SELECT-races and "stale client
177
- * recovery" paths the old multi-client design needed.
174
+ /** Run an operation against one of the account's per-workload lane
175
+ * connections. Tasks queue strictly sequentially WITHIN a lane (one IMAP
176
+ * command in flight, no SELECT race) but lanes run CONCURRENTLY, each on
177
+ * its own socket — so a slow op on one lane never blocks another.
178
178
  *
179
- * Default lane is `fast` covers virtually everything (body fetch,
180
- * flag toggle, move, incremental sync). Pass `slow: true` only for
181
- * operations the caller knows will take a long time and shouldn't
182
- * block the user (multi-folder prefetch batches, large backfills).
183
- * When both lanes have tasks, fast drains first.
179
+ * Pick the lane via `opts.lane` (e.g. "sync", "prefetch", "outbox").
180
+ * Default is "fast" interactive ops (body fetch on click, flag toggle,
181
+ * move). `slow: true` is a legacy alias for the "slow" misc lane; prefer
182
+ * a named lane so distinct workloads don't serialize behind each other.
184
183
  *
185
- * Within a lane, FIFO. The running task always finishes — IMAP can't
186
- * preempt a command mid-flight. */
184
+ * The running task always finishes — IMAP can't preempt a command
185
+ * mid-flight; the wall-clock timeout below bounds how long that can be. */
187
186
  withConnection<T>(accountId: string, fn: (client: any) => Promise<T>, opts?: {
188
187
  slow?: boolean;
188
+ lane?: string;
189
189
  timeoutMs?: number;
190
190
  }): Promise<T>;
191
- /** Run the next queued task on each lane. Fast and slow lanes drain
192
- * CONCURRENTLY — each on its own connection (C123). FIFO within each
193
- * lane. The running flag per lane prevents reentrant draining of the
194
- * same lane. */
191
+ /** Drain the next queued task on EVERY lane for the account. Lanes run
192
+ * concurrently — each on its own connection and the per-lane running
193
+ * flag prevents reentrant draining of the same lane. */
195
194
  private drainOpsQueue;
196
195
  /** Acquire one slot of the per-host connection semaphore. Returns a release
197
196
  * function — call exactly once when the socket is closed. Used by
@@ -304,7 +303,8 @@ export declare class ImapManager extends EventEmitter {
304
303
  private syncFolderViaApi;
305
304
  /** Store API-fetched messages to DB */
306
305
  private storeApiMessages;
307
- /** Kill and recreate the persistent ops connection */
306
+ /** Kill and recreate the persistent connections for an account — drops the
307
+ * client on every lane so the next task on each reconnects fresh. */
308
308
  private reconnectOps;
309
309
  /** Handle sync errors — classify and emit appropriate UI events.
310
310
  * The connection-cap branch was removed: with the unified ops queue +
package/index.js CHANGED
@@ -566,38 +566,46 @@ export class ImapManager extends EventEmitter {
566
566
  // All operations on an account are serialized through an operation queue.
567
567
  // No semaphore, no pool, no per-operation connect/disconnect.
568
568
  // IDLE uses a separate connection (see startWatching).
569
- /** Persistent slow-lane operational connection per account. Used by sync,
570
- * prefetch, outbox-append, large backfills, and any other "this might
571
- * take a while" operation. */
572
- opsClients = new Map();
573
- /** Lazy-allocated fast-lane client per account (C123). Lives alongside
574
- * `opsClients` so click-time body fetches and flag toggles don't have
575
- * to wait behind a multi-minute prefetch / backfill on the slow client.
576
- * Created on the first fast-lane `withConnection` call; reused while
577
- * alive; recreated on stale-detect or error-discard like opsClients.
578
- * Costs +1 IMAP socket per active account, well under any reasonable
579
- * per-user-IP cap (Dovecot's default is 20). */
580
- fastClients = new Map();
581
- /** Two-lane operation queue per account interactive ops (body fetch on
582
- * click, flag toggle) drain before background ops (sync, prefetch). FIFO
583
- * within each lane. The single ops connection means there's never a race
584
- * on which folder is SELECTed; commands run strictly sequentially. */
585
- opsQueues = new Map();
569
+ /** Per-workload connection lanes. Each (lane, account) gets its OWN
570
+ * persistent IMAP connection so a slow op on one lane can't block another:
571
+ * a 90s `SELECT Sent` on "outbox" or a giant `UID FETCH` on "prefetch" no
572
+ * longer wedges INBOX sync/backfill on "sync". This is the root fix for
573
+ * the "whole sync worker goes silent" wedge (Bob 2026-06-20): before, all
574
+ * background work shared ONE slow connection and serialized behind the
575
+ * slowest command (a 90s SELECT Sent / a giant prefetch FETCH stalled
576
+ * INBOX backfill behind it). Lanes in use:
577
+ * fast — interactive: body fetch on click, flag toggle, move
578
+ * slow — folder sync + set-diff backfill (the `{slow:true}` default)
579
+ * prefetch background body download (heaviest, longest)
580
+ * outbox — send + sent-sweep + Outbox/Sent SELECT/APPEND
581
+ * Per account that's ≤4 persistent sockets + 1 IDLE, under Dovecot's 20.
582
+ * laneName (accountId persistent client). */
583
+ laneClients = new Map();
584
+ /** Per-account, per-lane FIFO task queues. accountId (laneName → queue).
585
+ * All lanes for an account drain concurrently (each on its own socket);
586
+ * within a lane, strictly sequential — no SELECT race. */
587
+ laneQueues = new Map();
586
588
  /** Per-host semaphore — caps simultaneous IMAP socket opens to one server.
587
- * Defensive guardrail: with the single-ops-per-account model an individual
588
- * user's mailx never hits more than (#accounts × 2) sockets per host, well
589
- * under any reasonable server cap. Exists for the multi-account-on-one-host
590
- * case (e.g. bobma + bobma2 both on imap.iecc.com). */
589
+ * Defensive guardrail for the multi-account-on-one-host case (e.g. bobma +
590
+ * bobma2 both on imap.iecc.com). NOTE: the permit is held for the
591
+ * connection's LIFETIME (released on logout/destroy), not just the open —
592
+ * so this also bounds the number of live persistent connections per host.
593
+ * Each account now keeps up to 4 persistent lanes (fast/slow/prefetch/
594
+ * outbox) + 1 IDLE (IDLE bypasses the semaphore). 8 permits covers two
595
+ * accounts' lanes with headroom, still far under Dovecot's ~20/user+IP. */
591
596
  hostSemaphores = new Map();
592
- static HOST_PERMITS = 4;
593
- /** Get (or create) a persistent connection for an account on the named
594
- * lane. Two lanes today: `slow` (the original `opsClients` map — sync,
595
- * prefetch, backfill, outbox) and `fast` (the C123 `fastClients` map —
596
- * click-time body fetch, flag toggles, anything user-driven). Same
597
- * stale-detect + reconnect semantics on both. logout() is wrapped as a
598
- * no-op so legacy callers don't close the persistent client. */
597
+ static HOST_PERMITS = 8;
598
+ /** The (accountId → client) map for a lane, created on first use. */
599
+ laneClientMap(lane) {
600
+ let m = this.laneClients.get(lane);
601
+ if (!m) {
602
+ m = new Map();
603
+ this.laneClients.set(lane, m);
604
+ }
605
+ return m;
606
+ }
599
607
  async getLaneClient(accountId, lane) {
600
- const map = lane === "fast" ? this.fastClients : this.opsClients;
608
+ const map = this.laneClientMap(lane);
601
609
  let client = map.get(accountId);
602
610
  if (client) {
603
611
  // C38: health-check the cached client before returning. If the
@@ -618,7 +626,7 @@ export class ImapManager extends EventEmitter {
618
626
  console.log(` [conn] ${accountId}: stale ${lane} client detected — reconnecting`);
619
627
  client = undefined;
620
628
  }
621
- client = await this.newClient(accountId, lane === "fast" ? "fast" : "ops");
629
+ client = await this.newClient(accountId, lane);
622
630
  // Wrap logout as no-op — this is a persistent connection. The
623
631
  // newClient wrapper's close-counter runs on `_realLogout`.
624
632
  const realLogout = client.logout.bind(client);
@@ -633,29 +641,32 @@ export class ImapManager extends EventEmitter {
633
641
  async getOpsClient(accountId) {
634
642
  return this.getLaneClient(accountId, "slow");
635
643
  }
636
- /** Run an operation on the account's connection — queued, sequential, no concurrency */
637
- /** Run an operation against the account's single ops connection. Tasks
638
- * queue strictly sequentially per account only one IMAP command in
639
- * flight at a time. This eliminates the SELECT-races and "stale client
640
- * recovery" paths the old multi-client design needed.
644
+ /** Run an operation against one of the account's per-workload lane
645
+ * connections. Tasks queue strictly sequentially WITHIN a lane (one IMAP
646
+ * command in flight, no SELECT race) but lanes run CONCURRENTLY, each on
647
+ * its own socket — so a slow op on one lane never blocks another.
641
648
  *
642
- * Default lane is `fast` covers virtually everything (body fetch,
643
- * flag toggle, move, incremental sync). Pass `slow: true` only for
644
- * operations the caller knows will take a long time and shouldn't
645
- * block the user (multi-folder prefetch batches, large backfills).
646
- * When both lanes have tasks, fast drains first.
649
+ * Pick the lane via `opts.lane` (e.g. "sync", "prefetch", "outbox").
650
+ * Default is "fast" interactive ops (body fetch on click, flag toggle,
651
+ * move). `slow: true` is a legacy alias for the "slow" misc lane; prefer
652
+ * a named lane so distinct workloads don't serialize behind each other.
647
653
  *
648
- * Within a lane, FIFO. The running task always finishes — IMAP can't
649
- * preempt a command mid-flight. */
654
+ * The running task always finishes — IMAP can't preempt a command
655
+ * mid-flight; the wall-clock timeout below bounds how long that can be. */
650
656
  async withConnection(accountId, fn, opts = {}) {
651
- let queue = this.opsQueues.get(accountId);
657
+ const lane = opts.lane ?? (opts.slow ? "slow" : "fast");
658
+ let perAccount = this.laneQueues.get(accountId);
659
+ if (!perAccount) {
660
+ perAccount = new Map();
661
+ this.laneQueues.set(accountId, perAccount);
662
+ }
663
+ let queue = perAccount.get(lane);
652
664
  if (!queue) {
653
- queue = { fast: [], slow: [], runningFast: false, runningSlow: false };
654
- this.opsQueues.set(accountId, queue);
665
+ queue = { tasks: [], running: false };
666
+ perAccount.set(lane, queue);
655
667
  }
656
- const lane = opts.slow ? "slow" : "fast";
657
668
  // Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
658
- // half-open, server stalled mid-FETCH) keeps the queue's running flag
669
+ // half-open, server stalled mid-FETCH) keeps the lane's running flag
659
670
  // set forever and every subsequent same-lane task — including the
660
671
  // retry button the user just hit — waits behind it. Default is
661
672
  // generous; callers driving user-visible reads pass a tighter value.
@@ -676,13 +687,12 @@ export class ImapManager extends EventEmitter {
676
687
  }
677
688
  catch (e) {
678
689
  clearTimeout(timer);
679
- // Discard the lane's client on any error — a half-broken
680
- // socket poisons every subsequent request on that lane.
681
- // Don't touch the OTHER lane's client; the lanes are
682
- // independent. Destroy synchronously kills the in-flight
683
- // command's socket so the underlying promise rejects and
684
- // stops holding state.
685
- const map = lane === "fast" ? this.fastClients : this.opsClients;
690
+ // Discard THIS lane's client on any error — a half-broken
691
+ // socket poisons every subsequent request on the lane.
692
+ // Other lanes' clients are independent and untouched.
693
+ // Destroy synchronously kills the in-flight command's
694
+ // socket so the underlying promise rejects.
695
+ const map = this.laneClientMap(lane);
686
696
  const stale = map.get(accountId);
687
697
  map.delete(accountId);
688
698
  if (stale) {
@@ -698,33 +708,29 @@ export class ImapManager extends EventEmitter {
698
708
  reject(e);
699
709
  }
700
710
  };
701
- queue[lane].push(task);
711
+ queue.tasks.push(task);
702
712
  this.drainOpsQueue(accountId);
703
713
  });
704
714
  }
705
- /** Run the next queued task on each lane. Fast and slow lanes drain
706
- * CONCURRENTLY — each on its own connection (C123). FIFO within each
707
- * lane. The running flag per lane prevents reentrant draining of the
708
- * same lane. */
715
+ /** Drain the next queued task on EVERY lane for the account. Lanes run
716
+ * concurrently — each on its own connection and the per-lane running
717
+ * flag prevents reentrant draining of the same lane. */
709
718
  drainOpsQueue(accountId) {
710
- const queue = this.opsQueues.get(accountId);
711
- if (!queue)
719
+ const perAccount = this.laneQueues.get(accountId);
720
+ if (!perAccount)
712
721
  return;
713
- const drainLane = (lane) => {
714
- const runningKey = lane === "fast" ? "runningFast" : "runningSlow";
715
- if (queue[runningKey])
716
- return;
717
- const next = queue[lane].shift();
722
+ for (const queue of perAccount.values()) {
723
+ if (queue.running)
724
+ continue;
725
+ const next = queue.tasks.shift();
718
726
  if (!next)
719
- return;
720
- queue[runningKey] = true;
727
+ continue;
728
+ queue.running = true;
721
729
  next().finally(() => {
722
- queue[runningKey] = false;
723
- drainLane(lane);
730
+ queue.running = false;
731
+ this.drainOpsQueue(accountId);
724
732
  });
725
- };
726
- drainLane("fast");
727
- drainLane("slow");
733
+ }
728
734
  }
729
735
  /** Acquire one slot of the per-host connection semaphore. Returns a release
730
736
  * function — call exactly once when the socket is closed. Used by
@@ -849,7 +855,7 @@ export class ImapManager extends EventEmitter {
849
855
  * so the server's connection slots free immediately rather than
850
856
  * waiting for socket idle timeouts. */
851
857
  async closeAllClients(accountId) {
852
- for (const map of [this.opsClients, this.fastClients]) {
858
+ for (const map of this.laneClients.values()) {
853
859
  const c = map.get(accountId);
854
860
  map.delete(accountId);
855
861
  if (c) {
@@ -881,7 +887,7 @@ export class ImapManager extends EventEmitter {
881
887
  /** Disconnect the persistent operational connections (both lanes) for
882
888
  * an account. */
883
889
  async disconnectOps(accountId) {
884
- for (const [name, map] of [["ops", this.opsClients], ["fast", this.fastClients]]) {
890
+ for (const [name, map] of this.laneClients) {
885
891
  const client = map.get(accountId);
886
892
  map.delete(accountId);
887
893
  if (client) {
@@ -1333,7 +1339,13 @@ export class ImapManager extends EventEmitter {
1333
1339
  */
1334
1340
  async fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, excludeUids) {
1335
1341
  const folderPath = folder.path;
1336
- const existingUids = this.db.getUidsForFolder(accountId, folderId);
1342
+ // Diff against UIDs we actually have a MESSAGE ROW for (messages table),
1343
+ // NOT message_folders memberships. An orphan membership (member row but
1344
+ // no message row) would otherwise look "present" and never get its real
1345
+ // data fetched — leaving the message permanently missing from the list
1346
+ // while the folder count looks full (Bob 2026-06-20: ~1k old INBOX
1347
+ // messages stuck missing behind orphan memberships).
1348
+ const existingUids = this.db.getStoredUids(accountId, folderId);
1337
1349
  // Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
1338
1350
  // deletions of OLDER messages are reflected; large folders (134k INBOX)
1339
1351
  // get a date-bounded query so we don't enumerate the whole mailbox.
@@ -1625,7 +1637,11 @@ export class ImapManager extends EventEmitter {
1625
1637
  // retries instead of locking out for RECONCILE_THROTTLE_MS
1626
1638
  // (Bob 2026-06-20: boot-time "Not connected" failure locked
1627
1639
  // INBOX recovery out for 15 min).
1628
- const localCount = this.db.getMessageCount(accountId, folderId);
1640
+ // Compare STORED-ROW count (messages table) to the server's
1641
+ // count — not getMessageCount (message_folders), which orphan
1642
+ // memberships can inflate to match the server while real rows
1643
+ // are still missing.
1644
+ const localCount = this.db.getStoredCount(accountId, folderId);
1629
1645
  const hasDeficit = statusMessageCount !== null && localCount < statusMessageCount;
1630
1646
  const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
1631
1647
  const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
@@ -2495,17 +2511,20 @@ export class ImapManager extends EventEmitter {
2495
2511
  console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
2496
2512
  return stored;
2497
2513
  }
2498
- /** Kill and recreate the persistent ops connection */
2514
+ /** Kill and recreate the persistent connections for an account — drops the
2515
+ * client on every lane so the next task on each reconnects fresh. */
2499
2516
  async reconnectOps(accountId) {
2500
- const old = this.opsClients.get(accountId);
2501
- this.opsClients.delete(accountId);
2502
- if (old) {
2503
- try {
2504
- await (old._realLogout || old.logout)();
2517
+ for (const map of this.laneClients.values()) {
2518
+ const old = map.get(accountId);
2519
+ map.delete(accountId);
2520
+ if (old) {
2521
+ try {
2522
+ await (old._realLogout || old.logout)();
2523
+ }
2524
+ catch { /* */ }
2505
2525
  }
2506
- catch { /* */ }
2507
2526
  }
2508
- console.log(` [conn] ${accountId}: reconnecting`);
2527
+ console.log(` [conn] ${accountId}: reconnecting (all lanes)`);
2509
2528
  }
2510
2529
  /** Handle sync errors — classify and emit appropriate UI events.
2511
2530
  * The connection-cap branch was removed: with the unified ops queue +
@@ -3587,7 +3606,7 @@ export class ImapManager extends EventEmitter {
3587
3606
  })());
3588
3607
  });
3589
3608
  await Promise.all(pending);
3590
- }, { slow: true, timeoutMs: 300_000 });
3609
+ }, { lane: "prefetch", timeoutMs: 300_000 });
3591
3610
  // 5-min cap, not the 90s interactive default:
3592
3611
  // prefetch is background, and a 25-message body
3593
3612
  // chunk on a slow Dovecot legitimately runs past
@@ -4403,7 +4422,7 @@ export class ImapManager extends EventEmitter {
4403
4422
  await this.withConnection(accountId, async (client) => {
4404
4423
  await client.createmailbox("Outbox");
4405
4424
  await this.syncFolders(accountId, client);
4406
- });
4425
+ }, { lane: "outbox" });
4407
4426
  }
4408
4427
  catch (e) {
4409
4428
  // Might already exist — benign
@@ -4517,7 +4536,7 @@ export class ImapManager extends EventEmitter {
4517
4536
  await this.withConnection(accountId, async (client) => {
4518
4537
  appendedUid = await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
4519
4538
  console.log(` [outbox] Queued message in ${outboxPath}${appendedUid != null ? ` (UID ${appendedUid})` : ""}`);
4520
- });
4539
+ }, { lane: "outbox" });
4521
4540
  if (outboxFolder) {
4522
4541
  if (appendedUid != null) {
4523
4542
  // Inserts the row from the source bytes we already have +
@@ -4741,7 +4760,9 @@ export class ImapManager extends EventEmitter {
4741
4760
  // claim so the recovery sweeper picks it up next tick.
4742
4761
  try {
4743
4762
  const outboxPath = await this.ensureOutbox(accountId);
4744
- const client = await this.createClientWithLimit(accountId);
4763
+ // Outbox lane, not the slow/sync lane — a wedged APPEND here (which
4764
+ // withTimeout force-closes the socket on) must not disrupt sync.
4765
+ const client = await this.getLaneClient(accountId, "outbox");
4745
4766
  try {
4746
4767
  for (const { dir, file } of filesToSend) {
4747
4768
  const filePath = path.join(dir, file);
@@ -4887,8 +4908,8 @@ export class ImapManager extends EventEmitter {
4887
4908
  const account = settings.accounts.find(a => a.id === accountId);
4888
4909
  if (!account)
4889
4910
  return;
4890
- // List UIDs first — quick command, fast lane.
4891
- const uids = await this.withConnection(accountId, (client) => client.getUids(outboxFolder.path));
4911
+ // List UIDs first — quick command on the dedicated outbox lane.
4912
+ const uids = await this.withConnection(accountId, (client) => client.getUids(outboxFolder.path), { lane: "outbox" });
4892
4913
  if (uids.length === 0)
4893
4914
  return;
4894
4915
  const STALE_CLAIM_MS = 3600_000; // 1 hour — longer than any reasonable SMTP send
@@ -4896,9 +4917,9 @@ export class ImapManager extends EventEmitter {
4896
4917
  const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
4897
4918
  const sentFolder = this.findFolder(accountId, "sent");
4898
4919
  for (const uid of uids) {
4899
- // Each iteration is one slow-lane turn — fast-lane work can run
4900
- // between iterations, so a body click during a long outbox drain
4901
- // gets serviced promptly.
4920
+ // Each iteration is one outbox-lane turn — sync, prefetch and
4921
+ // fast-lane (click) work all run on their own connections, so a
4922
+ // long outbox drain never blocks them.
4902
4923
  const result = await this.withConnection(accountId, async (client) => {
4903
4924
  const flags = await client.getFlags(outboxFolder.path, uid);
4904
4925
  // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
@@ -4945,7 +4966,7 @@ export class ImapManager extends EventEmitter {
4945
4966
  return { skip: true };
4946
4967
  }
4947
4968
  return { source: msg.source };
4948
- }, { slow: true });
4969
+ }, { lane: "outbox" });
4949
4970
  if (result.skip)
4950
4971
  continue;
4951
4972
  const source = result.source;
@@ -4959,13 +4980,13 @@ export class ImapManager extends EventEmitter {
4959
4980
  await this.withConnection(accountId, async (client) => {
4960
4981
  // Delete FIRST to prevent double-send if Sent-copy fails.
4961
4982
  await client.deleteMessageByUid(outboxFolder.path, uid);
4962
- }, { slow: true });
4983
+ }, { lane: "outbox" });
4963
4984
  if (sentFolder) {
4964
4985
  let appendedSentUid = null;
4965
4986
  try {
4966
4987
  await this.withConnection(accountId, async (client) => {
4967
4988
  appendedSentUid = await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
4968
- }, { slow: true });
4989
+ }, { lane: "outbox" });
4969
4990
  }
4970
4991
  catch (sentErr) {
4971
4992
  console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
@@ -4998,7 +5019,7 @@ export class ImapManager extends EventEmitter {
4998
5019
  await this.withConnection(accountId, async (client) => {
4999
5020
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5000
5021
  await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5001
- }, { slow: true });
5022
+ }, { lane: "outbox" });
5002
5023
  }
5003
5024
  catch { /* best-effort */ }
5004
5025
  // Suppress the banner for transient network errors. ETIMEDOUT
@@ -5154,10 +5175,10 @@ export class ImapManager extends EventEmitter {
5154
5175
  }
5155
5176
  catch (e) {
5156
5177
  // Stale-socket errors (Dovecot silently drops idle connections,
5157
- // or the sync path timed out and destroyed the socket): force a
5158
- // fresh ops client so the next tick doesn't keep hitting the same
5159
- // dead socket. Without reconnectOps, the dead client stays in the
5160
- // opsClients map and every subsequent processOutbox call fails
5178
+ // or the sync path timed out and destroyed the socket): force
5179
+ // fresh lane clients so the next tick doesn't keep hitting the
5180
+ // same dead socket. Without reconnectOps, the dead client stays
5181
+ // in its lane map and every subsequent processOutbox call fails
5161
5182
  // immediately with "Not connected" — forever.
5162
5183
  const msg = String(e?.message || e);
5163
5184
  if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
@@ -5261,19 +5282,18 @@ export class ImapManager extends EventEmitter {
5261
5282
  return;
5262
5283
  let reconciled = 0;
5263
5284
  let appended = 0;
5264
- // Use a DEDICATED IMAP client for sent-sweep do NOT share the
5265
- // slow-lane ops client. The priority-INBOX sync grabs the slow-lane
5266
- // client via getOpsClient() (no lane queueing) and runs SEARCH→FETCH
5267
- // as a logical transaction; if sent-sweep's searchByHeader (SELECT
5268
- // Sent SEARCH CLOSE) interleaves on the same wire between the
5269
- // sync's SEARCH and FETCH, the FETCH lands with no mailbox selected
5270
- // and Dovecot returns "BAD No mailbox selected" — new mail never
5271
- // lands locally (Bob 2026-05-25: "not seeing recent mail").
5272
- // iflow-direct serializes per-command, not per-task, so reusing the
5273
- // shared client is unsafe across logical transactions. A dedicated
5274
- // client is the smallest surgical fix.
5275
- const client = await this.createClientWithLimit(accountId);
5276
- try {
5285
+ // Run the whole sweep on the dedicated "outbox" lane its OWN
5286
+ // persistent connection, separate from the slow/sync lane. Two reasons:
5287
+ // 1. Sent-sweep's `SELECT Sent` has been observed taking 90s on this
5288
+ // server; on the shared slow lane that stalled INBOX sync/backfill
5289
+ // behind it (the "whole worker goes silent" wedge, Bob 2026-06-20).
5290
+ // 2. withConnection serializes the entire SEARCH→APPEND sweep on one
5291
+ // connection, so it can't interleave mid-transaction with another
5292
+ // task's SELECT (the "BAD No mailbox selected" class, Bob
5293
+ // 2026-05-25). Previously this used createClientWithLimit() which
5294
+ // actually returned the SHARED slow client and then destroyed it in
5295
+ // a finally, the opposite of "dedicated".
5296
+ await this.withConnection(accountId, async (client) => {
5277
5297
  for (const row of rows) {
5278
5298
  const msgId = row.message_id;
5279
5299
  if (!msgId)
@@ -5317,17 +5337,7 @@ export class ImapManager extends EventEmitter {
5317
5337
  }
5318
5338
  }
5319
5339
  }
5320
- }
5321
- finally {
5322
- try {
5323
- await (client._realLogout || client.logout)();
5324
- }
5325
- catch { /* */ }
5326
- try {
5327
- client.destroy?.();
5328
- }
5329
- catch { /* */ }
5330
- }
5340
+ }, { lane: "outbox", timeoutMs: 120_000 });
5331
5341
  if (reconciled + appended > 0) {
5332
5342
  this.emit("folderCountsChanged", accountId, {});
5333
5343
  console.log(` [sent-sweep] ${accountId}: ${reconciled} rebound, ${appended} re-appended`);
@@ -5617,13 +5627,14 @@ export class ImapManager extends EventEmitter {
5617
5627
  this.stopPeriodicSync();
5618
5628
  this.stopOutboxWorker();
5619
5629
  await this.stopWatching();
5620
- // Disconnect persistent connections on both lanes. Use a Set
5621
- // because fastClients can hold an entry an account doesn't have
5622
- // in opsClients (e.g. body fetches happened but no sync did).
5623
- const accountIds = new Set([
5624
- ...this.opsClients.keys(),
5625
- ...this.fastClients.keys(),
5626
- ]);
5630
+ // Disconnect persistent connections on every lane. Union across all
5631
+ // lane maps because a lane can hold an account the others don't (e.g.
5632
+ // body fetches happened on "fast" but no sync ran on "sync").
5633
+ const accountIds = new Set();
5634
+ for (const map of this.laneClients.values()) {
5635
+ for (const k of map.keys())
5636
+ accountIds.add(k);
5637
+ }
5627
5638
  for (const accountId of accountIds) {
5628
5639
  await this.disconnectOps(accountId);
5629
5640
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.104",
3
+ "version": "0.1.106",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",