@bobfrankston/mailx-imap 0.1.29 → 0.1.31
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 +18 -0
- package/index.js +85 -49
- package/package.json +3 -3
package/index.d.ts
CHANGED
|
@@ -224,7 +224,25 @@ export declare class ImapManager extends EventEmitter {
|
|
|
224
224
|
path: string;
|
|
225
225
|
}, uid: number, source: string, flags: string[]): Promise<void>;
|
|
226
226
|
/** Sync messages for a specific folder */
|
|
227
|
+
/** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
|
|
228
|
+
* for the same folder concurrently — `syncOne` (full sync), `syncInbox`
|
|
229
|
+
* (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
|
|
230
|
+
* `syncInboxNewOnly` (IDLE callback). Each path takes a different
|
|
231
|
+
* connection, so withConnection can't serialize them. The DB layer
|
|
232
|
+
* enforces "one transaction per connection" but doesn't notice the
|
|
233
|
+
* concurrent UID set being mutated underneath. Symptom: two
|
|
234
|
+
* `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
|
|
235
|
+
* 21:47:52), `cannot start a transaction within a transaction` SQLite
|
|
236
|
+
* errors, and prefetch SELECT/FETCH races when sync's SELECT runs
|
|
237
|
+
* between prefetch's SELECT and FETCH.
|
|
238
|
+
*
|
|
239
|
+
* This per-folder mutex means the second concurrent caller waits
|
|
240
|
+
* rather than silently racing. The waiter still gets the lock when
|
|
241
|
+
* the first caller finishes — important for outbox flushes that
|
|
242
|
+
* expect their syncFolder for `Sent` to actually run. */
|
|
243
|
+
private syncFolderLocks;
|
|
227
244
|
syncFolder(accountId: string, folderId: number, client?: any): Promise<number>;
|
|
245
|
+
private _syncFolderImpl;
|
|
228
246
|
/** Sync all folders for all accounts */
|
|
229
247
|
syncAll(): Promise<void>;
|
|
230
248
|
private _syncAll;
|
package/index.js
CHANGED
|
@@ -972,7 +972,41 @@ export class ImapManager extends EventEmitter {
|
|
|
972
972
|
console.log(` [local-insert] ${folder.path} UID ${uid}: ${parsed.subject || "(no subject)"} (no IMAP roundtrip)`);
|
|
973
973
|
}
|
|
974
974
|
/** Sync messages for a specific folder */
|
|
975
|
+
/** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
|
|
976
|
+
* for the same folder concurrently — `syncOne` (full sync), `syncInbox`
|
|
977
|
+
* (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
|
|
978
|
+
* `syncInboxNewOnly` (IDLE callback). Each path takes a different
|
|
979
|
+
* connection, so withConnection can't serialize them. The DB layer
|
|
980
|
+
* enforces "one transaction per connection" but doesn't notice the
|
|
981
|
+
* concurrent UID set being mutated underneath. Symptom: two
|
|
982
|
+
* `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
|
|
983
|
+
* 21:47:52), `cannot start a transaction within a transaction` SQLite
|
|
984
|
+
* errors, and prefetch SELECT/FETCH races when sync's SELECT runs
|
|
985
|
+
* between prefetch's SELECT and FETCH.
|
|
986
|
+
*
|
|
987
|
+
* This per-folder mutex means the second concurrent caller waits
|
|
988
|
+
* rather than silently racing. The waiter still gets the lock when
|
|
989
|
+
* the first caller finishes — important for outbox flushes that
|
|
990
|
+
* expect their syncFolder for `Sent` to actually run. */
|
|
991
|
+
syncFolderLocks = new Map();
|
|
975
992
|
async syncFolder(accountId, folderId, client) {
|
|
993
|
+
const lockKey = `${accountId}:${folderId}`;
|
|
994
|
+
const inflight = this.syncFolderLocks.get(lockKey);
|
|
995
|
+
if (inflight) {
|
|
996
|
+
// Coalesce: callers that fire while a sync is in flight get the
|
|
997
|
+
// result of the in-flight call rather than starting a duplicate.
|
|
998
|
+
// For "quick check that finds new mail and triggers sync" this
|
|
999
|
+
// means the quick check waits for the existing sync to complete
|
|
1000
|
+
// — which is what the user wants anyway, no double work.
|
|
1001
|
+
console.log(` [sync-enter] ${accountId}/${folderId}: coalescing (sync already in flight)`);
|
|
1002
|
+
return inflight;
|
|
1003
|
+
}
|
|
1004
|
+
const promise = this._syncFolderImpl(accountId, folderId, client)
|
|
1005
|
+
.finally(() => { this.syncFolderLocks.delete(lockKey); });
|
|
1006
|
+
this.syncFolderLocks.set(lockKey, promise);
|
|
1007
|
+
return promise;
|
|
1008
|
+
}
|
|
1009
|
+
async _syncFolderImpl(accountId, folderId, client) {
|
|
976
1010
|
if (!client)
|
|
977
1011
|
client = await this.getOpsClient(accountId);
|
|
978
1012
|
const prefetch = getPrefetch();
|
|
@@ -1111,21 +1145,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1111
1145
|
const existingSet = new Set(existingUids);
|
|
1112
1146
|
const newSet = new Set(messages.map(m => m.uid));
|
|
1113
1147
|
const missingUids = allServerUids.filter((uid) => !existingSet.has(uid) && !newSet.has(uid));
|
|
1114
|
-
// Backfill chunk size
|
|
1115
|
-
// a
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// The 500-chunk version held the ops queue for the
|
|
1120
|
-
// entire backfill — Bob 2026-05-08 saw a click-to-render
|
|
1121
|
-
// wait of 100+ minutes on a busy backfill of the IP
|
|
1122
|
-
// folder.
|
|
1148
|
+
// Backfill chunk size. Use the passed-in `client` directly
|
|
1149
|
+
// (NOT a nested withConnection) — syncFolder is now wrapped
|
|
1150
|
+
// in withConnection at the call site, so the slow lane is
|
|
1151
|
+
// already locked for our duration. A nested withConnection
|
|
1152
|
+
// would deadlock waiting for the slot we hold.
|
|
1123
1153
|
const BACKFILL_CHUNK_SIZE = 100;
|
|
1124
1154
|
if (missingUids.length > 0 && missingUids.length <= 5000) {
|
|
1125
|
-
// For the log line we report a count; computing
|
|
1126
|
-
// min/max via a spread (`Math.min(...arr)`) blows V8's
|
|
1127
|
-
// argument limit on folders with tens of thousands of
|
|
1128
|
-
// UIDs. Use a manual reduce.
|
|
1129
1155
|
let minU = existingUids[0] ?? 0;
|
|
1130
1156
|
for (let i = 1; i < existingUids.length; i++)
|
|
1131
1157
|
if (existingUids[i] < minU)
|
|
@@ -1135,13 +1161,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1135
1161
|
for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
|
|
1136
1162
|
const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
|
|
1137
1163
|
const range = chunk.join(",");
|
|
1138
|
-
|
|
1139
|
-
// turn so any fast-lane click queued in the
|
|
1140
|
-
// meantime gets serviced between chunks. The
|
|
1141
|
-
// outer `client` param is bypassed here; the
|
|
1142
|
-
// queue-managed client is the same persistent
|
|
1143
|
-
// ops client (getOpsClient).
|
|
1144
|
-
const recovered = await this.withConnection(accountId, async (c) => await c.fetchMessages(folder.path, range, { source: false }), { slow: true });
|
|
1164
|
+
const recovered = await client.fetchMessages(folder.path, range, { source: false });
|
|
1145
1165
|
messages.push(...recovered);
|
|
1146
1166
|
recoveredTotal += recovered.length;
|
|
1147
1167
|
console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
|
|
@@ -1149,21 +1169,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1149
1169
|
}
|
|
1150
1170
|
else if (missingUids.length > 5000) {
|
|
1151
1171
|
console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
// through syncAccountViaApi and doesn't reach here —
|
|
1157
|
-
// its synthesized hash-UIDs have no temporal meaning).
|
|
1158
|
-
// If we ever wire this for a non-monotonic UID source,
|
|
1159
|
-
// sort by date instead — but that means an extra
|
|
1160
|
-
// fetch-of-INTERNALDATE round-trip, which we avoid for
|
|
1161
|
-
// free here under the IMAP guarantee.
|
|
1172
|
+
// Vanilla IMAP under stable UIDVALIDITY: higher UID =
|
|
1173
|
+
// later assignment ≈ more recent message (Dovecot/
|
|
1174
|
+
// Cyrus). Gmail-API path is separate (no temporal
|
|
1175
|
+
// meaning to its hash-UIDs).
|
|
1162
1176
|
const cappedSlice = missingUids.sort((a, b) => b - a).slice(0, 5000);
|
|
1163
1177
|
let recoveredTotal = 0;
|
|
1164
1178
|
for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
|
|
1165
1179
|
const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
|
|
1166
|
-
const recovered = await
|
|
1180
|
+
const recovered = await client.fetchMessages(folder.path, chunk.join(","), { source: false });
|
|
1167
1181
|
messages.push(...recovered);
|
|
1168
1182
|
recoveredTotal += recovered.length;
|
|
1169
1183
|
console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
|
|
@@ -1547,27 +1561,39 @@ export class ImapManager extends EventEmitter {
|
|
|
1547
1561
|
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1548
1562
|
if (isTrashChild && highestUid === 0)
|
|
1549
1563
|
return;
|
|
1550
|
-
let
|
|
1564
|
+
let clientForDiag = null;
|
|
1551
1565
|
try {
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
]);
|
|
1566
|
+
// Route syncFolder through the slow-lane queue so prefetch
|
|
1567
|
+
// (also slow lane) and sync take strict turns on the slow
|
|
1568
|
+
// client. Previously syncOne grabbed `getOpsClient` and
|
|
1569
|
+
// ran syncFolder directly OUTSIDE the queue; prefetch
|
|
1570
|
+
// chunks via withConnection raced against it on the same
|
|
1571
|
+
// client. Symptom: prefetch sent `SELECT INBOX` then sync
|
|
1572
|
+
// sent `SELECT Sent/Drafts`, then prefetch's `UID FETCH
|
|
1573
|
+
// <inbox-uids>` ran against Drafts → 0 bodies returned →
|
|
1574
|
+
// prefetch logs "0/N — NOT pruning" but bodies never
|
|
1575
|
+
// download. With C123 the fast lane has its own
|
|
1576
|
+
// independent client, so wrapping sync in slow-lane
|
|
1577
|
+
// withConnection doesn't block click-time body fetches.
|
|
1578
|
+
await this.withConnection(accountId, async (client) => {
|
|
1579
|
+
clientForDiag = client;
|
|
1580
|
+
await this.syncFolder(accountId, folder.id, client);
|
|
1581
|
+
}, { slow: true, timeoutMs: PER_FOLDER_TIMEOUT_MS });
|
|
1569
1582
|
}
|
|
1570
1583
|
catch (e) {
|
|
1584
|
+
// C120: per-folder timeout error appends transport
|
|
1585
|
+
// diagnostics so the [sync] log distinguishes "server
|
|
1586
|
+
// stopped responding" (sinceLastRead high) from "we
|
|
1587
|
+
// never finished writing" (writes climbing without
|
|
1588
|
+
// reads). The withConnection timeout already includes
|
|
1589
|
+
// its own message; we annotate further only for the
|
|
1590
|
+
// timeout path.
|
|
1591
|
+
if (/timeout/i.test(e?.message || "")) {
|
|
1592
|
+
const d = clientForDiag?.transport?.diagnostics;
|
|
1593
|
+
if (d) {
|
|
1594
|
+
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}`;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1571
1597
|
if (e.responseText?.includes("doesn't exist")) {
|
|
1572
1598
|
this.db.deleteFolder(folder.id);
|
|
1573
1599
|
}
|
|
@@ -2389,6 +2415,16 @@ export class ImapManager extends EventEmitter {
|
|
|
2389
2415
|
async prefetchBodies(accountId) {
|
|
2390
2416
|
if (this.prefetchingAccounts.has(accountId))
|
|
2391
2417
|
return;
|
|
2418
|
+
// Skip if the account isn't registered yet — the reconciler tick
|
|
2419
|
+
// can fire 2 s after daemon start, and addAccount may still be
|
|
2420
|
+
// running its OAuth flow. Without this guard we hit
|
|
2421
|
+
// ERROR_BUDGET (20) failures with "No config for account X" and
|
|
2422
|
+
// exit prefetch silently for the rest of the session — bodies
|
|
2423
|
+
// never download. Symptom: log shows 20 lines of `[prefetch] X
|
|
2424
|
+
// folder Y chunk 0: batch fetch failed: No config for account X`
|
|
2425
|
+
// followed by `stopping after 20 errors`.
|
|
2426
|
+
if (!this.configs.has(accountId))
|
|
2427
|
+
return;
|
|
2392
2428
|
this.prefetchingAccounts.add(accountId);
|
|
2393
2429
|
try {
|
|
2394
2430
|
await this._prefetchBodies(accountId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"@bobfrankston/mailx-store": "^0.1.15",
|
|
15
15
|
"@bobfrankston/iflow-direct": "^0.1.39",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
17
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
17
|
+
"@bobfrankston/smtp-direct": "^0.1.8",
|
|
18
18
|
"@bobfrankston/mailx-sync": "^0.1.16",
|
|
19
19
|
"@bobfrankston/oauthsupport": "^1.0.26"
|
|
20
20
|
},
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@bobfrankston/mailx-store": "^0.1.15",
|
|
43
43
|
"@bobfrankston/iflow-direct": "^0.1.39",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
45
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
45
|
+
"@bobfrankston/smtp-direct": "^0.1.8",
|
|
46
46
|
"@bobfrankston/mailx-sync": "^0.1.16",
|
|
47
47
|
"@bobfrankston/oauthsupport": "^1.0.26"
|
|
48
48
|
}
|