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