@bobfrankston/rmfmail 1.2.29 → 1.2.31

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.
@@ -1520,6 +1520,15 @@ export class ImapManager extends EventEmitter {
1520
1520
  this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
1521
1521
  }
1522
1522
 
1523
+ // QRESYNC confirmed UIDVALIDITY is unchanged (it falls back to
1524
+ // full sync otherwise), so serverUidNext is trustworthy — sweep
1525
+ // phantom (uid >= UIDNEXT) cross-wired rows here too. Without
1526
+ // this, folders with a stable modseq (most of them) take the
1527
+ // QRESYNC fast path forever and their phantoms never get swept
1528
+ // (Bob 2026-06-17: CEBog phantoms persisted through the
1529
+ // reconcile-only sweep because CEBog syncs via QRESYNC).
1530
+ this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
1531
+
1523
1532
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1524
1533
  console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1525
1534
  return newOnes.length;
@@ -1842,20 +1851,7 @@ export class ImapManager extends EventEmitter {
1842
1851
  // server hiccup — so delete them unconditionally (the real copy lives
1843
1852
  // in its true folder and re-syncs from there; the local store is a
1844
1853
  // cache). Only runs when STATUS gave a trustworthy UIDNEXT.
1845
- if (serverUidNext != null && serverUidNext > 0) {
1846
- try {
1847
- const phantoms = this.db.getUidsForFolder(accountId, folderId).filter(u => u >= serverUidNext!);
1848
- if (phantoms.length > 0) {
1849
- for (const uid of phantoms) {
1850
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
1851
- this.db.deleteMessage(accountId, folderId, uid, `phantom: uid>=server UIDNEXT ${serverUidNext}`, `mailx-imap phantom sweep (${folder.path})`);
1852
- }
1853
- deletedCount += phantoms.length;
1854
- this.db.recalcFolderCounts(folderId);
1855
- console.log(` [phantom-sweep] ${accountId}/${folder.path}: removed ${phantoms.length} cross-wired row(s) with uid >= UIDNEXT ${serverUidNext}`);
1856
- }
1857
- } catch (e: any) { console.error(` [phantom-sweep] ${accountId}/${folder.path}: ${e?.message || e}`); }
1858
- }
1854
+ deletedCount += this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
1859
1855
  try {
1860
1856
  // Reuse the server UID list set-diff already fetched.
1861
1857
  // Without this we made TWO `UID SEARCH` calls per folder
@@ -2973,6 +2969,36 @@ export class ImapManager extends EventEmitter {
2973
2969
  this.deferredDeletes.set(key, t);
2974
2970
  }
2975
2971
 
2972
+ /** Delete local rows whose uid >= the server's UIDNEXT for this folder.
2973
+ * Such a uid was NEVER assigned in this folder (UIDNEXT is the next one the
2974
+ * server will ever hand out), so the row is a cross-wired phantom ("UID is
2975
+ * not identity" corruption) — a real message stamped with the wrong
2976
+ * folder_id. They break prefetch (the server returns 0 FETCH responses for
2977
+ * uids it doesn't have → no body caches) and the normal set-diff reconcile
2978
+ * refuses to remove them when they're >50% of the folder. Deleting by the
2979
+ * UIDNEXT invariant is unconditional-safe: the uid is impossible, can't be a
2980
+ * move target, and the message's real copy re-syncs from its true folder
2981
+ * (the local store is a cache). Caller must have a TRUSTWORTHY uidNext
2982
+ * (STATUS result) AND confirmed UIDVALIDITY is unchanged for this folder
2983
+ * (full-sync and QRESYNC-success paths both satisfy that). Returns the count. */
2984
+ private sweepPhantomRows(accountId: string, folderId: number, folderPath: string, serverUidNext: number | null): number {
2985
+ if (serverUidNext == null || serverUidNext <= 0) return 0;
2986
+ try {
2987
+ const phantoms = this.db.getUidsForFolder(accountId, folderId).filter(u => u >= serverUidNext);
2988
+ if (phantoms.length === 0) return 0;
2989
+ for (const uid of phantoms) {
2990
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2991
+ this.db.deleteMessage(accountId, folderId, uid, `phantom: uid>=server UIDNEXT ${serverUidNext}`, `mailx-imap phantom sweep (${folderPath})`);
2992
+ }
2993
+ this.db.recalcFolderCounts(folderId);
2994
+ console.log(` [phantom-sweep] ${accountId}/${folderPath}: removed ${phantoms.length} cross-wired row(s) with uid >= UIDNEXT ${serverUidNext}`);
2995
+ return phantoms.length;
2996
+ } catch (e: any) {
2997
+ console.error(` [phantom-sweep] ${accountId}/${folderPath}: ${e?.message || e}`);
2998
+ return 0;
2999
+ }
3000
+ }
3001
+
2976
3002
  private async unlinkBodyFile(accountId: string, uid: number, folderId?: number): Promise<void> {
2977
3003
  try {
2978
3004
  const row: any = this.db.getMessageByUid(accountId, uid, folderId);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.99",
9
+ "version": "0.1.100",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",