@bobfrankston/mailx-imap 0.1.42 → 0.1.44

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 +5 -0
  2. package/index.js +183 -15
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -67,6 +67,11 @@ export declare class ImapManager extends EventEmitter {
67
67
  private accountErrorShown;
68
68
  private syncing;
69
69
  private inboxSyncing;
70
+ /** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
71
+ * gate so non-priority folders defer their first-sync until ~3 min
72
+ * past startup, when the event loop has quieted down. Each restart
73
+ * resets this; the gate auto-lifts as time passes. */
74
+ private _startupAt;
70
75
  /** Use native IMAP client instead of imapflow. Set to true to enable. */
71
76
  useNativeClient: boolean;
72
77
  /** Per-account health counters. Incremented when the server misbehaves
package/index.js CHANGED
@@ -142,6 +142,11 @@ export class ImapManager extends EventEmitter {
142
142
  accountErrorShown = new Set();
143
143
  syncing = false;
144
144
  inboxSyncing = false;
145
+ /** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
146
+ * gate so non-priority folders defer their first-sync until ~3 min
147
+ * past startup, when the event loop has quieted down. Each restart
148
+ * resets this; the gate auto-lifts as time passes. */
149
+ _startupAt = Date.now();
145
150
  /** Use native IMAP client instead of imapflow. Set to true to enable. */
146
151
  useNativeClient = false;
147
152
  // Connection management: see withConnection() below.
@@ -899,12 +904,15 @@ export class ImapManager extends EventEmitter {
899
904
  // upsert turns into an UPDATE (cheap); if it doesn't, the
900
905
  // insert proceeds. Trust the constraint — don't second-
901
906
  // guess it on a stale highestUid snapshot.
902
- // Tombstone check: if the user locally deleted this Message-ID,
903
- // don't re-import it. Server-side EXPUNGE may lag, or reconcile
904
- // may find the message in an old list snapshot. Without this,
905
- // deleted messages reappear on the next sync pass.
907
+ // Tombstone check: skip re-import while a local delete/move
908
+ // is in flight to the server. Cleared on action success
909
+ // (server EXPUNGED next sync's set-diff drops the row
910
+ // anyway) or on permanent failure (clearTombstoneForUid in
911
+ // failSyncAction → row reappears, user sees the action
912
+ // didn't take). QRESYNC servers bypass this entirely
913
+ // because their VANISHED responses are authoritative.
906
914
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
907
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
915
+ console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted, awaiting server confirm)`);
908
916
  continue;
909
917
  }
910
918
  const source = msg.source || "";
@@ -1119,6 +1127,93 @@ export class ImapManager extends EventEmitter {
1119
1127
  }
1120
1128
  }
1121
1129
  console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
1130
+ // ── QRESYNC fast path (RFC 7162) ─────────────────────────────────
1131
+ // When the server supports QRESYNC AND we have a saved (uidValidity,
1132
+ // modSeq) watermark for this folder, ask the server "what changed
1133
+ // since modSeq?". The server replies with an authoritative VANISHED
1134
+ // list (no client-side diffing needed, no tombstones needed) plus
1135
+ // unsolicited FETCH for flag/state changes. New UIDs > our prior
1136
+ // highest still go through the normal fetchMessagesSinceUid path
1137
+ // afterwards. Skips the heavy UID SEARCH set-diff below entirely.
1138
+ const folderSyncState = this.db.getFolderSync(folderId);
1139
+ const prevModSeq = parseInt(folderSyncState.highestModseq || "0", 10);
1140
+ const prevUidValidity = folderSyncState.uidvalidity || 0;
1141
+ const caps = (typeof client.getCapabilities === "function")
1142
+ ? client.getCapabilities()
1143
+ : new Set();
1144
+ const qresyncSupported = caps.has("QRESYNC");
1145
+ const haveWatermark = highestUid > 0 && prevModSeq > 0 && prevUidValidity > 0;
1146
+ if (qresyncSupported && haveWatermark && typeof client.resyncFolder === "function") {
1147
+ try {
1148
+ // Ensure ENABLE QRESYNC has been issued on this connection.
1149
+ // enableQresync is idempotent + capability-gated so this is a
1150
+ // no-op on the second-and-subsequent call per connection.
1151
+ if (typeof client.enableQresync === "function") {
1152
+ await client.enableQresync();
1153
+ }
1154
+ const __qrT0 = Date.now();
1155
+ const qr = await client.resyncFolder(folder.path, prevUidValidity, prevModSeq);
1156
+ console.log(` [qresync] ${accountId}/${folder.path}: vanished=${qr.vanishedUids.length} changed=${qr.changedMessages.length} newModSeq=${qr.newHighestModSeq} in ${Date.now() - __qrT0}ms`);
1157
+ if (qr.uidValidityChanged) {
1158
+ // UIDVALIDITY rolled — our local UIDs are stale. Fall through
1159
+ // to the full set-diff path; that'll discover the new state
1160
+ // and the deletion safeguards (50% threshold) will keep us
1161
+ // from wiping anything we shouldn't.
1162
+ console.log(` [qresync] ${accountId}/${folder.path}: UIDVALIDITY changed (was ${prevUidValidity}, now ${qr.exists}); falling back to full sync`);
1163
+ }
1164
+ else {
1165
+ // Apply VANISHED — server says these UIDs are gone. No
1166
+ // tombstone, no diff, just delete the local rows.
1167
+ let vanishedApplied = 0;
1168
+ for (const uid of qr.vanishedUids) {
1169
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
1170
+ if (env) {
1171
+ this.db.deleteMessage(accountId, uid, "server VANISHED via QRESYNC", "mailx-imap syncFolder/qresync");
1172
+ vanishedApplied++;
1173
+ }
1174
+ }
1175
+ if (vanishedApplied > 0) {
1176
+ this.db.recalcFolderCounts(folderId);
1177
+ this.emit("folderCountsChanged", accountId, {});
1178
+ }
1179
+ // Apply changed-state FETCH — flag updates since prior modSeq.
1180
+ for (const m of qr.changedMessages) {
1181
+ try {
1182
+ const flagsArr = Array.from(m.flags || []).map(f => String(f));
1183
+ this.db.updateMessageFlags(accountId, m.uid, flagsArr);
1184
+ }
1185
+ catch { /* row may have just been VANISHED */ }
1186
+ }
1187
+ // Fetch genuinely new messages — UID > our prior highest.
1188
+ // resyncFolder doesn't return these directly; the server
1189
+ // emits them only via state-change FETCH if their flags
1190
+ // changed since modSeq, which is unreliable. Pull them
1191
+ // explicitly via the existing incremental path.
1192
+ const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
1193
+ const newOnes = fetched.filter((m) => m.uid > highestUid);
1194
+ if (newOnes.length > 0) {
1195
+ await this.storeMessages(accountId, folderId, folder, newOnes, highestUid);
1196
+ }
1197
+ // Persist new watermark — next resync starts here.
1198
+ if (qr.newHighestModSeq !== undefined) {
1199
+ this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
1200
+ }
1201
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1202
+ console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1203
+ return newOnes.length;
1204
+ }
1205
+ }
1206
+ catch (qrErr) {
1207
+ // QRESYNC failed (server quirk, network glitch, etc.) — log
1208
+ // and fall through to the legacy set-diff path. We do NOT
1209
+ // clear the modSeq watermark here; the next attempt will
1210
+ // re-try QRESYNC, and if that also fails enough times the
1211
+ // operator can manually clear `highest_modseq` to force a
1212
+ // full resync.
1213
+ console.warn(` [qresync] ${accountId}/${folder.path}: failed (${qrErr?.message || qrErr}) — falling back to set-diff`);
1214
+ }
1215
+ }
1216
+ // ────────────────────────────────────────────────────────────────
1122
1217
  let messages;
1123
1218
  // Cache the server-UID list across set-diff and deletion-recon so we
1124
1219
  // don't pay for two `UID SEARCH` round-trips (the second one was
@@ -1315,10 +1410,9 @@ export class ImapManager extends EventEmitter {
1315
1410
  // UNIQUE constraint to dedupe — if mailx truly has the
1316
1411
  // row, the upsert becomes an UPDATE that refreshes
1317
1412
  // flags too, all in one path.
1318
- // Tombstone check same reason as the streamy onChunk path
1319
- // at storeMessages: a locally-deleted message that the server
1320
- // hasn't EXPUNGEd yet would otherwise reappear on next sync.
1321
- // User-visible symptom: "I deleted it but it came back."
1413
+ // Tombstone-skip: in-flight local delete/move waiting
1414
+ // for server confirmation. Cleared on action success or
1415
+ // permanent failure (see clearTombstoneForUid).
1322
1416
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1323
1417
  console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1324
1418
  continue;
@@ -1484,6 +1578,22 @@ export class ImapManager extends EventEmitter {
1484
1578
  }
1485
1579
  this.emit("folderSynced", accountId, folderId, syncedAt);
1486
1580
  this.db.updateLastSync(accountId, syncedAt);
1581
+ // Seed the QRESYNC watermark from the SELECT response of whatever
1582
+ // legacy operation just ran (fetchMessagesSinceUid, getUids, etc.).
1583
+ // Without this, a CONDSTORE/QRESYNC-capable server's `HIGHESTMODSEQ`
1584
+ // is captured by the native layer but never persisted, and the
1585
+ // QRESYNC fast path above stays permanently disabled because
1586
+ // `prevModSeq` is 0. Captures on every sync (cheap) so the watermark
1587
+ // converges to the latest known modSeq.
1588
+ try {
1589
+ if (typeof client.getCurrentMailboxInfo === "function") {
1590
+ const info = client.getCurrentMailboxInfo();
1591
+ if (info && info.highestModSeq && info.uidValidity) {
1592
+ this.db.updateFolderSync(folderId, info.uidValidity, String(info.highestModSeq));
1593
+ }
1594
+ }
1595
+ }
1596
+ catch { /* non-fatal — QRESYNC just stays disabled */ }
1487
1597
  return newCount;
1488
1598
  }
1489
1599
  /** Sync all folders for all accounts */
@@ -1611,12 +1721,37 @@ export class ImapManager extends EventEmitter {
1611
1721
  // timeout abandons a stalled command instead of waiting out
1612
1722
  // Dovecot's 300s server-side inactivity timer; the next sync tick
1613
1723
  // retries on a fresh socket.
1614
- const remaining = folders.filter(f => f.specialUse !== "inbox");
1724
+ // 2026-05-13: defer first-sync of non-priority folders by default
1725
+ // (C119 lazy folder sync). On bobma with 90+ folders, doing a
1726
+ // first-sync of every folder on every startup hammers the IMAP
1727
+ // socket buffer and pegs the daemon's event loop processing
1728
+ // FETCH literals — user clicks get a 20+ second IPC delay
1729
+ // because no IPC can squeeze in between FETCH chunks. Now: only
1730
+ // special-use folders (Sent/Drafts/Archive/Junk/Trash) + folders
1731
+ // mailx has previously seen (highestUid > 0, i.e. tracked
1732
+ // across the prior session) sync automatically. First-sync of
1733
+ // a "never-touched" non-special folder is deferred until the
1734
+ // user opens that folder (on-demand syncFolder call) or until
1735
+ // ~3 minutes after startup when the event loop is quiet.
1736
+ const STARTUP_LAZY_DELAY_MS = 3 * 60 * 1000;
1737
+ const startupQuietPoint = (this._startupAt || 0) + STARTUP_LAZY_DELAY_MS;
1738
+ const isLazyEligible = (f) => {
1739
+ if (f.specialUse && priorityOrder.includes(f.specialUse))
1740
+ return false;
1741
+ if (this.db.getHighestUid(accountId, f.id) > 0)
1742
+ return false;
1743
+ return true;
1744
+ };
1745
+ const remaining = folders.filter(f => f.specialUse !== "inbox" && !(isLazyEligible(f) && Date.now() < startupQuietPoint));
1615
1746
  remaining.sort((a, b) => {
1616
1747
  const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
1617
1748
  const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
1618
1749
  return pa - pb;
1619
1750
  });
1751
+ const deferredCount = folders.filter(f => f.specialUse !== "inbox" && isLazyEligible(f) && Date.now() < startupQuietPoint).length;
1752
+ if (deferredCount > 0) {
1753
+ console.log(` [sync] ${accountId}: deferring first-sync of ${deferredCount} non-priority folder(s) until ${new Date(startupQuietPoint).toLocaleTimeString()} or user opens them`);
1754
+ }
1620
1755
  const CONCURRENCY = 2;
1621
1756
  // First-sync of a fresh account on a cold Dovecot is dominated by
1622
1757
  // `UID SEARCH SINCE 30-days-ago`, which can take 5+ minutes on a
@@ -1892,10 +2027,9 @@ export class ImapManager extends EventEmitter {
1892
2027
  // standalone — bad rows are logged and skipped.
1893
2028
  for (const msg of msgs) {
1894
2029
  try {
1895
- // Tombstone check Gmail API sync was missing this so a
1896
- // locally-trashed Gmail message would reappear on the next
1897
- // listMessages tick if Gmail's eventual-consistency hadn't
1898
- // promoted the trash yet. Symmetric with the IMAP paths.
2030
+ // Tombstone-skip: in-flight local delete/move on Gmail.
2031
+ // Cleared by clearTombstoneForUid on failSyncAction so a
2032
+ // refused trash doesn't permanently hide the message.
1899
2033
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1900
2034
  continue;
1901
2035
  }
@@ -2799,6 +2933,14 @@ export class ImapManager extends EventEmitter {
2799
2933
  if (messages.length === 0)
2800
2934
  return;
2801
2935
  const trash = this.findFolder(accountId, "trash");
2936
+ // Tombstone each Message-ID so sync won't re-import the source-folder
2937
+ // row before the server-side MOVE completes. Cleared on permanent
2938
+ // failure (clearTombstoneForUid in processSyncActions).
2939
+ for (const msg of messages) {
2940
+ const env = this.db.getMessageByUid(accountId, msg.uid, msg.folderId);
2941
+ if (env?.messageId)
2942
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
2943
+ }
2802
2944
  // Local first — move to trash folder locally so the row stays
2803
2945
  // visible in Trash and Ctrl+Z can restore it. Body file stays in
2804
2946
  // its original folder dir; the next sync rebinds path on
@@ -2865,6 +3007,14 @@ export class ImapManager extends EventEmitter {
2865
3007
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2866
3008
  async trashMessage(accountId, folderId, uid) {
2867
3009
  const trash = this.findFolder(accountId, "trash");
3010
+ // Tombstone the Message-ID so sync won't re-import the row in the
3011
+ // source folder before the server-side move completes. Cleared on
3012
+ // permanent failure of the queued sync_action (see processSyncActions
3013
+ // catch block, where clearTombstoneForUid runs after attempts >= 5)
3014
+ // so the user sees the row reappear when their action didn't take.
3015
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
3016
+ if (env?.messageId)
3017
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
2868
3018
  // Local first — move to trash folder so the row stays visible in
2869
3019
  // Trash and Ctrl+Z can restore. Body file retained for undelete.
2870
3020
  // If we're already in trash (or no trash configured), fall through
@@ -3034,8 +3184,15 @@ export class ImapManager extends EventEmitter {
3034
3184
  catch (e) {
3035
3185
  console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
3036
3186
  this.db.failSyncAction(action.id, e.message);
3037
- if (action.attempts >= 5)
3187
+ if (action.attempts >= 5) {
3188
+ // Terminal failure on delete/move → clear tombstone
3189
+ // so the row reappears on next sync (server still
3190
+ // has it). Same rationale as the IMAP branch below.
3191
+ if (action.action === "delete" || action.action === "move") {
3192
+ this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
3193
+ }
3038
3194
  this.db.completeSyncAction(action.id);
3195
+ }
3039
3196
  }
3040
3197
  }
3041
3198
  }
@@ -3115,6 +3272,17 @@ export class ImapManager extends EventEmitter {
3115
3272
  this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
3116
3273
  if (action.attempts >= 5) {
3117
3274
  console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
3275
+ // 2026-05-13: terminal failure on a delete/move
3276
+ // means the server still has the message — so any
3277
+ // tombstone we set for it would otherwise hide it
3278
+ // from the user forever. Clear the tombstone so
3279
+ // the next sync re-imports the row, accurately
3280
+ // reflecting "your action didn't take, here it is
3281
+ // again." Applies to delete + move; flags/append
3282
+ // never tombstone.
3283
+ if (action.action === "delete" || action.action === "move") {
3284
+ this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
3285
+ }
3118
3286
  this.db.completeSyncAction(action.id);
3119
3287
  this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
3120
3288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
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.11",
13
13
  "@bobfrankston/mailx-settings": "^0.1.16",
14
- "@bobfrankston/mailx-store": "^0.1.22",
15
- "@bobfrankston/iflow-direct": "^0.1.42",
14
+ "@bobfrankston/mailx-store": "^0.1.24",
15
+ "@bobfrankston/iflow-direct": "^0.1.44",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
18
18
  "@bobfrankston/mailx-sync": "^0.1.16",
@@ -39,8 +39,8 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.11",
41
41
  "@bobfrankston/mailx-settings": "^0.1.16",
42
- "@bobfrankston/mailx-store": "^0.1.22",
43
- "@bobfrankston/iflow-direct": "^0.1.42",
42
+ "@bobfrankston/mailx-store": "^0.1.24",
43
+ "@bobfrankston/iflow-direct": "^0.1.44",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",
46
46
  "@bobfrankston/mailx-sync": "^0.1.16",