@bobfrankston/mailx-imap 0.1.51 → 0.1.53

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 +24 -0
  2. package/index.js +111 -6
  3. package/package.json +11 -11
package/index.d.ts CHANGED
@@ -380,6 +380,23 @@ export declare class ImapManager extends EventEmitter {
380
380
  private shouldSkipFolder;
381
381
  private recordFolderError;
382
382
  private clearFolderErrors;
383
+ /** UIDs that returned 0 bodies on a successful batch — either stale DB rows
384
+ * whose UIDVALIDITY has rotated, or a transient iflow parser miss that
385
+ * dropped the body literal mid-stream. The "NOT pruning" safety guard in
386
+ * `_prefetchBodies` refuses to delete them so a transient miss can't
387
+ * trigger data loss; the trade-off is they sit in `getMessagesWithoutBody`
388
+ * and would otherwise dominate the size-asc query, starving live folders.
389
+ *
390
+ * Backoff schedule on repeated failure: 5 min → 30 min → 2 h → 12 h.
391
+ * Without a TTL the very first transient miss permanently blocklists the
392
+ * UID for the session and the row never shows the cached-body dot
393
+ * (the symptom Bob reported 2026-05-19: document/large messages with
394
+ * no teal mark). With TTL, transient misses self-heal in 5 minutes;
395
+ * genuine zombies back off to once-per-12-hour retries — bounded load.
396
+ * Key shape `${accountId}:${folderId}:${uid}`. */
397
+ private prefetchFailures;
398
+ private markPrefetchEmpty;
399
+ private isPrefetchEmpty;
383
400
  /** Background body-cache backfill. Public so the Reconciler can schedule
384
401
  * the periodic tick under its priority/back-pressure rules; existing
385
402
  * in-method post-sync nudges (sync, fetchSince, fetchOne) call this
@@ -388,6 +405,13 @@ export declare class ImapManager extends EventEmitter {
388
405
  private _prefetchBodies;
389
406
  /** Get the body store for direct access */
390
407
  getBodyStore(): FileMessageStore;
408
+ /** Re-parse existing .eml files from disk to backfill has_attachments
409
+ * + preview for messages whose body landed before the prefetch-reparse
410
+ * fix shipped. No IMAP traffic — pure local-disk work. Throttled by the
411
+ * reconciler the same way as prefetch (back off when interactive lane
412
+ * has clicks pending) so it doesn't fight with the user. */
413
+ private backfillingAccounts;
414
+ backfillBodyMeta(accountId: string, limit?: number): Promise<void>;
391
415
  /** Move message across accounts using iflow's moveMessageToServer */
392
416
  moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
393
417
  /** Process pending sync actions for an account */
package/index.js CHANGED
@@ -951,11 +951,13 @@ export class ImapManager extends EventEmitter {
951
951
  let bodyPath = "";
952
952
  let preview = "";
953
953
  let hasAttachments = false;
954
+ let ftsBody = "";
954
955
  if (source) {
955
956
  bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
956
957
  const parsed = await extractPreview(source);
957
958
  preview = parsed.preview;
958
959
  hasAttachments = parsed.hasAttachments;
960
+ ftsBody = parsed.bodyText || "";
959
961
  }
960
962
  const flags = [];
961
963
  if (msg.seen)
@@ -966,7 +968,7 @@ export class ImapManager extends EventEmitter {
966
968
  flags.push("\\Answered");
967
969
  if (msg.draft)
968
970
  flags.push("\\Draft");
969
- this.db.upsertMessage({
971
+ const ftsRowId = this.db.upsertMessage({
970
972
  accountId, folderId, uid: msg.uid,
971
973
  messageId: msg.messageId || "",
972
974
  inReplyTo: msg.inReplyTo || "",
@@ -978,6 +980,12 @@ export class ImapManager extends EventEmitter {
978
980
  cc: toEmailAddresses(msg.cc || []),
979
981
  flags, size: msg.size || 0, hasAttachments, preview, bodyPath
980
982
  });
983
+ // Index the full body for search. extractPreview already
984
+ // parsed `source`, so the body text is free here — and
985
+ // indexing it at sync time means a word buried in the body
986
+ // is searchable without the user first opening the message.
987
+ if (ftsRowId && ftsBody)
988
+ this.db.updateFtsBody(ftsRowId, ftsBody);
981
989
  stored++;
982
990
  batchCount++;
983
991
  if (batchCount >= BATCH_SIZE) {
@@ -1476,7 +1484,7 @@ export class ImapManager extends EventEmitter {
1476
1484
  if (msg.draft)
1477
1485
  flags.push("\\Draft");
1478
1486
  // Store metadata
1479
- this.db.upsertMessage({
1487
+ const ftsRowId = this.db.upsertMessage({
1480
1488
  accountId,
1481
1489
  folderId,
1482
1490
  uid: msg.uid,
@@ -1494,6 +1502,11 @@ export class ImapManager extends EventEmitter {
1494
1502
  preview: parsed.preview,
1495
1503
  bodyPath
1496
1504
  });
1505
+ // Full body into the FTS index — the parse above already
1506
+ // produced the text, so search covers body content even
1507
+ // for messages the user hasn't opened.
1508
+ if (ftsRowId && parsed.bodyText)
1509
+ this.db.updateFtsBody(ftsRowId, parsed.bodyText);
1497
1510
  newCount++;
1498
1511
  }
1499
1512
  this.db.commitTransaction();
@@ -2704,6 +2717,36 @@ export class ImapManager extends EventEmitter {
2704
2717
  clearFolderErrors(accountId, folderPath) {
2705
2718
  this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
2706
2719
  }
2720
+ /** UIDs that returned 0 bodies on a successful batch — either stale DB rows
2721
+ * whose UIDVALIDITY has rotated, or a transient iflow parser miss that
2722
+ * dropped the body literal mid-stream. The "NOT pruning" safety guard in
2723
+ * `_prefetchBodies` refuses to delete them so a transient miss can't
2724
+ * trigger data loss; the trade-off is they sit in `getMessagesWithoutBody`
2725
+ * and would otherwise dominate the size-asc query, starving live folders.
2726
+ *
2727
+ * Backoff schedule on repeated failure: 5 min → 30 min → 2 h → 12 h.
2728
+ * Without a TTL the very first transient miss permanently blocklists the
2729
+ * UID for the session and the row never shows the cached-body dot
2730
+ * (the symptom Bob reported 2026-05-19: document/large messages with
2731
+ * no teal mark). With TTL, transient misses self-heal in 5 minutes;
2732
+ * genuine zombies back off to once-per-12-hour retries — bounded load.
2733
+ * Key shape `${accountId}:${folderId}:${uid}`. */
2734
+ prefetchFailures = new Map();
2735
+ markPrefetchEmpty(accountId, folderId, uid) {
2736
+ const key = `${accountId}:${folderId}:${uid}`;
2737
+ const prev = this.prefetchFailures.get(key);
2738
+ this.prefetchFailures.set(key, { count: (prev?.count ?? 0) + 1, lastTried: Date.now() });
2739
+ }
2740
+ isPrefetchEmpty(accountId, folderId, uid) {
2741
+ const f = this.prefetchFailures.get(`${accountId}:${folderId}:${uid}`);
2742
+ if (!f)
2743
+ return false;
2744
+ const backoffMs = f.count <= 1 ? 5 * 60_000
2745
+ : f.count <= 2 ? 30 * 60_000
2746
+ : f.count <= 3 ? 2 * 3600_000
2747
+ : 12 * 3600_000;
2748
+ return Date.now() - f.lastTried < backoffMs;
2749
+ }
2707
2750
  /** Background body-cache backfill. Public so the Reconciler can schedule
2708
2751
  * the periodic tick under its priority/back-pressure rules; existing
2709
2752
  * in-method post-sync nudges (sync, fetchSince, fetchOne) call this
@@ -2739,8 +2782,21 @@ export class ImapManager extends EventEmitter {
2739
2782
  // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
2740
2783
  // one SELECT + one UID FETCH per folder per tick instead of N round trips.
2741
2784
  let announced = false;
2742
- while (true) {
2743
- const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
2785
+ // Cap iterations so a fully-blocked queue can't loop forever. With
2786
+ // BATCH_SIZE=100 and the blocklist filter below, each iteration
2787
+ // either advances or terminates; this bound is a belt-and-braces
2788
+ // safety net for unexpected DB states.
2789
+ let iterationsRemaining = 50;
2790
+ while (iterationsRemaining-- > 0) {
2791
+ // Pull more than BATCH_SIZE from the DB so the empty-uid filter
2792
+ // can drop blocklisted rows and still hand a useful batch to the
2793
+ // fetcher. Without this, a queue dominated by stale UIDs returns
2794
+ // BATCH_SIZE rows that all get filtered to zero, and the loop
2795
+ // terminates without ever trying live messages further down the
2796
+ // size-asc list.
2797
+ const raw = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4);
2798
+ const missing = raw.filter(m => !this.isPrefetchEmpty(accountId, m.folderId, m.uid))
2799
+ .slice(0, BATCH_SIZE);
2744
2800
  if (missing.length === 0)
2745
2801
  break;
2746
2802
  if (!announced) {
@@ -2781,7 +2837,8 @@ export class ImapManager extends EventEmitter {
2781
2837
  try {
2782
2838
  const raw = Buffer.from(source, "utf-8");
2783
2839
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2784
- this.db.updateBodyPath(accountId, uid, bodyPath);
2840
+ const parsed = await extractPreview(source);
2841
+ this.db.updateBodyMeta(accountId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
2785
2842
  this.emit("bodyCached", accountId, uid);
2786
2843
  counters.totalFetched++;
2787
2844
  madeProgress = true;
@@ -2831,6 +2888,8 @@ export class ImapManager extends EventEmitter {
2831
2888
  }
2832
2889
  else if (batchSucceeded && !someReceived) {
2833
2890
  console.error(` [prefetch] ${accountId}/${folder.path}: Gmail batch returned 0/${uidsInFolder.length} bodies — NOT pruning (set-diff reconcile owns deletion). UIDs: ${uidsInFolder.slice(0, 5).join(",")}${uidsInFolder.length > 5 ? "..." : ""}`);
2891
+ for (const uid of uidsInFolder)
2892
+ this.markPrefetchEmpty(accountId, folderId, uid);
2834
2893
  }
2835
2894
  if (counters.errors >= ERROR_BUDGET)
2836
2895
  break;
@@ -2910,7 +2969,8 @@ export class ImapManager extends EventEmitter {
2910
2969
  try {
2911
2970
  const raw = Buffer.from(source, "utf-8");
2912
2971
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2913
- this.db.updateBodyPath(accountId, uid, bodyPath);
2972
+ const parsed = await extractPreview(source);
2973
+ this.db.updateBodyMeta(accountId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
2914
2974
  this.emit("bodyCached", accountId, uid);
2915
2975
  counters.totalFetched++;
2916
2976
  madeProgress = true;
@@ -2961,6 +3021,8 @@ export class ImapManager extends EventEmitter {
2961
3021
  }
2962
3022
  else if (batchSucceeded && !someReceived) {
2963
3023
  console.error(` [prefetch] ${accountId}/${folder.path}: chunk ${chunkStart}-${chunkStart + chunk.length - 1} returned 0/${chunk.length} bodies — NOT pruning (set-diff reconcile owns deletion). UIDs: ${chunk.slice(0, 5).join(",")}${chunk.length > 5 ? "..." : ""}`);
3024
+ for (const uid of chunk)
3025
+ this.markPrefetchEmpty(accountId, folderId, uid);
2964
3026
  }
2965
3027
  }
2966
3028
  if (counters.errors >= ERROR_BUDGET)
@@ -2987,6 +3049,49 @@ export class ImapManager extends EventEmitter {
2987
3049
  getBodyStore() {
2988
3050
  return this.bodyStore;
2989
3051
  }
3052
+ /** Re-parse existing .eml files from disk to backfill has_attachments
3053
+ * + preview for messages whose body landed before the prefetch-reparse
3054
+ * fix shipped. No IMAP traffic — pure local-disk work. Throttled by the
3055
+ * reconciler the same way as prefetch (back off when interactive lane
3056
+ * has clicks pending) so it doesn't fight with the user. */
3057
+ backfillingAccounts = new Set();
3058
+ async backfillBodyMeta(accountId, limit = 100) {
3059
+ if (this.backfillingAccounts.has(accountId))
3060
+ return;
3061
+ if (!this.configs.has(accountId))
3062
+ return;
3063
+ this.backfillingAccounts.add(accountId);
3064
+ try {
3065
+ const rows = this.db.getMessagesNeedingBodyReparse(accountId, limit);
3066
+ if (rows.length === 0)
3067
+ return;
3068
+ let ok = 0, fail = 0;
3069
+ for (const row of rows) {
3070
+ try {
3071
+ if (!(await this.bodyStore.hasByPath(row.bodyPath))) {
3072
+ // Body file vanished — stamp so we don't keep retrying.
3073
+ this.db.updateBodyMetaById(row.id, false, "");
3074
+ continue;
3075
+ }
3076
+ const buf = await this.bodyStore.readByPath(row.bodyPath);
3077
+ const parsed = await extractPreview(buf.toString("utf-8"));
3078
+ this.db.updateBodyMetaById(row.id, parsed.hasAttachments, parsed.preview);
3079
+ ok++;
3080
+ }
3081
+ catch (e) {
3082
+ fail++;
3083
+ console.error(` [backfill] ${accountId}/${row.uid}: ${e?.message || e}`);
3084
+ }
3085
+ }
3086
+ if (ok > 0 || fail > 0) {
3087
+ console.log(` [backfill] ${accountId}: reparsed ${ok}, failed ${fail}`);
3088
+ this.emit("folderCountsChanged", accountId, {});
3089
+ }
3090
+ }
3091
+ finally {
3092
+ this.backfillingAccounts.delete(accountId);
3093
+ }
3094
+ }
2990
3095
  /** Move message across accounts using iflow's moveMessageToServer */
2991
3096
  async moveMessageCrossAccount(fromAccountId, uid, fromFolderId, toAccountId, toFolderId) {
2992
3097
  const fromFolders = this.db.getFolders(fromAccountId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.15",
13
- "@bobfrankston/mailx-settings": "^0.1.17",
14
- "@bobfrankston/mailx-store": "^0.1.28",
12
+ "@bobfrankston/mailx-types": "^0.1.18",
13
+ "@bobfrankston/mailx-settings": "^0.1.20",
14
+ "@bobfrankston/mailx-store": "^0.1.30",
15
15
  "@bobfrankston/iflow-direct": "^0.1.50",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
18
- "@bobfrankston/mailx-sync": "^0.1.17",
19
- "@bobfrankston/oauthsupport": "^1.0.26"
18
+ "@bobfrankston/mailx-sync": "^0.1.19",
19
+ "@bobfrankston/oauthsupport": "^1.0.27"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
@@ -37,14 +37,14 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.15",
41
- "@bobfrankston/mailx-settings": "^0.1.17",
42
- "@bobfrankston/mailx-store": "^0.1.28",
40
+ "@bobfrankston/mailx-types": "^0.1.18",
41
+ "@bobfrankston/mailx-settings": "^0.1.20",
42
+ "@bobfrankston/mailx-store": "^0.1.30",
43
43
  "@bobfrankston/iflow-direct": "^0.1.50",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",
46
- "@bobfrankston/mailx-sync": "^0.1.17",
47
- "@bobfrankston/oauthsupport": "^1.0.26"
46
+ "@bobfrankston/mailx-sync": "^0.1.19",
47
+ "@bobfrankston/oauthsupport": "^1.0.27"
48
48
  }
49
49
  }
50
50
  }