@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.
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +44 -3
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-list.js +55 -3
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +56 -3
- package/client/components/message-viewer.js +5 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +5 -1
- package/client/compose/compose.bundle.js +11 -1
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.js +22 -1
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +22 -1
- package/client/styles/components.css +10 -0
- package/npmchanges.md +19 -0
- package/package.json +5 -5
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-core/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +117 -19
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +113 -18
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +17 -0
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +15 -0
- package/packages/mailx-service/reconciler.d.ts.map +1 -1
- package/packages/mailx-service/reconciler.js +4 -0
- package/packages/mailx-service/reconciler.js.map +1 -1
- package/packages/mailx-service/reconciler.ts +6 -0
- package/packages/mailx-settings/docs/rmf-tiny.md +31 -2
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts +27 -0
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +107 -5
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +120 -5
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/index.d.ts +1 -0
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +1 -0
- package/packages/mailx-types/package.json +1 -1
- package/.commitmsg +0 -16
- /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
|
-
|
|
2756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|