@bobfrankston/mailx-imap 0.1.30 → 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 +44 -0
  3. package/package.json +1 -1
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();
@@ -2381,6 +2415,16 @@ export class ImapManager extends EventEmitter {
2381
2415
  async prefetchBodies(accountId) {
2382
2416
  if (this.prefetchingAccounts.has(accountId))
2383
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;
2384
2428
  this.prefetchingAccounts.add(accountId);
2385
2429
  try {
2386
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.30",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",