@bobfrankston/mailx-imap 0.1.25 → 0.1.27

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 +21 -1
  2. package/index.js +111 -95
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -247,13 +247,33 @@ export declare class ImapManager extends EventEmitter {
247
247
  stopPeriodicSync(): void;
248
248
  /** Check if an account is OAuth (Gmail/Outlook — generous connection limits) */
249
249
  isOAuthAccount(accountId: string): boolean;
250
- /** Start IMAP IDLE watchers for INBOX on each account */
250
+ /** Start an IMAP IDLE watcher per account on INBOX. Other folders are
251
+ * not special-cased here — sync runs uniformly on every folder via the
252
+ * periodic full-sync timer (STATUS-before-SELECT optimization is the
253
+ * right way to make that cheap, not piling per-folder IDLE sockets). */
251
254
  startWatching(): Promise<void>;
252
255
  /** Stop all IDLE watchers */
253
256
  stopWatching(): Promise<void>;
254
257
  /** Unlink the on-disk body file for a message by reading its `body_path`
255
258
  * from the DB. Safe to call either before or after `db.deleteMessage`
256
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;
257
277
  private unlinkBodyFile;
258
278
  /** Fetch a single message body on demand, caching in the store.
259
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,6 +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);
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).
881
906
  console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
882
907
  let messages;
883
908
  const firstSync = highestUid === 0;
@@ -997,21 +1022,14 @@ export class ImapManager extends EventEmitter {
997
1022
  try {
998
1023
  for (let i = batchStart; i < batchEnd; i++) {
999
1024
  const msg = messages[i];
1000
- // Skip if we already have this UID
1001
- if (msg.uid <= highestUid) {
1002
- // But update flags in case they changed
1003
- const flags = [];
1004
- if (msg.seen)
1005
- flags.push("\\Seen");
1006
- if (msg.flagged)
1007
- flags.push("\\Flagged");
1008
- if (msg.answered)
1009
- flags.push("\\Answered");
1010
- if (msg.draft)
1011
- flags.push("\\Draft");
1012
- this.db.updateMessageFlags(accountId, msg.uid, flags);
1013
- continue;
1014
- }
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.
1015
1033
  // Tombstone check — same reason as the streamy onChunk path
1016
1034
  // at storeMessages: a locally-deleted message that the server
1017
1035
  // hasn't EXPUNGEd yet would otherwise reappear on next sync.
@@ -1102,17 +1120,25 @@ export class ImapManager extends EventEmitter {
1102
1120
  else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1103
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`);
1104
1122
  }
1105
- 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.
1106
1138
  for (const uid of toDelete) {
1107
- const env = this.db.getMessageByUid(accountId, uid);
1108
- const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1109
- console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1110
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1111
- this.db.deleteMessage(accountId, uid);
1112
- deletedCount++;
1139
+ this.scheduleDeferredReconcileDelete(accountId, folderId, uid, folder.path);
1113
1140
  }
1114
- if (deletedCount > 0)
1115
- console.log(` removed ${deletedCount} deleted messages`);
1141
+ console.log(` [reconcile-defer] ${accountId}/${folder.path}: scheduled ${toDelete.length} deletes (60s grace for move-detect)`);
1116
1142
  }
1117
1143
  }
1118
1144
  catch (e) {
@@ -1484,7 +1510,7 @@ export class ImapManager extends EventEmitter {
1484
1510
  const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1485
1511
  console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1486
1512
  this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
1487
- this.db.deleteMessage(accountId, uid);
1513
+ this.db.deleteMessage(accountId, uid, "Gmail-API reconcile: server list missing this UID", `mailx-imap Gmail reconcile (${folder.path})`);
1488
1514
  }
1489
1515
  if (toDelete.length > 0)
1490
1516
  console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
@@ -1863,7 +1889,10 @@ export class ImapManager extends EventEmitter {
1863
1889
  const config = this.configs.get(accountId);
1864
1890
  return !!config?.tokenProvider;
1865
1891
  }
1866
- /** Start IMAP IDLE watchers for INBOX on each account */
1892
+ /** Start an IMAP IDLE watcher per account on INBOX. Other folders are
1893
+ * not special-cased here — sync runs uniformly on every folder via the
1894
+ * periodic full-sync timer (STATUS-before-SELECT optimization is the
1895
+ * right way to make that cheap, not piling per-folder IDLE sockets). */
1867
1896
  async startWatching() {
1868
1897
  for (const [accountId] of this.configs) {
1869
1898
  if (this.watchers.has(accountId))
@@ -1873,70 +1902,16 @@ export class ImapManager extends EventEmitter {
1873
1902
  // is parked in IDLE, it's unusable for any other command, so
1874
1903
  // it can't share the ops queue. Counts against the per-host
1875
1904
  // semaphore (one slot for the IDLE socket).
1876
- //
1877
- // We watch INBOX (incoming mail) and Sent + Drafts (outgoing
1878
- // changes from another client — Thunderbird, phone, web).
1879
- // Without IDLE on Sent, a message just sent from another
1880
- // device would only show in mailx after the next periodic
1881
- // full sync.
1882
- const stops = [];
1883
- const clients = [];
1884
- const watchOne = async (mailboxLabel, path) => {
1885
- const client = await this.createClient(accountId, "idle");
1886
- clients.push(client);
1887
- const stop = await client.watchMailbox(path, (newCount) => {
1888
- console.log(` [idle] ${accountId} ${path}: ${newCount} new message(s)`);
1889
- if (mailboxLabel === "inbox") {
1890
- // Fast path: incremental fetch of NEW UIDs only.
1891
- // Heavy reconcile runs on the 5-minute STATUS poll.
1892
- this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] inbox sync error: ${e.message}`));
1893
- }
1894
- else {
1895
- // Sent / Drafts changed elsewhere. Use the
1896
- // standard folder sync — picks up the new UID,
1897
- // rebinds any optimistic local row by Message-ID.
1898
- const folder = this.findFolder(accountId, mailboxLabel);
1899
- if (folder) {
1900
- this.syncFolder(accountId, folder.id).catch(e => console.error(` [idle] ${path} sync error: ${e.message}`));
1901
- }
1902
- }
1903
- });
1904
- stops.push(stop);
1905
- };
1906
- await watchOne("inbox", "INBOX");
1907
- const sent = this.findFolder(accountId, "sent");
1908
- if (sent) {
1909
- try {
1910
- await watchOne("sent", sent.path);
1911
- }
1912
- catch (e) {
1913
- console.error(` [idle] Failed to watch ${sent.path}: ${e.message}`);
1914
- }
1915
- }
1916
- const drafts = this.findFolder(accountId, "drafts");
1917
- if (drafts) {
1918
- try {
1919
- await watchOne("drafts", drafts.path);
1920
- }
1921
- catch (e) {
1922
- console.error(` [idle] Failed to watch ${drafts.path}: ${e.message}`);
1923
- }
1924
- }
1905
+ const watchClient = await this.createClient(accountId, "idle");
1906
+ const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
1907
+ console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
1908
+ this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] sync error: ${e.message}`));
1909
+ });
1925
1910
  this.watchers.set(accountId, async () => {
1926
- for (const stop of stops) {
1927
- try {
1928
- await stop();
1929
- }
1930
- catch { /* ignore */ }
1931
- }
1932
- for (const c of clients) {
1933
- try {
1934
- await c.logout();
1935
- }
1936
- catch { /* ignore */ }
1937
- }
1911
+ await stop();
1912
+ await watchClient.logout();
1938
1913
  });
1939
- console.log(` [idle] Watching INBOX${sent ? "+Sent" : ""}${drafts ? "+Drafts" : ""} for ${accountId}`);
1914
+ console.log(` [idle] Watching INBOX for ${accountId}`);
1940
1915
  }
1941
1916
  catch (e) {
1942
1917
  console.error(` [idle] Failed to watch ${accountId}: ${e.message}`);
@@ -1956,6 +1931,47 @@ export class ImapManager extends EventEmitter {
1956
1931
  /** Unlink the on-disk body file for a message by reading its `body_path`
1957
1932
  * from the DB. Safe to call either before or after `db.deleteMessage`
1958
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 60s clock should run from
1953
+ // the 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
+ // Verify the row still exists at the original (folder, uid).
1959
+ // If move-detect rebound it during the grace window, this
1960
+ // lookup returns null — skip the delete.
1961
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
1962
+ if (!env || env.folderId !== folderId) {
1963
+ console.log(` [reconcile-skip] ${accountId}/${folderPath} uid=${uid}: row moved during grace window (rebound to folder ${env?.folderId ?? "deleted"})`);
1964
+ return;
1965
+ }
1966
+ const tag = env.messageId ? `msgid=${env.messageId} subj="${(env.subject || "").slice(0, 60)}"` : "no-msgid";
1967
+ console.log(` [reconcile-delete] ${accountId}/${folderPath} uid=${uid} ${tag} (after 60s grace, no rebind)`);
1968
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1969
+ this.db.deleteMessage(accountId, uid, "reconcile: server missing this UID 60s after detection (no move-detect rebind)", `mailx-imap syncFolder deferred reconcile (${folderPath})`);
1970
+ this.db.recalcFolderCounts(folderId);
1971
+ this.emit("folderCountsChanged", accountId, {});
1972
+ }, ImapManager.RECONCILE_DELETE_GRACE_MS);
1973
+ this.deferredDeletes.set(key, t);
1974
+ }
1959
1975
  async unlinkBodyFile(accountId, uid, folderId) {
1960
1976
  try {
1961
1977
  const row = this.db.getMessageByUid(accountId, uid, folderId);
@@ -2204,7 +2220,7 @@ export class ImapManager extends EventEmitter {
2204
2220
  continue;
2205
2221
  try {
2206
2222
  this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2207
- this.db.deleteMessage(accountId, uid);
2223
+ this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID — assumed deleted", "mailx-imap prefetchBodies (Gmail batch)");
2208
2224
  counters.deleted++;
2209
2225
  madeProgress = true;
2210
2226
  }
@@ -2304,7 +2320,7 @@ export class ImapManager extends EventEmitter {
2304
2320
  continue;
2305
2321
  try {
2306
2322
  this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2307
- this.db.deleteMessage(accountId, uid);
2323
+ this.db.deleteMessage(accountId, uid, "prefetch batch: server didn't return body for queued UID — assumed deleted", "mailx-imap prefetchBodies (IMAP batch)");
2308
2324
  counters.deleted++;
2309
2325
  madeProgress = true;
2310
2326
  }
@@ -2340,7 +2356,7 @@ export class ImapManager extends EventEmitter {
2340
2356
  // Local first — remove all from DB immediately
2341
2357
  for (const msg of messages) {
2342
2358
  this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2343
- this.db.deleteMessage(accountId, msg.uid);
2359
+ this.db.deleteMessage(accountId, msg.uid, "user-initiated delete (bulk)", "mailx-imap deleteMessages");
2344
2360
  }
2345
2361
  console.log(` Deleted ${messages.length} messages locally`);
2346
2362
  // Queue IMAP actions
@@ -2393,7 +2409,7 @@ export class ImapManager extends EventEmitter {
2393
2409
  const trash = this.findFolder(accountId, "trash");
2394
2410
  // Local first — remove from DB immediately
2395
2411
  this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2396
- this.db.deleteMessage(accountId, uid);
2412
+ this.db.deleteMessage(accountId, uid, "user-initiated trash", "mailx-imap trashMessage");
2397
2413
  // Queue IMAP action + log the resolution so "I deleted a message and
2398
2414
  // now it's in neither trash nor deleted" is diagnosable from the log.
2399
2415
  if (trash && trash.id !== folderId) {
@@ -2435,7 +2451,7 @@ export class ImapManager extends EventEmitter {
2435
2451
  if (!msg)
2436
2452
  throw new Error(`Message UID ${uid} not found in ${fromFolder.path}`);
2437
2453
  await sourceClient.moveMessageToServer(msg, fromFolder.path, targetClient, toFolder.path);
2438
- this.db.deleteMessage(fromAccountId, uid);
2454
+ this.db.deleteMessage(fromAccountId, uid, `cross-account move to ${toAccountId}/${toFolder.path}`, "mailx-imap moveBetweenAccounts");
2439
2455
  console.log(` Cross-account move: ${fromAccountId}/${fromFolder.path} UID ${uid} → ${toAccountId}/${toFolder.path}`);
2440
2456
  });
2441
2457
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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.10",
15
- "@bobfrankston/iflow-direct": "^0.1.30",
14
+ "@bobfrankston/mailx-store": "^0.1.12",
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.10",
43
- "@bobfrankston/iflow-direct": "^0.1.30",
42
+ "@bobfrankston/mailx-store": "^0.1.12",
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",