@bobfrankston/mailx-imap 0.1.26 → 0.1.28

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 +17 -0
  2. package/index.js +109 -56
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -257,6 +257,23 @@ export declare class ImapManager extends EventEmitter {
257
257
  /** Unlink the on-disk body file for a message by reading its `body_path`
258
258
  * from the DB. Safe to call either before or after `db.deleteMessage`
259
259
  * — read body_path first, store it, then unlink whenever. */
260
+ /** Per-(account, folder, uid) deferred-delete timer. Reconcile populates
261
+ * this; the timer fires 60s later and re-checks whether the row at that
262
+ * exact (folder, uid) still exists. If yes → really delete (server
263
+ * expunged it). If no → skip (move-detect rebound it elsewhere; the
264
+ * row already moved to a new folder/uid in the same DB row, so the
265
+ * original key no longer matches anything). Net effect: server-side
266
+ * moves preserve UUID + body cache + flags. */
267
+ private deferredDeletes;
268
+ /** Grace window for reconcile-delete. Set to 30 minutes because the
269
+ * full-folder sync loop walks ~96 folders sequentially and each one
270
+ * takes 1-30 seconds; a server-side move from INBOX to a folder that
271
+ * syncs late (alphabetically far from INBOX) can take 10+ minutes
272
+ * before move-detect fires in the destination. 60s was way too short
273
+ * in production — the grace expired and rows were committed-deleted
274
+ * before _Spam / Sent / archive folders had a chance to rebind. */
275
+ private static readonly RECONCILE_DELETE_GRACE_MS;
276
+ private scheduleDeferredReconcileDelete;
260
277
  private unlinkBodyFile;
261
278
  /** Fetch a single message body on demand, caching in the store.
262
279
  *
package/index.js CHANGED
@@ -199,6 +199,19 @@ export class ImapManager extends EventEmitter {
199
199
  this.transportFactory = transportFactory;
200
200
  const storePath = getStorePath();
201
201
  this.bodyStore = new FileMessageStore(storePath);
202
+ // Cancel pending deferred-delete when move-detect rebinds a row.
203
+ // Without this, the source folder's reconcile-delete grace timer
204
+ // would fire 30 minutes after detection for a row that's already
205
+ // moved elsewhere — the user would see the message vanish.
206
+ this.db.setOnMoveDetected((info) => {
207
+ const key = `${info.accountId}:${info.fromFolderId}:${info.fromUid}`;
208
+ const t = this.deferredDeletes.get(key);
209
+ if (t) {
210
+ clearTimeout(t);
211
+ this.deferredDeletes.delete(key);
212
+ console.log(` [reconcile-cancel] ${info.accountId} ${info.fromFolderId}/${info.fromUid}: deferred delete cancelled (move-detect rebound to ${info.toFolderId}/${info.toUid})`);
213
+ }
214
+ });
202
215
  }
203
216
  /** Get OAuth access token for an account (for SMTP auth) */
204
217
  async getOAuthToken(accountId) {
@@ -813,8 +826,15 @@ export class ImapManager extends EventEmitter {
813
826
  const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
814
827
  console.log(` [encoding] subject: "${msg.subject.substring(0, 60)}" hex: ${hex}`);
815
828
  }
816
- if (msg.uid <= highestUid)
817
- continue; // already have it
829
+ // CRITICAL: do NOT skip on `uid <= highestUid`. That check
830
+ // was a major bug — it silently dropped every gap-filled
831
+ // message (gap-fill specifically recovers UIDs in the
832
+ // already-scanned range, all of which are <= highestUid).
833
+ // upsertMessage's UNIQUE(account, folder, uid) constraint
834
+ // already deduplicates: if mailx truly has the row, the
835
+ // upsert turns into an UPDATE (cheap); if it doesn't, the
836
+ // insert proceeds. Trust the constraint — don't second-
837
+ // guess it on a stale highestUid snapshot.
818
838
  // Tombstone check: if the user locally deleted this Message-ID,
819
839
  // don't re-import it. Server-side EXPUNGE may lag, or reconcile
820
840
  // may find the message in an old list snapshot. Without this,
@@ -878,36 +898,11 @@ export class ImapManager extends EventEmitter {
878
898
  // monotonically increasing within a UIDVALIDITY (RFC 3501); a
879
899
  // high-water mark is the right anchor for incremental fetch.
880
900
  const highestUid = this.db.getHighestUid(accountId, folderId);
881
- // STATUS-before-SELECT: ask the server cheaply whether anything has
882
- // changed (UIDNEXT moved or message count differs). STATUS doesn't
883
- // load the mailbox index, doesn't lock the mailbox, doesn't risk the
884
- // SELECT-wedge that's been hammering bobma. Only when STATUS says
885
- // there's actual work to do do we fall through to SELECT+FETCH.
886
- // Apply uniformly to every folder — no special-casing.
887
- try {
888
- if (typeof client.getStatus === "function") {
889
- const status = await client.getStatus(folder.path);
890
- const serverHighest = (status.uidNext || 1) - 1;
891
- const serverCount = status.messages ?? -1;
892
- const localCount = this.db.getMessageCount(accountId, folderId);
893
- const isUpToDate = highestUid > 0
894
- && serverHighest <= highestUid
895
- && serverCount >= 0
896
- && serverCount === localCount;
897
- if (isUpToDate) {
898
- console.log(` [sync] ${accountId}/${folder.path}: STATUS up-to-date (uidNext=${status.uidNext}, server=${serverCount}, local=${localCount}) — skipping SELECT`);
899
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
900
- return 0;
901
- }
902
- console.log(` [sync] ${accountId}/${folder.path}: STATUS uidNext=${status.uidNext} server=${serverCount} local=${localCount} highestUid=${highestUid} — needs SELECT`);
903
- }
904
- }
905
- catch (e) {
906
- // STATUS shouldn't fail, but don't make it load-bearing — fall
907
- // through to the SELECT path on any error and let that path's
908
- // existing handling deal with it.
909
- console.log(` [sync] ${accountId}/${folder.path}: STATUS failed (${e?.message || e}) — falling through to SELECT`);
910
- }
901
+ // STATUS-before-SELECT was here. Removed added a round-trip per
902
+ // folder with no measured benefit on Bob's link, and the speculative
903
+ // "skip SELECT when nothing changed" optimization didn't actually
904
+ // skip anything in practice (server count vs local count nearly
905
+ // always differs slightly because of in-flight deletes/moves).
911
906
  console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
912
907
  let messages;
913
908
  const firstSync = highestUid === 0;
@@ -1027,21 +1022,14 @@ export class ImapManager extends EventEmitter {
1027
1022
  try {
1028
1023
  for (let i = batchStart; i < batchEnd; i++) {
1029
1024
  const msg = messages[i];
1030
- // Skip if we already have this UID
1031
- if (msg.uid <= highestUid) {
1032
- // But update flags in case they changed
1033
- const flags = [];
1034
- if (msg.seen)
1035
- flags.push("\\Seen");
1036
- if (msg.flagged)
1037
- flags.push("\\Flagged");
1038
- if (msg.answered)
1039
- flags.push("\\Answered");
1040
- if (msg.draft)
1041
- flags.push("\\Draft");
1042
- this.db.updateMessageFlags(accountId, msg.uid, flags);
1043
- continue;
1044
- }
1025
+ // CRITICAL: was `if (msg.uid <= highestUid) { update flags; continue }`.
1026
+ // Same bug as the streamy storeMessages path on line ~861:
1027
+ // it dropped every gap-fill message because gap-fills
1028
+ // recover UIDs in the already-scanned range (all <=
1029
+ // highestUid by definition). Trust upsertMessage's
1030
+ // UNIQUE constraint to dedupe — if mailx truly has the
1031
+ // row, the upsert becomes an UPDATE that refreshes
1032
+ // flags too, all in one path.
1045
1033
  // Tombstone check — same reason as the streamy onChunk path
1046
1034
  // at storeMessages: a locally-deleted message that the server
1047
1035
  // hasn't EXPUNGEd yet would otherwise reappear on next sync.
@@ -1132,17 +1120,25 @@ export class ImapManager extends EventEmitter {
1132
1120
  else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1133
1121
  console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1134
1122
  }
1135
- else {
1123
+ else if (toDelete.length > 0) {
1124
+ // DEFERRED DELETE — DO NOT delete immediately. Server-side
1125
+ // moves (Sieve filters, IMAP MOVE from another client)
1126
+ // make a UID disappear from the source folder before
1127
+ // showing up in the destination. mailx syncs folders
1128
+ // sequentially; if we delete on first detection, the
1129
+ // dest's `upsertMessage`-side move-detect can't rebind
1130
+ // (the row is gone), and we lose the UUID + body cache,
1131
+ // forcing a re-fetch from server.
1132
+ //
1133
+ // Instead: schedule the delete 60s out. If between now
1134
+ // and then move-detect rebinds the row (folder_id changes
1135
+ // to dest), the (acc, folder, uid) lookup at fire time
1136
+ // won't match — skip. If still at original (truly gone
1137
+ // from server, not just moved), commit the delete.
1136
1138
  for (const uid of toDelete) {
1137
- const env = this.db.getMessageByUid(accountId, uid);
1138
- const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1139
- console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1140
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1141
- this.db.deleteMessage(accountId, uid, "reconcile: server returned UID list without this row", `mailx-imap syncFolder reconcile (${folder.path})`);
1142
- deletedCount++;
1139
+ this.scheduleDeferredReconcileDelete(accountId, folderId, uid, folder.path);
1143
1140
  }
1144
- if (deletedCount > 0)
1145
- console.log(` removed ${deletedCount} deleted messages`);
1141
+ console.log(` [reconcile-defer] ${accountId}/${folder.path}: scheduled ${toDelete.length} deletes (60s grace for move-detect)`);
1146
1142
  }
1147
1143
  }
1148
1144
  catch (e) {
@@ -1935,6 +1931,63 @@ export class ImapManager extends EventEmitter {
1935
1931
  /** Unlink the on-disk body file for a message by reading its `body_path`
1936
1932
  * from the DB. Safe to call either before or after `db.deleteMessage`
1937
1933
  * — read body_path first, store it, then unlink whenever. */
1934
+ /** Per-(account, folder, uid) deferred-delete timer. Reconcile populates
1935
+ * this; the timer fires 60s later and re-checks whether the row at that
1936
+ * exact (folder, uid) still exists. If yes → really delete (server
1937
+ * expunged it). If no → skip (move-detect rebound it elsewhere; the
1938
+ * row already moved to a new folder/uid in the same DB row, so the
1939
+ * original key no longer matches anything). Net effect: server-side
1940
+ * moves preserve UUID + body cache + flags. */
1941
+ deferredDeletes = new Map();
1942
+ /** Grace window for reconcile-delete. Set to 30 minutes because the
1943
+ * full-folder sync loop walks ~96 folders sequentially and each one
1944
+ * takes 1-30 seconds; a server-side move from INBOX to a folder that
1945
+ * syncs late (alphabetically far from INBOX) can take 10+ minutes
1946
+ * before move-detect fires in the destination. 60s was way too short
1947
+ * in production — the grace expired and rows were committed-deleted
1948
+ * before _Spam / Sent / archive folders had a chance to rebind. */
1949
+ static RECONCILE_DELETE_GRACE_MS = 30 * 60_000;
1950
+ scheduleDeferredReconcileDelete(accountId, folderId, uid, folderPath) {
1951
+ const key = `${accountId}:${folderId}:${uid}`;
1952
+ // If already pending, don't reset — the grace clock runs from the
1953
+ // FIRST detection so a flapping server can't keep deferring.
1954
+ if (this.deferredDeletes.has(key))
1955
+ return;
1956
+ const t = setTimeout(() => {
1957
+ this.deferredDeletes.delete(key);
1958
+ // NEW SEMANTICS (post-message_folders refactor): we drop the
1959
+ // FOLDER MEMBERSHIP, not the messages row. The message itself
1960
+ // may exist in other folders (Gmail labels, server-side moves
1961
+ // already reconciled by the dest folder's sync); deleting the
1962
+ // messages row would clobber those.
1963
+ //
1964
+ // 1. Delete from message_folders for THIS (folder, uid).
1965
+ // 2. Look up if the messages row has any other memberships.
1966
+ // If yes — message lives elsewhere; just drop our row's
1967
+ // body-cache reference if it was tied to this location.
1968
+ // If no — message is truly orphaned; delete the messages
1969
+ // row + unlink body file.
1970
+ const ds = this.db.dropFolderMembership?.(accountId, folderId, uid, folderPath);
1971
+ // Fall back to the legacy path if the new method isn't there
1972
+ // for some reason (defensive — shouldn't happen, but the same
1973
+ // call site is hit by Gmail-API reconcile too which I haven't
1974
+ // converted yet).
1975
+ if (!ds) {
1976
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
1977
+ if (!env || env.folderId !== folderId) {
1978
+ console.log(` [reconcile-skip] ${accountId}/${folderPath} uid=${uid}: row moved during grace window`);
1979
+ return;
1980
+ }
1981
+ const tag = env.messageId ? `msgid=${env.messageId} subj="${(env.subject || "").slice(0, 60)}"` : "no-msgid";
1982
+ console.log(` [reconcile-delete] ${accountId}/${folderPath} uid=${uid} ${tag} (legacy path)`);
1983
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1984
+ this.db.deleteMessage(accountId, uid, "reconcile: server missing this UID after grace (legacy)", `mailx-imap deferred reconcile (${folderPath})`);
1985
+ }
1986
+ this.db.recalcFolderCounts(folderId);
1987
+ this.emit("folderCountsChanged", accountId, {});
1988
+ }, ImapManager.RECONCILE_DELETE_GRACE_MS);
1989
+ this.deferredDeletes.set(key, t);
1990
+ }
1938
1991
  async unlinkBodyFile(accountId, uid, folderId) {
1939
1992
  try {
1940
1993
  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.26",
3
+ "version": "0.1.28",
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.10",
13
13
  "@bobfrankston/mailx-settings": "^0.1.13",
14
- "@bobfrankston/mailx-store": "^0.1.11",
15
- "@bobfrankston/iflow-direct": "^0.1.32",
14
+ "@bobfrankston/mailx-store": "^0.1.13",
15
+ "@bobfrankston/iflow-direct": "^0.1.35",
16
16
  "@bobfrankston/tcp-transport": "^0.1.5",
17
17
  "@bobfrankston/smtp-direct": "^0.1.5",
18
18
  "@bobfrankston/mailx-sync": "^0.1.15",
@@ -39,8 +39,8 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.10",
41
41
  "@bobfrankston/mailx-settings": "^0.1.13",
42
- "@bobfrankston/mailx-store": "^0.1.11",
43
- "@bobfrankston/iflow-direct": "^0.1.32",
42
+ "@bobfrankston/mailx-store": "^0.1.13",
43
+ "@bobfrankston/iflow-direct": "^0.1.35",
44
44
  "@bobfrankston/tcp-transport": "^0.1.5",
45
45
  "@bobfrankston/smtp-direct": "^0.1.5",
46
46
  "@bobfrankston/mailx-sync": "^0.1.15",