@bobfrankston/mailx-imap 0.1.93 → 0.1.95

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 (2) hide show
  1. package/index.js +62 -15
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -3386,24 +3386,56 @@ export class ImapManager extends EventEmitter {
3386
3386
  const raw = Buffer.from(source, "utf-8");
3387
3387
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
3388
3388
  const parsed = await extractPreview(source);
3389
- this.db.updateBodyMeta(accountId, folderId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
3390
- this.emit("bodyCached", accountId, uid);
3391
- this.clearPrefetchEmpty(accountId, folderId, uid); // healed drop stale backoff
3392
- counters.totalFetched++;
3393
- madeProgress = true;
3389
+ // The body_path DB write races the main thread's
3390
+ // writer (two-writer WAL contention since sync
3391
+ // moved to its own thread). That's TRANSIENT
3392
+ // the .eml is already on disk; only this UPDATE
3393
+ // is losing the lock. Retry with backoff instead
3394
+ // of treating it as a poison body. Backing a
3395
+ // momentary lock off for 5m→12h was exactly why
3396
+ // prefetched bodies "weren't happening" (Bob
3397
+ // 2026-06-14): the .eml sat on disk but body_path
3398
+ // never landed, so the row looked un-downloaded
3399
+ // AND was suppressed from re-fetch.
3400
+ let wrote = false;
3401
+ for (let attempt = 0; attempt < 4 && !wrote; attempt++) {
3402
+ try {
3403
+ this.db.updateBodyMeta(accountId, folderId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
3404
+ wrote = true;
3405
+ }
3406
+ catch (we) {
3407
+ const locked = /database is locked|sqlite_busy|busy/i.test(String(we?.message || we));
3408
+ if (locked && attempt < 3) {
3409
+ await new Promise(r => setTimeout(r, 300 * (attempt + 1)));
3410
+ continue;
3411
+ }
3412
+ if (locked) {
3413
+ // Still locked — leave the UID
3414
+ // un-backed-off so the next tick heals it.
3415
+ console.error(` [prefetch] ${accountId}/${uid}: body_path write still locked after retries — retry next tick`);
3416
+ }
3417
+ else {
3418
+ // Genuine write failure (corrupt body etc.) — back off.
3419
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${we?.message || we}`);
3420
+ this.markPrefetchEmpty(accountId, folderId, uid);
3421
+ }
3422
+ break;
3423
+ }
3424
+ }
3425
+ if (wrote) {
3426
+ this.emit("bodyCached", accountId, uid);
3427
+ this.clearPrefetchEmpty(accountId, folderId, uid); // healed — drop stale backoff
3428
+ counters.totalFetched++;
3429
+ madeProgress = true;
3430
+ }
3394
3431
  }
3395
3432
  catch (e) {
3396
3433
  console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
3397
- // Back off this UID. A corrupt body (the
3398
- // putMessage guard rejecting an IMAP command
3399
- // leaked into the FETCH response — iflow
3400
- // protocol desync) was NOT marked, so the
3401
- // same poisoned UID got re-fetched every
3402
- // prefetch cycle forever, spamming the log
3403
- // and wasting fetch turns (Bob 2026-05-28
3404
- // "why is prefetching still broken" — UID
3405
- // 59686 failing on a loop). markPrefetchEmpty
3406
- // applies the 5m→30m→2h→12h backoff.
3434
+ // putMessage / extractPreview failure (e.g. a
3435
+ // corrupt body an IMAP command leaked into the
3436
+ // FETCH response). Genuine poison: back off
3437
+ // 5m→30m→2h→12h so it isn't re-fetched on a loop
3438
+ // (Bob 2026-05-28, UID 59686).
3407
3439
  this.markPrefetchEmpty(accountId, folderId, uid);
3408
3440
  }
3409
3441
  })());
@@ -3439,6 +3471,21 @@ export class ImapManager extends EventEmitter {
3439
3471
  console.error(` [prefetch] ${accountId} folder ${folder.path} chunk ${chunkStart / PREFETCH_CHUNK_SIZE}: batch fetch failed: ${msg}`);
3440
3472
  counters.errors++;
3441
3473
  this.recordFolderError(accountId, folder.path);
3474
+ // A dead connection / network outage won't heal
3475
+ // mid-run — every further chunk just re-fails on the
3476
+ // same dead socket and burns the error budget (Bob
3477
+ // 2026-06-13: prefetch "Not connected" thrash on a
3478
+ // flaky network). Stop this folder's remaining chunks
3479
+ // NOW; the next reconciler tick reconnects fresh.
3480
+ // These are transient, so the UIDs are NOT backed off
3481
+ // (markPrefetchEmpty is per-message poison only) —
3482
+ // they retry next tick. Genuine per-body errors are
3483
+ // handled per-UID above and never reach here.
3484
+ const dead = /not connected|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EPIPE|socket|connection (closed|reset|lost)|disconnect/i.test(msg);
3485
+ if (dead) {
3486
+ console.log(` [prefetch] ${accountId}/${folder.path}: connection down — deferring rest to next tick`);
3487
+ break;
3488
+ }
3442
3489
  if (counters.errors >= ERROR_BUDGET)
3443
3490
  break;
3444
3491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.19",
13
13
  "@bobfrankston/mailx-settings": "^0.1.26",
14
- "@bobfrankston/mailx-store": "^0.1.48",
14
+ "@bobfrankston/mailx-store": "^0.1.49",
15
15
  "@bobfrankston/iflow-direct": "^0.1.53",
16
16
  "@bobfrankston/tcp-transport": "^0.1.7",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.19",
41
41
  "@bobfrankston/mailx-settings": "^0.1.26",
42
- "@bobfrankston/mailx-store": "^0.1.48",
42
+ "@bobfrankston/mailx-store": "^0.1.49",
43
43
  "@bobfrankston/iflow-direct": "^0.1.53",
44
44
  "@bobfrankston/tcp-transport": "^0.1.7",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",