@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.
- package/index.d.ts +24 -0
- package/index.js +111 -6
- 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
|
-
|
|
2743
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
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.
|
|
19
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
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.
|
|
41
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
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.
|
|
47
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
46
|
+
"@bobfrankston/mailx-sync": "^0.1.19",
|
|
47
|
+
"@bobfrankston/oauthsupport": "^1.0.27"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|