@bobfrankston/mailx-imap 0.1.102 → 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 (2) hide show
  1. package/index.js +52 -23
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -1331,7 +1331,8 @@ export class ImapManager extends EventEmitter {
1331
1331
  * caller's deletion-reconcile can reuse it instead of paying for a second
1332
1332
  * UID SEARCH round-trip.
1333
1333
  */
1334
- async fetchServerOnlyUids(client, accountId, folderId, folderPath, statusMessageCount, excludeUids) {
1334
+ async fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, excludeUids) {
1335
+ const folderPath = folder.path;
1335
1336
  const existingUids = this.db.getUidsForFolder(accountId, folderId);
1336
1337
  // Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
1337
1338
  // deletions of OLDER messages are reflected; large folders (134k INBOX)
@@ -1359,29 +1360,45 @@ export class ImapManager extends EventEmitter {
1359
1360
  console.log(` [sync-uids] ${accountId}/${folderPath}: ${serverUids.length} UIDs in ${Date.now() - __t0}ms`);
1360
1361
  const existingSet = new Set(existingUids);
1361
1362
  const missingUids = serverUids.filter((uid) => !existingSet.has(uid) && !excludeUids.has(uid));
1362
- const recovered = [];
1363
1363
  if (missingUids.length === 0)
1364
- return { recovered, serverUids, dateBounded };
1364
+ return { recoveredCount: 0, serverUids, dateBounded };
1365
1365
  // Cap so a misbehaving response / brand-new account doesn't pull tens
1366
1366
  // of thousands at once; the rest resumes next cycle. Higher UID ≈ more
1367
- // recent under stable UIDVALIDITY, so prefer newest when capping.
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.
1368
1370
  const BACKFILL_CHUNK_SIZE = 100;
1369
1371
  const RECONCILE_FETCH_CAP = 5000;
1370
1372
  let toFetch = missingUids;
1371
1373
  if (missingUids.length > RECONCILE_FETCH_CAP) {
1372
- console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; will resume next cycle`);
1374
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; resumes next cycle`);
1373
1375
  toFetch = missingUids.slice().sort((a, b) => b - a).slice(0, RECONCILE_FETCH_CAP);
1374
1376
  }
1375
1377
  else {
1376
1378
  console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — fetching`);
1377
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;
1378
1385
  for (let i = 0; i < toFetch.length; i += BACKFILL_CHUNK_SIZE) {
1379
1386
  const chunk = toFetch.slice(i, i + BACKFILL_CHUNK_SIZE);
1380
- const got = await client.fetchMessages(folderPath, chunk.join(","), { source: false });
1381
- recovered.push(...got);
1382
- console.log(` ${folderPath}: backfill ${recovered.length}/${toFetch.length}`);
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})`);
1383
1396
  }
1384
- return { recovered, serverUids, dateBounded };
1397
+ if (recoveredCount > 0) {
1398
+ this.db.recalcFolderCounts(folderId);
1399
+ this.emit("folderCountsChanged", accountId, {});
1400
+ }
1401
+ return { recoveredCount, serverUids, dateBounded };
1385
1402
  }
1386
1403
  async syncFolder(accountId, folderId, client) {
1387
1404
  const lockKey = `${accountId}:${folderId}`;
@@ -1590,17 +1607,28 @@ export class ImapManager extends EventEmitter {
1590
1607
  // set-diff on the first sync after boot, then at most once
1591
1608
  // per RECONCILE_THROTTLE_MS so the fast path stays fast.
1592
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;
1593
1622
  const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
1594
- if (Date.now() - lastRecon > RECONCILE_THROTTLE_MS) {
1595
- this.lastReconcileMs.set(folderId, Date.now());
1623
+ const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
1624
+ if (hasDeficit || throttleElapsed) {
1596
1625
  try {
1597
- const sd = await this.fetchServerOnlyUids(client, accountId, folderId, folder.path, statusMessageCount, new Set(newOnes.map((m) => m.uid)));
1598
- if (sd.recovered.length > 0) {
1599
- backfilledCount = await this.storeMessages(accountId, folderId, folder, sd.recovered, highestUid);
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) {
1600
1629
  console.log(` [qresync] ${accountId}/${folder.path}: set-diff backfilled ${backfilledCount} server-only UID(s)`);
1601
- this.db.recalcFolderCounts(folderId);
1602
- this.emit("folderCountsChanged", accountId, {});
1603
1630
  }
1631
+ this.lastReconcileMs.set(folderId, Date.now());
1604
1632
  }
1605
1633
  catch (e) {
1606
1634
  console.error(` [qresync] ${accountId}/${folder.path}: set-diff backfill failed: ${e?.message || e}`);
@@ -1677,21 +1705,22 @@ export class ImapManager extends EventEmitter {
1677
1705
  // historical messages in one cycle. Hit the cap and the next
1678
1706
  // sync picks up the rest.
1679
1707
  try {
1680
- // Shared with the QRESYNC fast path (fetchServerOnlyUids).
1681
- // Exclude the UIDs we just fetched via fetchMessagesSinceUid —
1682
- // they're in `messages` but not yet in the DB, so they'd
1683
- // otherwise look "missing" and get re-fetched.
1684
- const sd = await this.fetchServerOnlyUids(client, accountId, folderId, folder.path, statusMessageCount, new Set(messages.map(m => m.uid)));
1685
- messages.push(...sd.recovered);
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)));
1686
1715
  // Stash the server UID list for the deletion-reconciliation
1687
1716
  // block below — no point hitting the server a second time.
1688
1717
  serverUidsCached = sd.serverUids;
1689
1718
  serverUidsAreDateBounded = sd.dateBounded;
1719
+ this.lastReconcileMs.set(folderId, Date.now());
1690
1720
  }
1691
1721
  catch (e) {
1692
1722
  console.error(` ${folder.path}: reconciliation failed: ${e.message}`);
1693
1723
  }
1694
- this.lastReconcileMs.set(folderId, Date.now());
1695
1724
  // Date-based backfill via SEARCH SINCE was here. Removed —
1696
1725
  // set-diff above already covers the same case (any server UID
1697
1726
  // not in our local set gets fetched, regardless of whether it's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.102",
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",