@bobfrankston/mailx-imap 0.1.98 → 0.1.100
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 +13 -0
- package/index.js +56 -0
- package/package.json +5 -5
package/index.d.ts
CHANGED
|
@@ -354,6 +354,19 @@ export declare class ImapManager extends EventEmitter {
|
|
|
354
354
|
* before _Spam / Sent / archive folders had a chance to rebind. */
|
|
355
355
|
private static readonly RECONCILE_DELETE_GRACE_MS;
|
|
356
356
|
private scheduleDeferredReconcileDelete;
|
|
357
|
+
/** Delete local rows whose uid >= the server's UIDNEXT for this folder.
|
|
358
|
+
* Such a uid was NEVER assigned in this folder (UIDNEXT is the next one the
|
|
359
|
+
* server will ever hand out), so the row is a cross-wired phantom ("UID is
|
|
360
|
+
* not identity" corruption) — a real message stamped with the wrong
|
|
361
|
+
* folder_id. They break prefetch (the server returns 0 FETCH responses for
|
|
362
|
+
* uids it doesn't have → no body caches) and the normal set-diff reconcile
|
|
363
|
+
* refuses to remove them when they're >50% of the folder. Deleting by the
|
|
364
|
+
* UIDNEXT invariant is unconditional-safe: the uid is impossible, can't be a
|
|
365
|
+
* move target, and the message's real copy re-syncs from its true folder
|
|
366
|
+
* (the local store is a cache). Caller must have a TRUSTWORTHY uidNext
|
|
367
|
+
* (STATUS result) AND confirmed UIDVALIDITY is unchanged for this folder
|
|
368
|
+
* (full-sync and QRESYNC-success paths both satisfy that). Returns the count. */
|
|
369
|
+
private sweepPhantomRows;
|
|
357
370
|
private unlinkBodyFile;
|
|
358
371
|
/** Fetch a single message body on demand, caching in the store.
|
|
359
372
|
*
|
package/index.js
CHANGED
|
@@ -1361,6 +1361,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1361
1361
|
// where a full UID SEARCH is cheap and reveals server-side deletes
|
|
1362
1362
|
// of older messages).
|
|
1363
1363
|
let statusMessageCount = null;
|
|
1364
|
+
let serverUidNext = null;
|
|
1364
1365
|
if (highestUid > 0) {
|
|
1365
1366
|
try {
|
|
1366
1367
|
console.log(` [sync-status] ${accountId}/${folder.path}: calling STATUS...`);
|
|
@@ -1371,6 +1372,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1371
1372
|
if (status && typeof status.messages === "number")
|
|
1372
1373
|
statusMessageCount = status.messages;
|
|
1373
1374
|
if (status && typeof status.uidNext === "number") {
|
|
1375
|
+
serverUidNext = status.uidNext;
|
|
1374
1376
|
const serverHighest = status.uidNext - 1;
|
|
1375
1377
|
const noNewUids = serverHighest <= highestUid;
|
|
1376
1378
|
const countsMatch = typeof status.messages === "number" && status.messages === localCount;
|
|
@@ -1503,6 +1505,14 @@ export class ImapManager extends EventEmitter {
|
|
|
1503
1505
|
if (qr.newHighestModSeq !== undefined) {
|
|
1504
1506
|
this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
|
|
1505
1507
|
}
|
|
1508
|
+
// QRESYNC confirmed UIDVALIDITY is unchanged (it falls back to
|
|
1509
|
+
// full sync otherwise), so serverUidNext is trustworthy — sweep
|
|
1510
|
+
// phantom (uid >= UIDNEXT) cross-wired rows here too. Without
|
|
1511
|
+
// this, folders with a stable modseq (most of them) take the
|
|
1512
|
+
// QRESYNC fast path forever and their phantoms never get swept
|
|
1513
|
+
// (Bob 2026-06-17: CEBog phantoms persisted through the
|
|
1514
|
+
// reconcile-only sweep because CEBog syncs via QRESYNC).
|
|
1515
|
+
this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
|
|
1506
1516
|
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1507
1517
|
console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
|
|
1508
1518
|
return newOnes.length;
|
|
@@ -1813,6 +1823,20 @@ export class ImapManager extends EventEmitter {
|
|
|
1813
1823
|
// data (the "ubiquiti letter disappeared after reply" case had no trace).
|
|
1814
1824
|
let deletedCount = 0;
|
|
1815
1825
|
if (!firstSync) {
|
|
1826
|
+
// PHANTOM SWEEP (Bob 2026-06-17). A local row whose uid >= the server's
|
|
1827
|
+
// UIDNEXT is PROVABLY impossible: UIDNEXT is the next uid the server
|
|
1828
|
+
// will ever assign in this folder, so any uid >= it was never assigned
|
|
1829
|
+
// here. These are cross-wired rows ("UID is not identity" corruption —
|
|
1830
|
+
// CEBog had 106/120 rows with uid >= UIDNEXT=94, real messages stamped
|
|
1831
|
+
// with the wrong folder_id). They break prefetch: it asks the server
|
|
1832
|
+
// for uids the folder doesn't have → 0 FETCH responses → no body ever
|
|
1833
|
+
// caches. The normal set-diff reconcile WON'T remove them because the
|
|
1834
|
+
// 50%-deletion safety guard refuses (88% of CEBog would delete). But
|
|
1835
|
+
// these uids can't be a real message, a move target, or a transient
|
|
1836
|
+
// server hiccup — so delete them unconditionally (the real copy lives
|
|
1837
|
+
// in its true folder and re-syncs from there; the local store is a
|
|
1838
|
+
// cache). Only runs when STATUS gave a trustworthy UIDNEXT.
|
|
1839
|
+
deletedCount += this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
|
|
1816
1840
|
try {
|
|
1817
1841
|
// Reuse the server UID list set-diff already fetched.
|
|
1818
1842
|
// Without this we made TWO `UID SEARCH` calls per folder
|
|
@@ -2941,6 +2965,38 @@ export class ImapManager extends EventEmitter {
|
|
|
2941
2965
|
}, ImapManager.RECONCILE_DELETE_GRACE_MS);
|
|
2942
2966
|
this.deferredDeletes.set(key, t);
|
|
2943
2967
|
}
|
|
2968
|
+
/** Delete local rows whose uid >= the server's UIDNEXT for this folder.
|
|
2969
|
+
* Such a uid was NEVER assigned in this folder (UIDNEXT is the next one the
|
|
2970
|
+
* server will ever hand out), so the row is a cross-wired phantom ("UID is
|
|
2971
|
+
* not identity" corruption) — a real message stamped with the wrong
|
|
2972
|
+
* folder_id. They break prefetch (the server returns 0 FETCH responses for
|
|
2973
|
+
* uids it doesn't have → no body caches) and the normal set-diff reconcile
|
|
2974
|
+
* refuses to remove them when they're >50% of the folder. Deleting by the
|
|
2975
|
+
* UIDNEXT invariant is unconditional-safe: the uid is impossible, can't be a
|
|
2976
|
+
* move target, and the message's real copy re-syncs from its true folder
|
|
2977
|
+
* (the local store is a cache). Caller must have a TRUSTWORTHY uidNext
|
|
2978
|
+
* (STATUS result) AND confirmed UIDVALIDITY is unchanged for this folder
|
|
2979
|
+
* (full-sync and QRESYNC-success paths both satisfy that). Returns the count. */
|
|
2980
|
+
sweepPhantomRows(accountId, folderId, folderPath, serverUidNext) {
|
|
2981
|
+
if (serverUidNext == null || serverUidNext <= 0)
|
|
2982
|
+
return 0;
|
|
2983
|
+
try {
|
|
2984
|
+
const phantoms = this.db.getUidsForFolder(accountId, folderId).filter(u => u >= serverUidNext);
|
|
2985
|
+
if (phantoms.length === 0)
|
|
2986
|
+
return 0;
|
|
2987
|
+
for (const uid of phantoms) {
|
|
2988
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
2989
|
+
this.db.deleteMessage(accountId, folderId, uid, `phantom: uid>=server UIDNEXT ${serverUidNext}`, `mailx-imap phantom sweep (${folderPath})`);
|
|
2990
|
+
}
|
|
2991
|
+
this.db.recalcFolderCounts(folderId);
|
|
2992
|
+
console.log(` [phantom-sweep] ${accountId}/${folderPath}: removed ${phantoms.length} cross-wired row(s) with uid >= UIDNEXT ${serverUidNext}`);
|
|
2993
|
+
return phantoms.length;
|
|
2994
|
+
}
|
|
2995
|
+
catch (e) {
|
|
2996
|
+
console.error(` [phantom-sweep] ${accountId}/${folderPath}: ${e?.message || e}`);
|
|
2997
|
+
return 0;
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
2944
3000
|
async unlinkBodyFile(accountId, uid, folderId) {
|
|
2945
3001
|
try {
|
|
2946
3002
|
const row = this.db.getMessageByUid(accountId, uid, folderId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.100",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.28",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.54",
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
18
18
|
"@bobfrankston/mailx-sync": "^0.1.22",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.28",
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.54",
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
46
46
|
"@bobfrankston/mailx-sync": "^0.1.22",
|