@bobfrankston/rmfmail 1.1.99 → 1.1.102

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 (50) hide show
  1. package/client/android-bootstrap.bundle.js.map +2 -2
  2. package/client/app.bundle.js +44 -3
  3. package/client/app.bundle.js.map +2 -2
  4. package/client/components/message-list.js +55 -3
  5. package/client/components/message-list.js.map +1 -1
  6. package/client/components/message-list.ts +56 -3
  7. package/client/components/message-viewer.js +5 -1
  8. package/client/components/message-viewer.js.map +1 -1
  9. package/client/components/message-viewer.ts +5 -1
  10. package/client/compose/compose.bundle.js +11 -1
  11. package/client/compose/compose.bundle.js.map +2 -2
  12. package/client/compose/compose.js +22 -1
  13. package/client/compose/compose.js.map +1 -1
  14. package/client/compose/compose.ts +22 -1
  15. package/client/styles/components.css +10 -0
  16. package/npmchanges.md +19 -0
  17. package/package.json +5 -5
  18. package/packages/mailx-core/index.d.ts +1 -0
  19. package/packages/mailx-core/index.d.ts.map +1 -1
  20. package/packages/mailx-imap/index.d.ts +24 -0
  21. package/packages/mailx-imap/index.d.ts.map +1 -1
  22. package/packages/mailx-imap/index.js +117 -19
  23. package/packages/mailx-imap/index.js.map +1 -1
  24. package/packages/mailx-imap/index.ts +113 -18
  25. package/packages/mailx-imap/package-lock.json +2 -2
  26. package/packages/mailx-imap/package.json +1 -1
  27. package/packages/mailx-service/index.d.ts.map +1 -1
  28. package/packages/mailx-service/index.js +17 -0
  29. package/packages/mailx-service/index.js.map +1 -1
  30. package/packages/mailx-service/index.ts +15 -0
  31. package/packages/mailx-service/reconciler.d.ts.map +1 -1
  32. package/packages/mailx-service/reconciler.js +4 -0
  33. package/packages/mailx-service/reconciler.js.map +1 -1
  34. package/packages/mailx-service/reconciler.ts +6 -0
  35. package/packages/mailx-settings/docs/rmf-tiny.md +31 -2
  36. package/packages/mailx-settings/package.json +1 -1
  37. package/packages/mailx-store/db.d.ts +27 -0
  38. package/packages/mailx-store/db.d.ts.map +1 -1
  39. package/packages/mailx-store/db.js +107 -5
  40. package/packages/mailx-store/db.js.map +1 -1
  41. package/packages/mailx-store/db.ts +120 -5
  42. package/packages/mailx-store/package.json +1 -1
  43. package/packages/mailx-store-web/package.json +1 -1
  44. package/packages/mailx-types/index.d.ts +1 -0
  45. package/packages/mailx-types/index.d.ts.map +1 -1
  46. package/packages/mailx-types/index.js.map +1 -1
  47. package/packages/mailx-types/index.ts +1 -0
  48. package/packages/mailx-types/package.json +1 -1
  49. package/.commitmsg +0 -16
  50. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-75440 → node_modules.npmglobalize-stash-47276}/.package-lock.json +0 -0
@@ -2717,6 +2717,36 @@ export class ImapManager extends EventEmitter {
2717
2717
  clearFolderErrors(accountId, folderPath) {
2718
2718
  this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
2719
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
+ }
2720
2750
  /** Background body-cache backfill. Public so the Reconciler can schedule
2721
2751
  * the periodic tick under its priority/back-pressure rules; existing
2722
2752
  * in-method post-sync nudges (sync, fetchSince, fetchOne) call this
@@ -2752,8 +2782,21 @@ export class ImapManager extends EventEmitter {
2752
2782
  // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
2753
2783
  // one SELECT + one UID FETCH per folder per tick instead of N round trips.
2754
2784
  let announced = false;
2755
- while (true) {
2756
- 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);
2757
2800
  if (missing.length === 0)
2758
2801
  break;
2759
2802
  if (!announced) {
@@ -2794,7 +2837,8 @@ export class ImapManager extends EventEmitter {
2794
2837
  try {
2795
2838
  const raw = Buffer.from(source, "utf-8");
2796
2839
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2797
- this.db.updateBodyPath(accountId, uid, bodyPath);
2840
+ const parsed = await extractPreview(source);
2841
+ this.db.updateBodyMeta(accountId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
2798
2842
  this.emit("bodyCached", accountId, uid);
2799
2843
  counters.totalFetched++;
2800
2844
  madeProgress = true;
@@ -2830,20 +2874,22 @@ export class ImapManager extends EventEmitter {
2830
2874
  // owns deletion via a 30-min grace; defer to it.
2831
2875
  const someReceived = received.size > 0;
2832
2876
  if (batchSucceeded && someReceived) {
2877
+ // Mirror of the IMAP-path fix: never delete on a
2878
+ // partial batch — Gmail API has its own transient
2879
+ // miss modes (rate-limit retry losing a message,
2880
+ // /batch response parse error) that look exactly
2881
+ // like server-side expunge. Set-diff reconcile in
2882
+ // syncAccountViaApi owns deletion.
2833
2883
  for (const uid of uidsInFolder) {
2834
2884
  if (received.has(uid))
2835
2885
  continue;
2836
- try {
2837
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2838
- this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID — assumed deleted", "mailx-imap prefetchBodies (Gmail batch)");
2839
- counters.deleted++;
2840
- madeProgress = true;
2841
- }
2842
- catch { /* ignore */ }
2886
+ this.markPrefetchEmpty(accountId, folderId, uid);
2843
2887
  }
2844
2888
  }
2845
2889
  else if (batchSucceeded && !someReceived) {
2846
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);
2847
2893
  }
2848
2894
  if (counters.errors >= ERROR_BUDGET)
2849
2895
  break;
@@ -2923,7 +2969,8 @@ export class ImapManager extends EventEmitter {
2923
2969
  try {
2924
2970
  const raw = Buffer.from(source, "utf-8");
2925
2971
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2926
- this.db.updateBodyPath(accountId, uid, bodyPath);
2972
+ const parsed = await extractPreview(source);
2973
+ this.db.updateBodyMeta(accountId, uid, bodyPath, parsed.hasAttachments, parsed.preview);
2927
2974
  this.emit("bodyCached", accountId, uid);
2928
2975
  counters.totalFetched++;
2929
2976
  madeProgress = true;
@@ -2960,20 +3007,28 @@ export class ImapManager extends EventEmitter {
2960
3007
  // authoritative deletion path with a 30-min
2961
3008
  // grace window; prefetch defers to it.
2962
3009
  const someReceived = received.size > 0;
2963
- if (batchSucceeded && someReceived)
3010
+ if (batchSucceeded && someReceived) {
3011
+ // DO NOT DELETE missing UIDs. A partial response is
3012
+ // an iflow parser miss / mid-stream hiccup MUCH more
3013
+ // often than a real server expunge — and deleting on
3014
+ // that signal cost Bob a Bambu Labs verification
3015
+ // code (audit id 3785, 2026-05-20), plus dozens of
3016
+ // other valid messages over the day. Set-diff
3017
+ // reconcile in syncFolder is the authoritative
3018
+ // deletion path with a 30-min grace window; prefetch
3019
+ // defers to it. Mark the UIDs as prefetch-empty so
3020
+ // the TTL backoff (5min → 12h) retries them — that
3021
+ // path is non-destructive.
2964
3022
  for (const uid of chunk) {
2965
3023
  if (received.has(uid))
2966
3024
  continue;
2967
- try {
2968
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2969
- this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID — assumed deleted", "mailx-imap prefetchBodies (IMAP batch)");
2970
- counters.deleted++;
2971
- madeProgress = true;
2972
- }
2973
- catch { /* ignore */ }
3025
+ this.markPrefetchEmpty(accountId, folderId, uid);
2974
3026
  }
3027
+ }
2975
3028
  else if (batchSucceeded && !someReceived) {
2976
3029
  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 ? "..." : ""}`);
3030
+ for (const uid of chunk)
3031
+ this.markPrefetchEmpty(accountId, folderId, uid);
2977
3032
  }
2978
3033
  }
2979
3034
  if (counters.errors >= ERROR_BUDGET)
@@ -3000,6 +3055,49 @@ export class ImapManager extends EventEmitter {
3000
3055
  getBodyStore() {
3001
3056
  return this.bodyStore;
3002
3057
  }
3058
+ /** Re-parse existing .eml files from disk to backfill has_attachments
3059
+ * + preview for messages whose body landed before the prefetch-reparse
3060
+ * fix shipped. No IMAP traffic — pure local-disk work. Throttled by the
3061
+ * reconciler the same way as prefetch (back off when interactive lane
3062
+ * has clicks pending) so it doesn't fight with the user. */
3063
+ backfillingAccounts = new Set();
3064
+ async backfillBodyMeta(accountId, limit = 100) {
3065
+ if (this.backfillingAccounts.has(accountId))
3066
+ return;
3067
+ if (!this.configs.has(accountId))
3068
+ return;
3069
+ this.backfillingAccounts.add(accountId);
3070
+ try {
3071
+ const rows = this.db.getMessagesNeedingBodyReparse(accountId, limit);
3072
+ if (rows.length === 0)
3073
+ return;
3074
+ let ok = 0, fail = 0;
3075
+ for (const row of rows) {
3076
+ try {
3077
+ if (!(await this.bodyStore.hasByPath(row.bodyPath))) {
3078
+ // Body file vanished — stamp so we don't keep retrying.
3079
+ this.db.updateBodyMetaById(row.id, false, "");
3080
+ continue;
3081
+ }
3082
+ const buf = await this.bodyStore.readByPath(row.bodyPath);
3083
+ const parsed = await extractPreview(buf.toString("utf-8"));
3084
+ this.db.updateBodyMetaById(row.id, parsed.hasAttachments, parsed.preview);
3085
+ ok++;
3086
+ }
3087
+ catch (e) {
3088
+ fail++;
3089
+ console.error(` [backfill] ${accountId}/${row.uid}: ${e?.message || e}`);
3090
+ }
3091
+ }
3092
+ if (ok > 0 || fail > 0) {
3093
+ console.log(` [backfill] ${accountId}: reparsed ${ok}, failed ${fail}`);
3094
+ this.emit("folderCountsChanged", accountId, {});
3095
+ }
3096
+ }
3097
+ finally {
3098
+ this.backfillingAccounts.delete(accountId);
3099
+ }
3100
+ }
3003
3101
  /** Move message across accounts using iflow's moveMessageToServer */
3004
3102
  async moveMessageCrossAccount(fromAccountId, uid, fromFolderId, toAccountId, toFolderId) {
3005
3103
  const fromFolders = this.db.getFolders(fromAccountId);