@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.
Files changed (3) hide show
  1. package/index.d.ts +18 -0
  2. package/index.js +85 -49
  3. 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: ops queue yields between chunks so
1115
- // a click-time body fetch can interleave. 100 UIDs per
1116
- // chunk balances throughput (one FETCH command per chunk)
1117
- // against latency (a chunk takes 1-3s on Dovecot, which
1118
- // is the worst-case wait an interactive click endures).
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
- // Each chunk gets its own withConnection slow-lane
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
- // ASSUMPTION: vanilla IMAP under stable UIDVALIDITY,
1153
- // higher UID = later assignment ≈ more recent message.
1154
- // True for Dovecot / Cyrus / standard IMAP, which is the
1155
- // only place this code path runs (Gmail-API mode goes
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 this.withConnection(accountId, async (c) => await c.fetchMessages(folder.path, chunk.join(","), { source: false }), { slow: true });
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 fresh = null;
1564
+ let clientForDiag = null;
1551
1565
  try {
1552
- fresh = await this.getOpsClient(accountId);
1553
- await Promise.race([
1554
- this.syncFolder(accountId, folder.id, fresh),
1555
- new Promise((_, reject) => setTimeout(() => {
1556
- // C120: pull TCP transport diagnostics into the
1557
- // per-folder timeout error so the [sync] log
1558
- // distinguishes "server stopped responding"
1559
- // (sinceLastRead high) from "we never finished
1560
- // writing" (writes climbing without reads). Same
1561
- // shape iflow-direct emits on its own timeouts.
1562
- const d = fresh?.transport?.diagnostics;
1563
- const diag = d
1564
- ? ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms]`
1565
- : "";
1566
- reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}${diag}`));
1567
- }, PER_FOLDER_TIMEOUT_MS)),
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.29",
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.6",
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.6",
45
+ "@bobfrankston/smtp-direct": "^0.1.8",
46
46
  "@bobfrankston/mailx-sync": "^0.1.16",
47
47
  "@bobfrankston/oauthsupport": "^1.0.26"
48
48
  }