@bobfrankston/rmfmail 1.0.590 → 1.0.594
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 +1 -0
- package/client/components/folder-tree.js +32 -3
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +32 -3
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +18 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +85 -49
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +86 -55
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
|
@@ -1013,7 +1013,43 @@ export class ImapManager extends EventEmitter {
|
|
|
1013
1013
|
}
|
|
1014
1014
|
|
|
1015
1015
|
/** Sync messages for a specific folder */
|
|
1016
|
+
/** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
|
|
1017
|
+
* for the same folder concurrently — `syncOne` (full sync), `syncInbox`
|
|
1018
|
+
* (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
|
|
1019
|
+
* `syncInboxNewOnly` (IDLE callback). Each path takes a different
|
|
1020
|
+
* connection, so withConnection can't serialize them. The DB layer
|
|
1021
|
+
* enforces "one transaction per connection" but doesn't notice the
|
|
1022
|
+
* concurrent UID set being mutated underneath. Symptom: two
|
|
1023
|
+
* `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
|
|
1024
|
+
* 21:47:52), `cannot start a transaction within a transaction` SQLite
|
|
1025
|
+
* errors, and prefetch SELECT/FETCH races when sync's SELECT runs
|
|
1026
|
+
* between prefetch's SELECT and FETCH.
|
|
1027
|
+
*
|
|
1028
|
+
* This per-folder mutex means the second concurrent caller waits
|
|
1029
|
+
* rather than silently racing. The waiter still gets the lock when
|
|
1030
|
+
* the first caller finishes — important for outbox flushes that
|
|
1031
|
+
* expect their syncFolder for `Sent` to actually run. */
|
|
1032
|
+
private syncFolderLocks = new Map<string, Promise<number>>();
|
|
1033
|
+
|
|
1016
1034
|
async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
|
|
1035
|
+
const lockKey = `${accountId}:${folderId}`;
|
|
1036
|
+
const inflight = this.syncFolderLocks.get(lockKey);
|
|
1037
|
+
if (inflight) {
|
|
1038
|
+
// Coalesce: callers that fire while a sync is in flight get the
|
|
1039
|
+
// result of the in-flight call rather than starting a duplicate.
|
|
1040
|
+
// For "quick check that finds new mail and triggers sync" this
|
|
1041
|
+
// means the quick check waits for the existing sync to complete
|
|
1042
|
+
// — which is what the user wants anyway, no double work.
|
|
1043
|
+
console.log(` [sync-enter] ${accountId}/${folderId}: coalescing (sync already in flight)`);
|
|
1044
|
+
return inflight;
|
|
1045
|
+
}
|
|
1046
|
+
const promise = this._syncFolderImpl(accountId, folderId, client)
|
|
1047
|
+
.finally(() => { this.syncFolderLocks.delete(lockKey); });
|
|
1048
|
+
this.syncFolderLocks.set(lockKey, promise);
|
|
1049
|
+
return promise;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private async _syncFolderImpl(accountId: string, folderId: number, client?: any): Promise<number> {
|
|
1017
1053
|
if (!client) client = await this.getOpsClient(accountId);
|
|
1018
1054
|
const prefetch = getPrefetch();
|
|
1019
1055
|
|
|
@@ -1159,21 +1195,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1159
1195
|
const missingUids = allServerUids.filter((uid: number) =>
|
|
1160
1196
|
!existingSet.has(uid) && !newSet.has(uid)
|
|
1161
1197
|
);
|
|
1162
|
-
// Backfill chunk size
|
|
1163
|
-
// a
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
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.
|
|
1198
|
+
// Backfill chunk size. Use the passed-in `client` directly
|
|
1199
|
+
// (NOT a nested withConnection) — syncFolder is now wrapped
|
|
1200
|
+
// in withConnection at the call site, so the slow lane is
|
|
1201
|
+
// already locked for our duration. A nested withConnection
|
|
1202
|
+
// would deadlock waiting for the slot we hold.
|
|
1171
1203
|
const BACKFILL_CHUNK_SIZE = 100;
|
|
1172
1204
|
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
1205
|
let minU = existingUids[0] ?? 0;
|
|
1178
1206
|
for (let i = 1; i < existingUids.length; i++) if (existingUids[i] < minU) minU = existingUids[i];
|
|
1179
1207
|
console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
|
|
@@ -1181,40 +1209,22 @@ export class ImapManager extends EventEmitter {
|
|
|
1181
1209
|
for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
|
|
1182
1210
|
const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
|
|
1183
1211
|
const range = chunk.join(",");
|
|
1184
|
-
|
|
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
|
-
);
|
|
1212
|
+
const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
|
|
1194
1213
|
messages.push(...recovered);
|
|
1195
1214
|
recoveredTotal += recovered.length;
|
|
1196
1215
|
console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
|
|
1197
1216
|
}
|
|
1198
1217
|
} else if (missingUids.length > 5000) {
|
|
1199
1218
|
console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
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.
|
|
1219
|
+
// Vanilla IMAP under stable UIDVALIDITY: higher UID =
|
|
1220
|
+
// later assignment ≈ more recent message (Dovecot/
|
|
1221
|
+
// Cyrus). Gmail-API path is separate (no temporal
|
|
1222
|
+
// meaning to its hash-UIDs).
|
|
1210
1223
|
const cappedSlice = missingUids.sort((a: number, b: number) => b - a).slice(0, 5000);
|
|
1211
1224
|
let recoveredTotal = 0;
|
|
1212
1225
|
for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
|
|
1213
1226
|
const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
|
|
1214
|
-
const recovered = await
|
|
1215
|
-
await (c as any).fetchMessages(folder.path, chunk.join(","), { source: false }),
|
|
1216
|
-
{ slow: true },
|
|
1217
|
-
);
|
|
1227
|
+
const recovered = await (client as any).fetchMessages(folder.path, chunk.join(","), { source: false });
|
|
1218
1228
|
messages.push(...recovered);
|
|
1219
1229
|
recoveredTotal += recovered.length;
|
|
1220
1230
|
console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
|
|
@@ -1610,26 +1620,38 @@ export class ImapManager extends EventEmitter {
|
|
|
1610
1620
|
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
1611
1621
|
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1612
1622
|
if (isTrashChild && highestUid === 0) return;
|
|
1613
|
-
let
|
|
1623
|
+
let clientForDiag: any = null;
|
|
1614
1624
|
try {
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
]);
|
|
1625
|
+
// Route syncFolder through the slow-lane queue so prefetch
|
|
1626
|
+
// (also slow lane) and sync take strict turns on the slow
|
|
1627
|
+
// client. Previously syncOne grabbed `getOpsClient` and
|
|
1628
|
+
// ran syncFolder directly OUTSIDE the queue; prefetch
|
|
1629
|
+
// chunks via withConnection raced against it on the same
|
|
1630
|
+
// client. Symptom: prefetch sent `SELECT INBOX` then sync
|
|
1631
|
+
// sent `SELECT Sent/Drafts`, then prefetch's `UID FETCH
|
|
1632
|
+
// <inbox-uids>` ran against Drafts → 0 bodies returned →
|
|
1633
|
+
// prefetch logs "0/N — NOT pruning" but bodies never
|
|
1634
|
+
// download. With C123 the fast lane has its own
|
|
1635
|
+
// independent client, so wrapping sync in slow-lane
|
|
1636
|
+
// withConnection doesn't block click-time body fetches.
|
|
1637
|
+
await this.withConnection(accountId, async (client) => {
|
|
1638
|
+
clientForDiag = client;
|
|
1639
|
+
await this.syncFolder(accountId, folder.id, client);
|
|
1640
|
+
}, { slow: true, timeoutMs: PER_FOLDER_TIMEOUT_MS });
|
|
1632
1641
|
} catch (e: any) {
|
|
1642
|
+
// C120: per-folder timeout error appends transport
|
|
1643
|
+
// diagnostics so the [sync] log distinguishes "server
|
|
1644
|
+
// stopped responding" (sinceLastRead high) from "we
|
|
1645
|
+
// never finished writing" (writes climbing without
|
|
1646
|
+
// reads). The withConnection timeout already includes
|
|
1647
|
+
// its own message; we annotate further only for the
|
|
1648
|
+
// timeout path.
|
|
1649
|
+
if (/timeout/i.test(e?.message || "")) {
|
|
1650
|
+
const d = (clientForDiag as any)?.transport?.diagnostics;
|
|
1651
|
+
if (d) {
|
|
1652
|
+
e.message = `${e.message} [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms] folder=${folder.path}`;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1633
1655
|
if (e.responseText?.includes("doesn't exist")) {
|
|
1634
1656
|
this.db.deleteFolder(folder.id);
|
|
1635
1657
|
} else {
|
|
@@ -2444,6 +2466,15 @@ export class ImapManager extends EventEmitter {
|
|
|
2444
2466
|
* too and the per-account `prefetchingAccounts` set deduplicates. */
|
|
2445
2467
|
async prefetchBodies(accountId: string): Promise<void> {
|
|
2446
2468
|
if (this.prefetchingAccounts.has(accountId)) return;
|
|
2469
|
+
// Skip if the account isn't registered yet — the reconciler tick
|
|
2470
|
+
// can fire 2 s after daemon start, and addAccount may still be
|
|
2471
|
+
// running its OAuth flow. Without this guard we hit
|
|
2472
|
+
// ERROR_BUDGET (20) failures with "No config for account X" and
|
|
2473
|
+
// exit prefetch silently for the rest of the session — bodies
|
|
2474
|
+
// never download. Symptom: log shows 20 lines of `[prefetch] X
|
|
2475
|
+
// folder Y chunk 0: batch fetch failed: No config for account X`
|
|
2476
|
+
// followed by `stopping after 20 errors`.
|
|
2477
|
+
if (!this.configs.has(accountId)) return;
|
|
2447
2478
|
this.prefetchingAccounts.add(accountId);
|
|
2448
2479
|
try { await this._prefetchBodies(accountId); }
|
|
2449
2480
|
finally { this.prefetchingAccounts.delete(accountId); }
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bobfrankston/mailx-imap",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.31",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/iflow-direct": "^0.1.27",
|