@bobfrankston/mailx-imap 0.1.103 → 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.
- package/index.d.ts +44 -44
- package/index.js +141 -132
- package/package.json +3 -3
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
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
/**
|
|
163
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
-
*
|
|
186
|
-
*
|
|
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
|
-
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
*
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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 =
|
|
593
|
-
/**
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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 =
|
|
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
|
|
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
|
|
637
|
-
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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
|
-
*
|
|
649
|
-
*
|
|
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
|
-
|
|
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 = {
|
|
654
|
-
|
|
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
|
|
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
|
|
680
|
-
// socket poisons every subsequent request on
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
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
|
|
711
|
+
queue.tasks.push(task);
|
|
702
712
|
this.drainOpsQueue(accountId);
|
|
703
713
|
});
|
|
704
714
|
}
|
|
705
|
-
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
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
|
|
711
|
-
if (!
|
|
719
|
+
const perAccount = this.laneQueues.get(accountId);
|
|
720
|
+
if (!perAccount)
|
|
712
721
|
return;
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
720
|
-
queue
|
|
727
|
+
continue;
|
|
728
|
+
queue.running = true;
|
|
721
729
|
next().finally(() => {
|
|
722
|
-
queue
|
|
723
|
-
|
|
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
|
|
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
|
|
890
|
+
for (const [name, map] of this.laneClients) {
|
|
885
891
|
const client = map.get(accountId);
|
|
886
892
|
map.delete(accountId);
|
|
887
893
|
if (client) {
|
|
@@ -1367,8 +1373,16 @@ export class ImapManager extends EventEmitter {
|
|
|
1367
1373
|
// recent under stable UIDVALIDITY, so prefer newest when capping — the
|
|
1368
1374
|
// user cares most about recent mail and the deficit gate keeps calling
|
|
1369
1375
|
// us until the backlog is drained.
|
|
1376
|
+
//
|
|
1377
|
+
// Kept deliberately small (Bob 2026-06-20): each backfilled batch feeds
|
|
1378
|
+
// the body-prefetcher, which issues large UID FETCHes on the single
|
|
1379
|
+
// shared slow-lane connection. A 5000-message batch flooded that
|
|
1380
|
+
// connection (cmd-chain waits of 70s+, 90s FETCH timeouts) and wedged
|
|
1381
|
+
// the whole sync worker. A small batch per cycle keeps prefetch's queue
|
|
1382
|
+
// shallow so the connection stays responsive; the deficit gate drains
|
|
1383
|
+
// the backlog over more cycles instead of one connection-killing burst.
|
|
1370
1384
|
const BACKFILL_CHUNK_SIZE = 100;
|
|
1371
|
-
const RECONCILE_FETCH_CAP =
|
|
1385
|
+
const RECONCILE_FETCH_CAP = 1000;
|
|
1372
1386
|
let toFetch = missingUids;
|
|
1373
1387
|
if (missingUids.length > RECONCILE_FETCH_CAP) {
|
|
1374
1388
|
console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; resumes next cycle`);
|
|
@@ -2487,17 +2501,20 @@ export class ImapManager extends EventEmitter {
|
|
|
2487
2501
|
console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
|
|
2488
2502
|
return stored;
|
|
2489
2503
|
}
|
|
2490
|
-
/** Kill and recreate the persistent
|
|
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. */
|
|
2491
2506
|
async reconnectOps(accountId) {
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
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 { /* */ }
|
|
2497
2515
|
}
|
|
2498
|
-
catch { /* */ }
|
|
2499
2516
|
}
|
|
2500
|
-
console.log(` [conn] ${accountId}: reconnecting`);
|
|
2517
|
+
console.log(` [conn] ${accountId}: reconnecting (all lanes)`);
|
|
2501
2518
|
}
|
|
2502
2519
|
/** Handle sync errors — classify and emit appropriate UI events.
|
|
2503
2520
|
* The connection-cap branch was removed: with the unified ops queue +
|
|
@@ -3579,7 +3596,7 @@ export class ImapManager extends EventEmitter {
|
|
|
3579
3596
|
})());
|
|
3580
3597
|
});
|
|
3581
3598
|
await Promise.all(pending);
|
|
3582
|
-
}, {
|
|
3599
|
+
}, { lane: "prefetch", timeoutMs: 300_000 });
|
|
3583
3600
|
// 5-min cap, not the 90s interactive default:
|
|
3584
3601
|
// prefetch is background, and a 25-message body
|
|
3585
3602
|
// chunk on a slow Dovecot legitimately runs past
|
|
@@ -4395,7 +4412,7 @@ export class ImapManager extends EventEmitter {
|
|
|
4395
4412
|
await this.withConnection(accountId, async (client) => {
|
|
4396
4413
|
await client.createmailbox("Outbox");
|
|
4397
4414
|
await this.syncFolders(accountId, client);
|
|
4398
|
-
});
|
|
4415
|
+
}, { lane: "outbox" });
|
|
4399
4416
|
}
|
|
4400
4417
|
catch (e) {
|
|
4401
4418
|
// Might already exist — benign
|
|
@@ -4509,7 +4526,7 @@ export class ImapManager extends EventEmitter {
|
|
|
4509
4526
|
await this.withConnection(accountId, async (client) => {
|
|
4510
4527
|
appendedUid = await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
|
|
4511
4528
|
console.log(` [outbox] Queued message in ${outboxPath}${appendedUid != null ? ` (UID ${appendedUid})` : ""}`);
|
|
4512
|
-
});
|
|
4529
|
+
}, { lane: "outbox" });
|
|
4513
4530
|
if (outboxFolder) {
|
|
4514
4531
|
if (appendedUid != null) {
|
|
4515
4532
|
// Inserts the row from the source bytes we already have +
|
|
@@ -4733,7 +4750,9 @@ export class ImapManager extends EventEmitter {
|
|
|
4733
4750
|
// claim so the recovery sweeper picks it up next tick.
|
|
4734
4751
|
try {
|
|
4735
4752
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
4736
|
-
|
|
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");
|
|
4737
4756
|
try {
|
|
4738
4757
|
for (const { dir, file } of filesToSend) {
|
|
4739
4758
|
const filePath = path.join(dir, file);
|
|
@@ -4879,8 +4898,8 @@ export class ImapManager extends EventEmitter {
|
|
|
4879
4898
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
4880
4899
|
if (!account)
|
|
4881
4900
|
return;
|
|
4882
|
-
// List UIDs first — quick command
|
|
4883
|
-
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" });
|
|
4884
4903
|
if (uids.length === 0)
|
|
4885
4904
|
return;
|
|
4886
4905
|
const STALE_CLAIM_MS = 3600_000; // 1 hour — longer than any reasonable SMTP send
|
|
@@ -4888,9 +4907,9 @@ export class ImapManager extends EventEmitter {
|
|
|
4888
4907
|
const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
|
|
4889
4908
|
const sentFolder = this.findFolder(accountId, "sent");
|
|
4890
4909
|
for (const uid of uids) {
|
|
4891
|
-
// Each iteration is one
|
|
4892
|
-
//
|
|
4893
|
-
//
|
|
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.
|
|
4894
4913
|
const result = await this.withConnection(accountId, async (client) => {
|
|
4895
4914
|
const flags = await client.getFlags(outboxFolder.path, uid);
|
|
4896
4915
|
// Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
|
|
@@ -4937,7 +4956,7 @@ export class ImapManager extends EventEmitter {
|
|
|
4937
4956
|
return { skip: true };
|
|
4938
4957
|
}
|
|
4939
4958
|
return { source: msg.source };
|
|
4940
|
-
}, {
|
|
4959
|
+
}, { lane: "outbox" });
|
|
4941
4960
|
if (result.skip)
|
|
4942
4961
|
continue;
|
|
4943
4962
|
const source = result.source;
|
|
@@ -4951,13 +4970,13 @@ export class ImapManager extends EventEmitter {
|
|
|
4951
4970
|
await this.withConnection(accountId, async (client) => {
|
|
4952
4971
|
// Delete FIRST to prevent double-send if Sent-copy fails.
|
|
4953
4972
|
await client.deleteMessageByUid(outboxFolder.path, uid);
|
|
4954
|
-
}, {
|
|
4973
|
+
}, { lane: "outbox" });
|
|
4955
4974
|
if (sentFolder) {
|
|
4956
4975
|
let appendedSentUid = null;
|
|
4957
4976
|
try {
|
|
4958
4977
|
await this.withConnection(accountId, async (client) => {
|
|
4959
4978
|
appendedSentUid = await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
|
|
4960
|
-
}, {
|
|
4979
|
+
}, { lane: "outbox" });
|
|
4961
4980
|
}
|
|
4962
4981
|
catch (sentErr) {
|
|
4963
4982
|
console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
|
|
@@ -4990,7 +5009,7 @@ export class ImapManager extends EventEmitter {
|
|
|
4990
5009
|
await this.withConnection(accountId, async (client) => {
|
|
4991
5010
|
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
4992
5011
|
await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
|
|
4993
|
-
}, {
|
|
5012
|
+
}, { lane: "outbox" });
|
|
4994
5013
|
}
|
|
4995
5014
|
catch { /* best-effort */ }
|
|
4996
5015
|
// Suppress the banner for transient network errors. ETIMEDOUT
|
|
@@ -5146,10 +5165,10 @@ export class ImapManager extends EventEmitter {
|
|
|
5146
5165
|
}
|
|
5147
5166
|
catch (e) {
|
|
5148
5167
|
// Stale-socket errors (Dovecot silently drops idle connections,
|
|
5149
|
-
// or the sync path timed out and destroyed the socket): force
|
|
5150
|
-
// fresh
|
|
5151
|
-
// dead socket. Without reconnectOps, the dead client stays
|
|
5152
|
-
//
|
|
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
|
|
5153
5172
|
// immediately with "Not connected" — forever.
|
|
5154
5173
|
const msg = String(e?.message || e);
|
|
5155
5174
|
if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
|
|
@@ -5253,19 +5272,18 @@ export class ImapManager extends EventEmitter {
|
|
|
5253
5272
|
return;
|
|
5254
5273
|
let reconciled = 0;
|
|
5255
5274
|
let appended = 0;
|
|
5256
|
-
//
|
|
5257
|
-
//
|
|
5258
|
-
//
|
|
5259
|
-
//
|
|
5260
|
-
//
|
|
5261
|
-
//
|
|
5262
|
-
//
|
|
5263
|
-
//
|
|
5264
|
-
//
|
|
5265
|
-
//
|
|
5266
|
-
//
|
|
5267
|
-
|
|
5268
|
-
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) => {
|
|
5269
5287
|
for (const row of rows) {
|
|
5270
5288
|
const msgId = row.message_id;
|
|
5271
5289
|
if (!msgId)
|
|
@@ -5309,17 +5327,7 @@ export class ImapManager extends EventEmitter {
|
|
|
5309
5327
|
}
|
|
5310
5328
|
}
|
|
5311
5329
|
}
|
|
5312
|
-
}
|
|
5313
|
-
finally {
|
|
5314
|
-
try {
|
|
5315
|
-
await (client._realLogout || client.logout)();
|
|
5316
|
-
}
|
|
5317
|
-
catch { /* */ }
|
|
5318
|
-
try {
|
|
5319
|
-
client.destroy?.();
|
|
5320
|
-
}
|
|
5321
|
-
catch { /* */ }
|
|
5322
|
-
}
|
|
5330
|
+
}, { lane: "outbox", timeoutMs: 120_000 });
|
|
5323
5331
|
if (reconciled + appended > 0) {
|
|
5324
5332
|
this.emit("folderCountsChanged", accountId, {});
|
|
5325
5333
|
console.log(` [sent-sweep] ${accountId}: ${reconciled} rebound, ${appended} re-appended`);
|
|
@@ -5609,13 +5617,14 @@ export class ImapManager extends EventEmitter {
|
|
|
5609
5617
|
this.stopPeriodicSync();
|
|
5610
5618
|
this.stopOutboxWorker();
|
|
5611
5619
|
await this.stopWatching();
|
|
5612
|
-
// Disconnect persistent connections on
|
|
5613
|
-
// because
|
|
5614
|
-
//
|
|
5615
|
-
const accountIds = new Set(
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
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
|
+
}
|
|
5619
5628
|
for (const accountId of accountIds) {
|
|
5620
5629
|
await this.disconnectOps(accountId);
|
|
5621
5630
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.105",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.30",
|
|
14
14
|
"@bobfrankston/mailx-store": "^0.1.54",
|
|
15
15
|
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
".transformedSnapshot": {
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
41
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
41
|
+
"@bobfrankston/mailx-settings": "^0.1.30",
|
|
42
42
|
"@bobfrankston/mailx-store": "^0.1.54",
|
|
43
43
|
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.7",
|