@bobfrankston/mailx-imap 0.1.104 → 0.1.105

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 +132 -131
  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) {
@@ -2495,17 +2501,20 @@ export class ImapManager extends EventEmitter {
2495
2501
  console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
2496
2502
  return stored;
2497
2503
  }
2498
- /** Kill and recreate the persistent ops connection */
2504
+ /** Kill and recreate the persistent connections for an account — drops the
2505
+ * client on every lane so the next task on each reconnects fresh. */
2499
2506
  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)();
2507
+ for (const map of this.laneClients.values()) {
2508
+ const old = map.get(accountId);
2509
+ map.delete(accountId);
2510
+ if (old) {
2511
+ try {
2512
+ await (old._realLogout || old.logout)();
2513
+ }
2514
+ catch { /* */ }
2505
2515
  }
2506
- catch { /* */ }
2507
2516
  }
2508
- console.log(` [conn] ${accountId}: reconnecting`);
2517
+ console.log(` [conn] ${accountId}: reconnecting (all lanes)`);
2509
2518
  }
2510
2519
  /** Handle sync errors — classify and emit appropriate UI events.
2511
2520
  * The connection-cap branch was removed: with the unified ops queue +
@@ -3587,7 +3596,7 @@ export class ImapManager extends EventEmitter {
3587
3596
  })());
3588
3597
  });
3589
3598
  await Promise.all(pending);
3590
- }, { slow: true, timeoutMs: 300_000 });
3599
+ }, { lane: "prefetch", timeoutMs: 300_000 });
3591
3600
  // 5-min cap, not the 90s interactive default:
3592
3601
  // prefetch is background, and a 25-message body
3593
3602
  // chunk on a slow Dovecot legitimately runs past
@@ -4403,7 +4412,7 @@ export class ImapManager extends EventEmitter {
4403
4412
  await this.withConnection(accountId, async (client) => {
4404
4413
  await client.createmailbox("Outbox");
4405
4414
  await this.syncFolders(accountId, client);
4406
- });
4415
+ }, { lane: "outbox" });
4407
4416
  }
4408
4417
  catch (e) {
4409
4418
  // Might already exist — benign
@@ -4517,7 +4526,7 @@ export class ImapManager extends EventEmitter {
4517
4526
  await this.withConnection(accountId, async (client) => {
4518
4527
  appendedUid = await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
4519
4528
  console.log(` [outbox] Queued message in ${outboxPath}${appendedUid != null ? ` (UID ${appendedUid})` : ""}`);
4520
- });
4529
+ }, { lane: "outbox" });
4521
4530
  if (outboxFolder) {
4522
4531
  if (appendedUid != null) {
4523
4532
  // Inserts the row from the source bytes we already have +
@@ -4741,7 +4750,9 @@ export class ImapManager extends EventEmitter {
4741
4750
  // claim so the recovery sweeper picks it up next tick.
4742
4751
  try {
4743
4752
  const outboxPath = await this.ensureOutbox(accountId);
4744
- const client = await this.createClientWithLimit(accountId);
4753
+ // Outbox lane, not the slow/sync lane — a wedged APPEND here (which
4754
+ // withTimeout force-closes the socket on) must not disrupt sync.
4755
+ const client = await this.getLaneClient(accountId, "outbox");
4745
4756
  try {
4746
4757
  for (const { dir, file } of filesToSend) {
4747
4758
  const filePath = path.join(dir, file);
@@ -4887,8 +4898,8 @@ export class ImapManager extends EventEmitter {
4887
4898
  const account = settings.accounts.find(a => a.id === accountId);
4888
4899
  if (!account)
4889
4900
  return;
4890
- // List UIDs first — quick command, fast lane.
4891
- const uids = await this.withConnection(accountId, (client) => client.getUids(outboxFolder.path));
4901
+ // List UIDs first — quick command on the dedicated outbox lane.
4902
+ const uids = await this.withConnection(accountId, (client) => client.getUids(outboxFolder.path), { lane: "outbox" });
4892
4903
  if (uids.length === 0)
4893
4904
  return;
4894
4905
  const STALE_CLAIM_MS = 3600_000; // 1 hour — longer than any reasonable SMTP send
@@ -4896,9 +4907,9 @@ export class ImapManager extends EventEmitter {
4896
4907
  const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
4897
4908
  const sentFolder = this.findFolder(accountId, "sent");
4898
4909
  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.
4910
+ // Each iteration is one outbox-lane turn — sync, prefetch and
4911
+ // fast-lane (click) work all run on their own connections, so a
4912
+ // long outbox drain never blocks them.
4902
4913
  const result = await this.withConnection(accountId, async (client) => {
4903
4914
  const flags = await client.getFlags(outboxFolder.path, uid);
4904
4915
  // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
@@ -4945,7 +4956,7 @@ export class ImapManager extends EventEmitter {
4945
4956
  return { skip: true };
4946
4957
  }
4947
4958
  return { source: msg.source };
4948
- }, { slow: true });
4959
+ }, { lane: "outbox" });
4949
4960
  if (result.skip)
4950
4961
  continue;
4951
4962
  const source = result.source;
@@ -4959,13 +4970,13 @@ export class ImapManager extends EventEmitter {
4959
4970
  await this.withConnection(accountId, async (client) => {
4960
4971
  // Delete FIRST to prevent double-send if Sent-copy fails.
4961
4972
  await client.deleteMessageByUid(outboxFolder.path, uid);
4962
- }, { slow: true });
4973
+ }, { lane: "outbox" });
4963
4974
  if (sentFolder) {
4964
4975
  let appendedSentUid = null;
4965
4976
  try {
4966
4977
  await this.withConnection(accountId, async (client) => {
4967
4978
  appendedSentUid = await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
4968
- }, { slow: true });
4979
+ }, { lane: "outbox" });
4969
4980
  }
4970
4981
  catch (sentErr) {
4971
4982
  console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
@@ -4998,7 +5009,7 @@ export class ImapManager extends EventEmitter {
4998
5009
  await this.withConnection(accountId, async (client) => {
4999
5010
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5000
5011
  await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5001
- }, { slow: true });
5012
+ }, { lane: "outbox" });
5002
5013
  }
5003
5014
  catch { /* best-effort */ }
5004
5015
  // Suppress the banner for transient network errors. ETIMEDOUT
@@ -5154,10 +5165,10 @@ export class ImapManager extends EventEmitter {
5154
5165
  }
5155
5166
  catch (e) {
5156
5167
  // 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
5168
+ // or the sync path timed out and destroyed the socket): force
5169
+ // fresh lane clients so the next tick doesn't keep hitting the
5170
+ // same dead socket. Without reconnectOps, the dead client stays
5171
+ // in its lane map and every subsequent processOutbox call fails
5161
5172
  // immediately with "Not connected" — forever.
5162
5173
  const msg = String(e?.message || e);
5163
5174
  if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
@@ -5261,19 +5272,18 @@ export class ImapManager extends EventEmitter {
5261
5272
  return;
5262
5273
  let reconciled = 0;
5263
5274
  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 {
5275
+ // Run the whole sweep on the dedicated "outbox" lane its OWN
5276
+ // persistent connection, separate from the slow/sync lane. Two reasons:
5277
+ // 1. Sent-sweep's `SELECT Sent` has been observed taking 90s on this
5278
+ // server; on the shared slow lane that stalled INBOX sync/backfill
5279
+ // behind it (the "whole worker goes silent" wedge, Bob 2026-06-20).
5280
+ // 2. withConnection serializes the entire SEARCH→APPEND sweep on one
5281
+ // connection, so it can't interleave mid-transaction with another
5282
+ // task's SELECT (the "BAD No mailbox selected" class, Bob
5283
+ // 2026-05-25). Previously this used createClientWithLimit() which
5284
+ // actually returned the SHARED slow client and then destroyed it in
5285
+ // a finally, the opposite of "dedicated".
5286
+ await this.withConnection(accountId, async (client) => {
5277
5287
  for (const row of rows) {
5278
5288
  const msgId = row.message_id;
5279
5289
  if (!msgId)
@@ -5317,17 +5327,7 @@ export class ImapManager extends EventEmitter {
5317
5327
  }
5318
5328
  }
5319
5329
  }
5320
- }
5321
- finally {
5322
- try {
5323
- await (client._realLogout || client.logout)();
5324
- }
5325
- catch { /* */ }
5326
- try {
5327
- client.destroy?.();
5328
- }
5329
- catch { /* */ }
5330
- }
5330
+ }, { lane: "outbox", timeoutMs: 120_000 });
5331
5331
  if (reconciled + appended > 0) {
5332
5332
  this.emit("folderCountsChanged", accountId, {});
5333
5333
  console.log(` [sent-sweep] ${accountId}: ${reconciled} rebound, ${appended} re-appended`);
@@ -5617,13 +5617,14 @@ export class ImapManager extends EventEmitter {
5617
5617
  this.stopPeriodicSync();
5618
5618
  this.stopOutboxWorker();
5619
5619
  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
- ]);
5620
+ // Disconnect persistent connections on every lane. Union across all
5621
+ // lane maps because a lane can hold an account the others don't (e.g.
5622
+ // body fetches happened on "fast" but no sync ran on "sync").
5623
+ const accountIds = new Set();
5624
+ for (const map of this.laneClients.values()) {
5625
+ for (const k of map.keys())
5626
+ accountIds.add(k);
5627
+ }
5627
5628
  for (const accountId of accountIds) {
5628
5629
  await this.disconnectOps(accountId);
5629
5630
  }
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.105",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",