@bobfrankston/rmfmail 1.0.697 → 1.0.699

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.
@@ -98,7 +98,7 @@ function decodeEntities(text) {
98
98
  /** Extract a plain-text preview from message source */
99
99
  async function extractPreview(source) {
100
100
  try {
101
- const parsed = await parseSerial(source);
101
+ const parsed = await parseSerial(source, "background");
102
102
  const bodyText = parsed.text || "";
103
103
  const bodyHtml = parsed.html || "";
104
104
  // Use text part; fall back to stripping HTML tags if text is empty
@@ -899,12 +899,15 @@ export class ImapManager extends EventEmitter {
899
899
  // upsert turns into an UPDATE (cheap); if it doesn't, the
900
900
  // insert proceeds. Trust the constraint — don't second-
901
901
  // 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.
902
+ // Tombstone check: skip re-import while a local delete/move
903
+ // is in flight to the server. Cleared on action success
904
+ // (server EXPUNGED next sync's set-diff drops the row
905
+ // anyway) or on permanent failure (clearTombstoneForUid in
906
+ // failSyncAction → row reappears, user sees the action
907
+ // didn't take). QRESYNC servers bypass this entirely
908
+ // because their VANISHED responses are authoritative.
906
909
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
907
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
910
+ console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted, awaiting server confirm)`);
908
911
  continue;
909
912
  }
910
913
  const source = msg.source || "";
@@ -971,7 +974,11 @@ export class ImapManager extends EventEmitter {
971
974
  *
972
975
  * Fires the same emits as a normal sync so the UI updates. */
973
976
  async insertLocalRowFromSource(accountId, folder, uid, source, flags) {
974
- const parsed = await parseSerial(source);
977
+ // insertLocalRowFromSource runs right after sendMessage — that's a
978
+ // user-initiated path but the parse cost is on the post-send
979
+ // background work, not the click-through. Tag as background so a
980
+ // concurrent user-click foreground parse jumps ahead.
981
+ const parsed = await parseSerial(source, "background");
975
982
  // Coerce mailparser AddressObject(s) into the flat `{name, address}[]`
976
983
  // shape storeMessages's downstream toEmailAddresses expects.
977
984
  // RFC 2047 encoded-word decoding (incl. inside quoted-strings) is
@@ -1115,6 +1122,93 @@ export class ImapManager extends EventEmitter {
1115
1122
  }
1116
1123
  }
1117
1124
  console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
1125
+ // ── QRESYNC fast path (RFC 7162) ─────────────────────────────────
1126
+ // When the server supports QRESYNC AND we have a saved (uidValidity,
1127
+ // modSeq) watermark for this folder, ask the server "what changed
1128
+ // since modSeq?". The server replies with an authoritative VANISHED
1129
+ // list (no client-side diffing needed, no tombstones needed) plus
1130
+ // unsolicited FETCH for flag/state changes. New UIDs > our prior
1131
+ // highest still go through the normal fetchMessagesSinceUid path
1132
+ // afterwards. Skips the heavy UID SEARCH set-diff below entirely.
1133
+ const folderSyncState = this.db.getFolderSync(folderId);
1134
+ const prevModSeq = parseInt(folderSyncState.highestModseq || "0", 10);
1135
+ const prevUidValidity = folderSyncState.uidvalidity || 0;
1136
+ const caps = (typeof client.getCapabilities === "function")
1137
+ ? client.getCapabilities()
1138
+ : new Set();
1139
+ const qresyncSupported = caps.has("QRESYNC");
1140
+ const haveWatermark = highestUid > 0 && prevModSeq > 0 && prevUidValidity > 0;
1141
+ if (qresyncSupported && haveWatermark && typeof client.resyncFolder === "function") {
1142
+ try {
1143
+ // Ensure ENABLE QRESYNC has been issued on this connection.
1144
+ // enableQresync is idempotent + capability-gated so this is a
1145
+ // no-op on the second-and-subsequent call per connection.
1146
+ if (typeof client.enableQresync === "function") {
1147
+ await client.enableQresync();
1148
+ }
1149
+ const __qrT0 = Date.now();
1150
+ const qr = await client.resyncFolder(folder.path, prevUidValidity, prevModSeq);
1151
+ console.log(` [qresync] ${accountId}/${folder.path}: vanished=${qr.vanishedUids.length} changed=${qr.changedMessages.length} newModSeq=${qr.newHighestModSeq} in ${Date.now() - __qrT0}ms`);
1152
+ if (qr.uidValidityChanged) {
1153
+ // UIDVALIDITY rolled — our local UIDs are stale. Fall through
1154
+ // to the full set-diff path; that'll discover the new state
1155
+ // and the deletion safeguards (50% threshold) will keep us
1156
+ // from wiping anything we shouldn't.
1157
+ console.log(` [qresync] ${accountId}/${folder.path}: UIDVALIDITY changed (was ${prevUidValidity}, now ${qr.exists}); falling back to full sync`);
1158
+ }
1159
+ else {
1160
+ // Apply VANISHED — server says these UIDs are gone. No
1161
+ // tombstone, no diff, just delete the local rows.
1162
+ let vanishedApplied = 0;
1163
+ for (const uid of qr.vanishedUids) {
1164
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
1165
+ if (env) {
1166
+ this.db.deleteMessage(accountId, uid, "server VANISHED via QRESYNC", "mailx-imap syncFolder/qresync");
1167
+ vanishedApplied++;
1168
+ }
1169
+ }
1170
+ if (vanishedApplied > 0) {
1171
+ this.db.recalcFolderCounts(folderId);
1172
+ this.emit("folderCountsChanged", accountId, {});
1173
+ }
1174
+ // Apply changed-state FETCH — flag updates since prior modSeq.
1175
+ for (const m of qr.changedMessages) {
1176
+ try {
1177
+ const flagsArr = Array.from(m.flags || []).map(f => String(f));
1178
+ this.db.updateMessageFlags(accountId, m.uid, flagsArr);
1179
+ }
1180
+ catch { /* row may have just been VANISHED */ }
1181
+ }
1182
+ // Fetch genuinely new messages — UID > our prior highest.
1183
+ // resyncFolder doesn't return these directly; the server
1184
+ // emits them only via state-change FETCH if their flags
1185
+ // changed since modSeq, which is unreliable. Pull them
1186
+ // explicitly via the existing incremental path.
1187
+ const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
1188
+ const newOnes = fetched.filter((m) => m.uid > highestUid);
1189
+ if (newOnes.length > 0) {
1190
+ await this.storeMessages(accountId, folderId, folder, newOnes, highestUid);
1191
+ }
1192
+ // Persist new watermark — next resync starts here.
1193
+ if (qr.newHighestModSeq !== undefined) {
1194
+ this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
1195
+ }
1196
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1197
+ console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1198
+ return newOnes.length;
1199
+ }
1200
+ }
1201
+ catch (qrErr) {
1202
+ // QRESYNC failed (server quirk, network glitch, etc.) — log
1203
+ // and fall through to the legacy set-diff path. We do NOT
1204
+ // clear the modSeq watermark here; the next attempt will
1205
+ // re-try QRESYNC, and if that also fails enough times the
1206
+ // operator can manually clear `highest_modseq` to force a
1207
+ // full resync.
1208
+ console.warn(` [qresync] ${accountId}/${folder.path}: failed (${qrErr?.message || qrErr}) — falling back to set-diff`);
1209
+ }
1210
+ }
1211
+ // ────────────────────────────────────────────────────────────────
1118
1212
  let messages;
1119
1213
  // Cache the server-UID list across set-diff and deletion-recon so we
1120
1214
  // don't pay for two `UID SEARCH` round-trips (the second one was
@@ -1311,10 +1405,9 @@ export class ImapManager extends EventEmitter {
1311
1405
  // UNIQUE constraint to dedupe — if mailx truly has the
1312
1406
  // row, the upsert becomes an UPDATE that refreshes
1313
1407
  // flags too, all in one path.
1314
- // Tombstone check same reason as the streamy onChunk path
1315
- // at storeMessages: a locally-deleted message that the server
1316
- // hasn't EXPUNGEd yet would otherwise reappear on next sync.
1317
- // User-visible symptom: "I deleted it but it came back."
1408
+ // Tombstone-skip: in-flight local delete/move waiting
1409
+ // for server confirmation. Cleared on action success or
1410
+ // permanent failure (see clearTombstoneForUid).
1318
1411
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1319
1412
  console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1320
1413
  continue;
@@ -1480,6 +1573,22 @@ export class ImapManager extends EventEmitter {
1480
1573
  }
1481
1574
  this.emit("folderSynced", accountId, folderId, syncedAt);
1482
1575
  this.db.updateLastSync(accountId, syncedAt);
1576
+ // Seed the QRESYNC watermark from the SELECT response of whatever
1577
+ // legacy operation just ran (fetchMessagesSinceUid, getUids, etc.).
1578
+ // Without this, a CONDSTORE/QRESYNC-capable server's `HIGHESTMODSEQ`
1579
+ // is captured by the native layer but never persisted, and the
1580
+ // QRESYNC fast path above stays permanently disabled because
1581
+ // `prevModSeq` is 0. Captures on every sync (cheap) so the watermark
1582
+ // converges to the latest known modSeq.
1583
+ try {
1584
+ if (typeof client.getCurrentMailboxInfo === "function") {
1585
+ const info = client.getCurrentMailboxInfo();
1586
+ if (info && info.highestModSeq && info.uidValidity) {
1587
+ this.db.updateFolderSync(folderId, info.uidValidity, String(info.highestModSeq));
1588
+ }
1589
+ }
1590
+ }
1591
+ catch { /* non-fatal — QRESYNC just stays disabled */ }
1483
1592
  return newCount;
1484
1593
  }
1485
1594
  /** Sync all folders for all accounts */
@@ -1888,10 +1997,9 @@ export class ImapManager extends EventEmitter {
1888
1997
  // standalone — bad rows are logged and skipped.
1889
1998
  for (const msg of msgs) {
1890
1999
  try {
1891
- // Tombstone check Gmail API sync was missing this so a
1892
- // locally-trashed Gmail message would reappear on the next
1893
- // listMessages tick if Gmail's eventual-consistency hadn't
1894
- // promoted the trash yet. Symmetric with the IMAP paths.
2000
+ // Tombstone-skip: in-flight local delete/move on Gmail.
2001
+ // Cleared by clearTombstoneForUid on failSyncAction so a
2002
+ // refused trash doesn't permanently hide the message.
1895
2003
  if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1896
2004
  continue;
1897
2005
  }
@@ -2795,6 +2903,14 @@ export class ImapManager extends EventEmitter {
2795
2903
  if (messages.length === 0)
2796
2904
  return;
2797
2905
  const trash = this.findFolder(accountId, "trash");
2906
+ // Tombstone each Message-ID so sync won't re-import the source-folder
2907
+ // row before the server-side MOVE completes. Cleared on permanent
2908
+ // failure (clearTombstoneForUid in processSyncActions).
2909
+ for (const msg of messages) {
2910
+ const env = this.db.getMessageByUid(accountId, msg.uid, msg.folderId);
2911
+ if (env?.messageId)
2912
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
2913
+ }
2798
2914
  // Local first — move to trash folder locally so the row stays
2799
2915
  // visible in Trash and Ctrl+Z can restore it. Body file stays in
2800
2916
  // its original folder dir; the next sync rebinds path on
@@ -2861,6 +2977,14 @@ export class ImapManager extends EventEmitter {
2861
2977
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2862
2978
  async trashMessage(accountId, folderId, uid) {
2863
2979
  const trash = this.findFolder(accountId, "trash");
2980
+ // Tombstone the Message-ID so sync won't re-import the row in the
2981
+ // source folder before the server-side move completes. Cleared on
2982
+ // permanent failure of the queued sync_action (see processSyncActions
2983
+ // catch block, where clearTombstoneForUid runs after attempts >= 5)
2984
+ // so the user sees the row reappear when their action didn't take.
2985
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
2986
+ if (env?.messageId)
2987
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
2864
2988
  // Local first — move to trash folder so the row stays visible in
2865
2989
  // Trash and Ctrl+Z can restore. Body file retained for undelete.
2866
2990
  // If we're already in trash (or no trash configured), fall through
@@ -3030,8 +3154,15 @@ export class ImapManager extends EventEmitter {
3030
3154
  catch (e) {
3031
3155
  console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
3032
3156
  this.db.failSyncAction(action.id, e.message);
3033
- if (action.attempts >= 5)
3157
+ if (action.attempts >= 5) {
3158
+ // Terminal failure on delete/move → clear tombstone
3159
+ // so the row reappears on next sync (server still
3160
+ // has it). Same rationale as the IMAP branch below.
3161
+ if (action.action === "delete" || action.action === "move") {
3162
+ this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
3163
+ }
3034
3164
  this.db.completeSyncAction(action.id);
3165
+ }
3035
3166
  }
3036
3167
  }
3037
3168
  }
@@ -3111,6 +3242,17 @@ export class ImapManager extends EventEmitter {
3111
3242
  this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
3112
3243
  if (action.attempts >= 5) {
3113
3244
  console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
3245
+ // 2026-05-13: terminal failure on a delete/move
3246
+ // means the server still has the message — so any
3247
+ // tombstone we set for it would otherwise hide it
3248
+ // from the user forever. Clear the tombstone so
3249
+ // the next sync re-imports the row, accurately
3250
+ // reflecting "your action didn't take, here it is
3251
+ // again." Applies to delete + move; flags/append
3252
+ // never tombstone.
3253
+ if (action.action === "delete" || action.action === "move") {
3254
+ this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
3255
+ }
3114
3256
  this.db.completeSyncAction(action.id);
3115
3257
  this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
3116
3258
  }