@bobfrankston/mailx-imap 0.1.99 → 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.
Files changed (3) hide show
  1. package/index.d.ts +13 -0
  2. package/index.js +41 -17
  3. package/package.json +3 -3
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
@@ -1505,6 +1505,14 @@ export class ImapManager extends EventEmitter {
1505
1505
  if (qr.newHighestModSeq !== undefined) {
1506
1506
  this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
1507
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);
1508
1516
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1509
1517
  console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1510
1518
  return newOnes.length;
@@ -1828,23 +1836,7 @@ export class ImapManager extends EventEmitter {
1828
1836
  // server hiccup — so delete them unconditionally (the real copy lives
1829
1837
  // in its true folder and re-syncs from there; the local store is a
1830
1838
  // cache). Only runs when STATUS gave a trustworthy UIDNEXT.
1831
- if (serverUidNext != null && serverUidNext > 0) {
1832
- try {
1833
- const phantoms = this.db.getUidsForFolder(accountId, folderId).filter(u => u >= serverUidNext);
1834
- if (phantoms.length > 0) {
1835
- for (const uid of phantoms) {
1836
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1837
- this.db.deleteMessage(accountId, folderId, uid, `phantom: uid>=server UIDNEXT ${serverUidNext}`, `mailx-imap phantom sweep (${folder.path})`);
1838
- }
1839
- deletedCount += phantoms.length;
1840
- this.db.recalcFolderCounts(folderId);
1841
- console.log(` [phantom-sweep] ${accountId}/${folder.path}: removed ${phantoms.length} cross-wired row(s) with uid >= UIDNEXT ${serverUidNext}`);
1842
- }
1843
- }
1844
- catch (e) {
1845
- console.error(` [phantom-sweep] ${accountId}/${folder.path}: ${e?.message || e}`);
1846
- }
1847
- }
1839
+ deletedCount += this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
1848
1840
  try {
1849
1841
  // Reuse the server UID list set-diff already fetched.
1850
1842
  // Without this we made TWO `UID SEARCH` calls per folder
@@ -2973,6 +2965,38 @@ export class ImapManager extends EventEmitter {
2973
2965
  }, ImapManager.RECONCILE_DELETE_GRACE_MS);
2974
2966
  this.deferredDeletes.set(key, t);
2975
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
+ }
2976
3000
  async unlinkBodyFile(accountId, uid, folderId) {
2977
3001
  try {
2978
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.99",
3
+ "version": "0.1.100",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,7 +11,7 @@
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.53",
14
+ "@bobfrankston/mailx-store": "^0.1.54",
15
15
  "@bobfrankston/iflow-direct": "^0.1.55",
16
16
  "@bobfrankston/tcp-transport": "^0.1.7",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
@@ -39,7 +39,7 @@
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.53",
42
+ "@bobfrankston/mailx-store": "^0.1.54",
43
43
  "@bobfrankston/iflow-direct": "^0.1.55",
44
44
  "@bobfrankston/tcp-transport": "^0.1.7",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",