@bobfrankston/mailx-imap 0.1.30 → 0.1.32

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 +64 -3
  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);
@@ -3324,7 +3368,12 @@ export class ImapManager extends EventEmitter {
3324
3368
  // this exact case: `.sending-rmf39-63196` sat in the queue
3325
3369
  // for 7+ hours because PID 63196 was now an unrelated Node.
3326
3370
  // (c) it's our PID — never sweep our own claim.
3327
- const STALE_CLAIM_MS = 3600_000;
3371
+ // 5 minutes: SMTP transactions complete in seconds; 5m of
3372
+ // claim with no progress means the worker is wedged or PID
3373
+ // got recycled. Earlier 1h was a band-aid that left Bob's
3374
+ // outbox stuck for hours when an SMTP wedge crashed the
3375
+ // worker mid-flight.
3376
+ const STALE_CLAIM_MS = 5 * 60_000;
3328
3377
  const myPid = process.pid;
3329
3378
  for (const dir of [outboxDir, queuedDir]) {
3330
3379
  if (!fs.existsSync(dir))
@@ -3426,11 +3475,23 @@ export class ImapManager extends EventEmitter {
3426
3475
  // file is visible to the scan loop again.
3427
3476
  const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
3428
3477
  const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
3429
- fs.writeFileSync(claimedPath, withDelay, "utf-8");
3478
+ try {
3479
+ fs.writeFileSync(claimedPath, withDelay, "utf-8");
3480
+ }
3481
+ catch (we) {
3482
+ console.error(` [outbox] FAIL writeBack ${claimedPath}: ${we?.message || we}`);
3483
+ }
3430
3484
  try {
3431
3485
  fs.renameSync(claimedPath, filePath);
3432
3486
  }
3433
- catch { /* file stays claimed; recovery sweeper will handle */ }
3487
+ catch (re) {
3488
+ // Loud — the file is now stuck in `.sending-` state
3489
+ // until the recovery sweeper's stale-claim timer
3490
+ // (5 min) reclaims it. User-visible as "stuck" in
3491
+ // the outbox view; cancelable via the new always-
3492
+ // allowed Cancel button.
3493
+ console.error(` [outbox] FAIL renameBack ${claimedPath} → ${filePath}: ${re?.message || re} — claim will sit until stale-recovery (5 min)`);
3494
+ }
3434
3495
  console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
3435
3496
  }
3436
3497
  }
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.32",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",