@bobfrankston/mailx-imap 0.1.100 → 0.1.103

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 +161 -88
  3. package/package.json +3 -3
package/index.d.ts CHANGED
@@ -271,6 +271,26 @@ export declare class ImapManager extends EventEmitter {
271
271
  * the first caller finishes — important for outbox flushes that
272
272
  * expect their syncFolder for `Sent` to actually run. */
273
273
  private syncFolderLocks;
274
+ /** Per-folder timestamp of the last set-difference reconcile. Drives the
275
+ * throttle for the QRESYNC-path gap backfill (see fetchServerOnlyUids /
276
+ * syncFolder QRESYNC branch). Empty at boot, so the first sync of each
277
+ * folder after a restart always runs a reconcile — which is exactly when
278
+ * a daemon-was-down gap needs to be healed. */
279
+ private lastReconcileMs;
280
+ /**
281
+ * Set-difference backfill. Fetch every server UID (within the history
282
+ * window) that we don't already have locally and aren't fetching this
283
+ * cycle. This is the ONLY mechanism that recovers a message which is on
284
+ * the server but missing locally — the high-water-mark fetch
285
+ * (fetchMessagesSinceUid) cannot, because it only looks above the local
286
+ * highest UID. Gaps it heals: a message that arrived while the daemon was
287
+ * down (so it sits below the watermark once the next poll advances it), or
288
+ * one dropped mid-stream by a response desync. Returns the recovered
289
+ * messages (caller stores them) plus the server UID list it fetched, so the
290
+ * caller's deletion-reconcile can reuse it instead of paying for a second
291
+ * UID SEARCH round-trip.
292
+ */
293
+ private fetchServerOnlyUids;
274
294
  syncFolder(accountId: string, folderId: number, client?: any): Promise<number>;
275
295
  private _syncFolderImpl;
276
296
  /** Sync all folders for all accounts */
@@ -379,7 +399,7 @@ export declare class ImapManager extends EventEmitter {
379
399
  * Server fetch goes through the unified ops queue on the fast lane —
380
400
  * the user clicked, they're waiting, this jumps ahead of any background
381
401
  * prefetch sitting in the slow lane. */
382
- fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
402
+ fetchMessageBody(accountId: string, folderId: number, uid: number, force?: boolean): Promise<Buffer | null>;
383
403
  /** Fetch message body via Gmail/Outlook API.
384
404
  * Throws `MessageNotFoundError` when the server says the message is gone
385
405
  * (deleted from another device, for example). The caller uses that to
package/index.js CHANGED
@@ -21,6 +21,11 @@ const SMTP_PORT_IMPLICIT_TLS = 465;
21
21
  * the same file is retried. Gives the server time to settle so a retry after a
22
22
  * lost-ack doesn't arrive while the first copy is still being processed. */
23
23
  const OUTBOX_RETRY_DELAY_MS = 60000;
24
+ /** How often the QRESYNC fast path runs a full set-difference reconcile to
25
+ * heal gaps the high-water-mark fetch can't see. The first sync of a folder
26
+ * after boot always runs one (the map is empty); thereafter it's throttled to
27
+ * this interval so the QRESYNC speedup is preserved in steady state. */
28
+ const RECONCILE_THROTTLE_MS = 15 * 60 * 1000;
24
29
  /** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
25
30
  function parseRetryInfo(raw) {
26
31
  const headerEnd = raw.search(/\r?\n\r?\n/);
@@ -1307,6 +1312,94 @@ export class ImapManager extends EventEmitter {
1307
1312
  * the first caller finishes — important for outbox flushes that
1308
1313
  * expect their syncFolder for `Sent` to actually run. */
1309
1314
  syncFolderLocks = new Map();
1315
+ /** Per-folder timestamp of the last set-difference reconcile. Drives the
1316
+ * throttle for the QRESYNC-path gap backfill (see fetchServerOnlyUids /
1317
+ * syncFolder QRESYNC branch). Empty at boot, so the first sync of each
1318
+ * folder after a restart always runs a reconcile — which is exactly when
1319
+ * a daemon-was-down gap needs to be healed. */
1320
+ lastReconcileMs = new Map();
1321
+ /**
1322
+ * Set-difference backfill. Fetch every server UID (within the history
1323
+ * window) that we don't already have locally and aren't fetching this
1324
+ * cycle. This is the ONLY mechanism that recovers a message which is on
1325
+ * the server but missing locally — the high-water-mark fetch
1326
+ * (fetchMessagesSinceUid) cannot, because it only looks above the local
1327
+ * highest UID. Gaps it heals: a message that arrived while the daemon was
1328
+ * down (so it sits below the watermark once the next poll advances it), or
1329
+ * one dropped mid-stream by a response desync. Returns the recovered
1330
+ * messages (caller stores them) plus the server UID list it fetched, so the
1331
+ * caller's deletion-reconcile can reuse it instead of paying for a second
1332
+ * UID SEARCH round-trip.
1333
+ */
1334
+ async fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, excludeUids) {
1335
+ const folderPath = folder.path;
1336
+ const existingUids = this.db.getUidsForFolder(accountId, folderId);
1337
+ // Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
1338
+ // deletions of OLDER messages are reflected; large folders (134k INBOX)
1339
+ // get a date-bounded query so we don't enumerate the whole mailbox.
1340
+ const SMALL_FOLDER_THRESHOLD = 500;
1341
+ const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1342
+ const historyDays = getHistoryDays(accountId);
1343
+ const SINCE_DAYS = historyDays > 0 ? historyDays : 1825;
1344
+ const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1345
+ let serverUids;
1346
+ let dateBounded;
1347
+ const __t0 = Date.now();
1348
+ if (isSmallFolder) {
1349
+ console.log(` [sync-uids] ${accountId}/${folderPath}: small folder (server=${statusMessageCount}) — getUids ALL`);
1350
+ serverUids = await client.getUids(folderPath);
1351
+ dateBounded = false;
1352
+ }
1353
+ else {
1354
+ console.log(` [sync-uids] ${accountId}/${folderPath}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1355
+ serverUids = typeof client.getUidsSince === "function"
1356
+ ? await client.getUidsSince(folderPath, sinceDate)
1357
+ : await client.getUids(folderPath);
1358
+ dateBounded = true;
1359
+ }
1360
+ console.log(` [sync-uids] ${accountId}/${folderPath}: ${serverUids.length} UIDs in ${Date.now() - __t0}ms`);
1361
+ const existingSet = new Set(existingUids);
1362
+ const missingUids = serverUids.filter((uid) => !existingSet.has(uid) && !excludeUids.has(uid));
1363
+ if (missingUids.length === 0)
1364
+ return { recoveredCount: 0, serverUids, dateBounded };
1365
+ // Cap so a misbehaving response / brand-new account doesn't pull tens
1366
+ // of thousands at once; the rest resumes next cycle. Higher UID ≈ more
1367
+ // recent under stable UIDVALIDITY, so prefer newest when capping — the
1368
+ // user cares most about recent mail and the deficit gate keeps calling
1369
+ // us until the backlog is drained.
1370
+ const BACKFILL_CHUNK_SIZE = 100;
1371
+ const RECONCILE_FETCH_CAP = 5000;
1372
+ let toFetch = missingUids;
1373
+ if (missingUids.length > RECONCILE_FETCH_CAP) {
1374
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; resumes next cycle`);
1375
+ toFetch = missingUids.slice().sort((a, b) => b - a).slice(0, RECONCILE_FETCH_CAP);
1376
+ }
1377
+ else {
1378
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — fetching`);
1379
+ }
1380
+ // Store each chunk as it arrives so a mid-stream connection drop (the
1381
+ // "Not connected" / desync class on this server) keeps the progress it
1382
+ // already made instead of losing the whole batch. Stop on the first
1383
+ // chunk error; the deficit gate calls us again next cycle to resume.
1384
+ let recoveredCount = 0;
1385
+ for (let i = 0; i < toFetch.length; i += BACKFILL_CHUNK_SIZE) {
1386
+ const chunk = toFetch.slice(i, i + BACKFILL_CHUNK_SIZE);
1387
+ try {
1388
+ const got = await client.fetchMessages(folderPath, chunk.join(","), { source: false });
1389
+ recoveredCount += await this.storeMessages(accountId, folderId, folder, got, 0);
1390
+ }
1391
+ catch (e) {
1392
+ console.error(` ${folderPath}: backfill chunk ${i}-${i + chunk.length} failed (${e?.message || e}) — keeping ${recoveredCount} so far, resuming next cycle`);
1393
+ break;
1394
+ }
1395
+ console.log(` ${folderPath}: backfill ${Math.min(i + BACKFILL_CHUNK_SIZE, toFetch.length)}/${toFetch.length} (stored ${recoveredCount})`);
1396
+ }
1397
+ if (recoveredCount > 0) {
1398
+ this.db.recalcFolderCounts(folderId);
1399
+ this.emit("folderCountsChanged", accountId, {});
1400
+ }
1401
+ return { recoveredCount, serverUids, dateBounded };
1402
+ }
1310
1403
  async syncFolder(accountId, folderId, client) {
1311
1404
  const lockKey = `${accountId}:${folderId}`;
1312
1405
  const inflight = this.syncFolderLocks.get(lockKey);
@@ -1501,6 +1594,46 @@ export class ImapManager extends EventEmitter {
1501
1594
  await this.storeMessages(accountId, folderId, folder, newOnes, highestUid);
1502
1595
  console.log(` [qr-phase] ${folder.path}: storeMessages(${newOnes.length}) in ${Date.now() - __t2}ms`);
1503
1596
  }
1597
+ // SET-DIFF GAP RECOVERY (bounded + throttled). The
1598
+ // fetchMessagesSinceUid above is a high-water-mark fetch —
1599
+ // it CANNOT recover a server UID that's missing below our
1600
+ // highest (a message that arrived while the daemon was down,
1601
+ // now under the watermark the next poll advances) or one
1602
+ // dropped mid-stream by a response desync. QRESYNC otherwise
1603
+ // returns here, making such gaps permanent (Bob 2026-06-20:
1604
+ // "messages before 7am are missing" — daemon crashed
1605
+ // overnight; the catch-up since-fetch lost the low UIDs of
1606
+ // the range and QRESYNC never reconciled). Run a full
1607
+ // set-diff on the first sync after boot, then at most once
1608
+ // per RECONCILE_THROTTLE_MS so the fast path stays fast.
1609
+ let backfilledCount = 0;
1610
+ // Run the set-diff when EITHER the folder has a known
1611
+ // deficit (local count < server count → messages are
1612
+ // missing, keep reconciling every cycle until caught up) OR
1613
+ // the throttle window elapsed (periodic safety sweep for
1614
+ // gaps that don't move the count, e.g. a desync-dropped
1615
+ // message replaced by a later arrival). On success advance
1616
+ // the throttle; on FAILURE leave it so the next cycle
1617
+ // retries instead of locking out for RECONCILE_THROTTLE_MS
1618
+ // (Bob 2026-06-20: boot-time "Not connected" failure locked
1619
+ // INBOX recovery out for 15 min).
1620
+ const localCount = this.db.getMessageCount(accountId, folderId);
1621
+ const hasDeficit = statusMessageCount !== null && localCount < statusMessageCount;
1622
+ const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
1623
+ const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
1624
+ if (hasDeficit || throttleElapsed) {
1625
+ try {
1626
+ const sd = await this.fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, new Set(newOnes.map((m) => m.uid)));
1627
+ backfilledCount = sd.recoveredCount;
1628
+ if (backfilledCount > 0) {
1629
+ console.log(` [qresync] ${accountId}/${folder.path}: set-diff backfilled ${backfilledCount} server-only UID(s)`);
1630
+ }
1631
+ this.lastReconcileMs.set(folderId, Date.now());
1632
+ }
1633
+ catch (e) {
1634
+ console.error(` [qresync] ${accountId}/${folder.path}: set-diff backfill failed: ${e?.message || e}`);
1635
+ }
1636
+ }
1504
1637
  // Persist new watermark — next resync starts here.
1505
1638
  if (qr.newHighestModSeq !== undefined) {
1506
1639
  this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
@@ -1515,7 +1648,7 @@ export class ImapManager extends EventEmitter {
1515
1648
  this.sweepPhantomRows(accountId, folderId, folder.path, serverUidNext);
1516
1649
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1517
1650
  console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1518
- return newOnes.length;
1651
+ return newOnes.length + backfilledCount;
1519
1652
  }
1520
1653
  }
1521
1654
  catch (qrErr) {
@@ -1571,92 +1704,19 @@ export class ImapManager extends EventEmitter {
1571
1704
  // brand-new account doesn't try to pull tens of thousands of
1572
1705
  // historical messages in one cycle. Hit the cap and the next
1573
1706
  // sync picks up the rest.
1574
- const existingUids = this.db.getUidsForFolder(accountId, folderId);
1575
1707
  try {
1576
- // Date-bound the server UID query when we have a history
1577
- // window. UID SEARCH ALL on a 134k-message INBOX returns
1578
- // 134k integers and triggers a heavyweight full-folder
1579
- // scan on the server; UID SEARCH SINCE <date> returns only
1580
- // UIDs in the window we actually care about. For
1581
- // historyDays=0 ("keep everything") we still bound to the
1582
- // last 5 years on incremental syncs enough that we never
1583
- // miss an in-flight message, without enumerating decades of
1584
- // archive every cycle. First sync gets all UIDs (no anchor
1585
- // yet so we have to compare against the empty local set
1586
- // anyway).
1587
- // Small folders (Drafts, Sent, Outbox, Trash, …) get a FULL
1588
- // UID fetch instead of date-bounded. Without this, server-side
1589
- // deletions of OLDER messages (e.g., user emptied Trash from
1590
- // Thunderbird) are never reflected — date-bound filters them
1591
- // out of the comparison entirely. Bob 2026-05-12: "I deleted
1592
- // my drafts and Thunderbird shows that but rmfmail is not
1593
- // acknowledging the deletions." Threshold tuned conservatively
1594
- // — UID SEARCH ALL on a 500-message folder is sub-second; the
1595
- // pathology being avoided is the same call on 130k+ INBOX.
1596
- const SMALL_FOLDER_THRESHOLD = 500;
1597
- const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1598
- const SINCE_DAYS = effectiveDays > 0 ? effectiveDays : 1825;
1599
- const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1600
- let allServerUids;
1601
- const __uidsT0 = Date.now();
1602
- if (isSmallFolder) {
1603
- console.log(` [sync-uids] ${accountId}/${folder.path}: small folder (server=${statusMessageCount}) — getUids ALL`);
1604
- allServerUids = await client.getUids(folder.path);
1605
- serverUidsAreDateBounded = false;
1606
- }
1607
- else {
1608
- console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1609
- allServerUids = typeof client.getUidsSince === "function"
1610
- ? await client.getUidsSince(folder.path, sinceDate)
1611
- : await client.getUids(folder.path);
1612
- serverUidsAreDateBounded = true;
1613
- }
1614
- console.log(` [sync-uids] ${accountId}/${folder.path}: ${allServerUids.length} UIDs in ${Date.now() - __uidsT0}ms`);
1615
- // Stash for the deletion-reconciliation block below — we
1616
- // already have the server UID list, no point hitting the
1617
- // server a second time.
1618
- serverUidsCached = allServerUids;
1619
- const existingSet = new Set(existingUids);
1620
- const newSet = new Set(messages.map(m => m.uid));
1621
- const missingUids = allServerUids.filter((uid) => !existingSet.has(uid) && !newSet.has(uid));
1622
- // Backfill chunk size. Use the passed-in `client` directly
1623
- // (NOT a nested withConnection) — syncFolder is now wrapped
1624
- // in withConnection at the call site, so the slow lane is
1625
- // already locked for our duration. A nested withConnection
1626
- // would deadlock waiting for the slot we hold.
1627
- const BACKFILL_CHUNK_SIZE = 100;
1628
- if (missingUids.length > 0 && missingUids.length <= 5000) {
1629
- let minU = existingUids[0] ?? 0;
1630
- for (let i = 1; i < existingUids.length; i++)
1631
- if (existingUids[i] < minU)
1632
- minU = existingUids[i];
1633
- console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
1634
- let recoveredTotal = 0;
1635
- for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1636
- const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1637
- const range = chunk.join(",");
1638
- const recovered = await client.fetchMessages(folder.path, range, { source: false });
1639
- messages.push(...recovered);
1640
- recoveredTotal += recovered.length;
1641
- console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
1642
- }
1643
- }
1644
- else if (missingUids.length > 5000) {
1645
- console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
1646
- // Vanilla IMAP under stable UIDVALIDITY: higher UID =
1647
- // later assignment ≈ more recent message (Dovecot/
1648
- // Cyrus). Gmail-API path is separate (no temporal
1649
- // meaning to its hash-UIDs).
1650
- const cappedSlice = missingUids.sort((a, b) => b - a).slice(0, 5000);
1651
- let recoveredTotal = 0;
1652
- for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1653
- const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
1654
- const recovered = await client.fetchMessages(folder.path, chunk.join(","), { source: false });
1655
- messages.push(...recovered);
1656
- recoveredTotal += recovered.length;
1657
- console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
1658
- }
1659
- }
1708
+ // Shared with the QRESYNC fast path (fetchServerOnlyUids). It
1709
+ // stores recovered messages itself (chunk-at-a-time, so a
1710
+ // dropped connection keeps partial progress). Exclude the UIDs
1711
+ // we just fetched via fetchMessagesSinceUid they're in
1712
+ // `messages`, about to be stored by the batch loop below, so
1713
+ // they'd otherwise look "missing" and get re-fetched.
1714
+ const sd = await this.fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, new Set(messages.map(m => m.uid)));
1715
+ // Stash the server UID list for the deletion-reconciliation
1716
+ // block below no point hitting the server a second time.
1717
+ serverUidsCached = sd.serverUids;
1718
+ serverUidsAreDateBounded = sd.dateBounded;
1719
+ this.lastReconcileMs.set(folderId, Date.now());
1660
1720
  }
1661
1721
  catch (e) {
1662
1722
  console.error(` ${folder.path}: reconciliation failed: ${e.message}`);
@@ -3017,7 +3077,7 @@ export class ImapManager extends EventEmitter {
3017
3077
  * Server fetch goes through the unified ops queue on the fast lane —
3018
3078
  * the user clicked, they're waiting, this jumps ahead of any background
3019
3079
  * prefetch sitting in the slow lane. */
3020
- async fetchMessageBody(accountId, folderId, uid) {
3080
+ async fetchMessageBody(accountId, folderId, uid, force = false) {
3021
3081
  // Belt-and-braces against `UID FETCH 0`. The IMAP server rejects it as
3022
3082
  // BAD "Invalid uidset" and the connection slot is consumed for the
3023
3083
  // round-trip. The enqueue path now guards too — this catches direct
@@ -3042,7 +3102,17 @@ export class ImapManager extends EventEmitter {
3042
3102
  // delayed until the client timed out. The 5min→12h backoff (shared with
3043
3103
  // prefetch via isPrefetchEmpty) stops the tight re-fetch loop; the body
3044
3104
  // simply shows as unavailable until the backoff lapses and one retry runs.
3045
- if (this.isPrefetchEmpty(accountId, folderId, uid))
3105
+ // EXCEPT an interactive click (`force`): the user is staring at a blank
3106
+ // preview demanding THIS body NOW. Honoring a *background* prefetch
3107
+ // failure's backoff for an explicit user action is a local-first
3108
+ // violation — it left a real INBOX message (bobma uid 4967554,
3109
+ // 2026-06-19) showing an eternal "getting body" spinner for up to 12 h
3110
+ // because one earlier prefetch chunk returned 0 bodies (an iflow parser
3111
+ // miss, not a real expunge — siblings recovered on retry). A forced
3112
+ // single-UID fetch re-attempts immediately; if it genuinely fails it
3113
+ // re-arms the backoff below and the reconciler emits bodyFetchError so
3114
+ // the viewer shows a banner instead of spinning forever.
3115
+ if (!force && this.isPrefetchEmpty(accountId, folderId, uid))
3046
3116
  return null;
3047
3117
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
3048
3118
  if (!folder)
@@ -3067,6 +3137,9 @@ export class ImapManager extends EventEmitter {
3067
3137
  return null;
3068
3138
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
3069
3139
  this.db.updateBodyPath(accountId, folderId, uid, bodyPath);
3140
+ // A forced fetch that beat a stale backoff just healed the row;
3141
+ // drop the prefetch-empty record so it doesn't suppress the next tick.
3142
+ this.clearPrefetchEmpty(accountId, folderId, uid);
3070
3143
  this.emit("bodyCached", accountId, uid);
3071
3144
  return raw;
3072
3145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.100",
3
+ "version": "0.1.103",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.19",
13
- "@bobfrankston/mailx-settings": "^0.1.28",
13
+ "@bobfrankston/mailx-settings": "^0.1.29",
14
14
  "@bobfrankston/mailx-store": "^0.1.54",
15
15
  "@bobfrankston/iflow-direct": "^0.1.55",
16
16
  "@bobfrankston/tcp-transport": "^0.1.7",
@@ -38,7 +38,7 @@
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.19",
41
- "@bobfrankston/mailx-settings": "^0.1.28",
41
+ "@bobfrankston/mailx-settings": "^0.1.29",
42
42
  "@bobfrankston/mailx-store": "^0.1.54",
43
43
  "@bobfrankston/iflow-direct": "^0.1.55",
44
44
  "@bobfrankston/tcp-transport": "^0.1.7",