@bobfrankston/rmfmail 1.2.46 → 1.2.47

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.
@@ -1346,10 +1346,11 @@ export class ImapManager extends EventEmitter {
1346
1346
  client: any,
1347
1347
  accountId: string,
1348
1348
  folderId: number,
1349
- folderPath: string,
1349
+ folder: { id: number; path: string },
1350
1350
  statusMessageCount: number | null,
1351
1351
  excludeUids: Set<number>,
1352
- ): Promise<{ recovered: any[]; serverUids: number[] | null; dateBounded: boolean }> {
1352
+ ): Promise<{ recoveredCount: number; serverUids: number[] | null; dateBounded: boolean }> {
1353
+ const folderPath = folder.path;
1353
1354
  const existingUids = this.db.getUidsForFolder(accountId, folderId);
1354
1355
  // Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
1355
1356
  // deletions of OLDER messages are reflected; large folders (134k INBOX)
@@ -1376,27 +1377,42 @@ export class ImapManager extends EventEmitter {
1376
1377
  console.log(` [sync-uids] ${accountId}/${folderPath}: ${serverUids.length} UIDs in ${Date.now() - __t0}ms`);
1377
1378
  const existingSet = new Set(existingUids);
1378
1379
  const missingUids = serverUids.filter((uid: number) => !existingSet.has(uid) && !excludeUids.has(uid));
1379
- const recovered: any[] = [];
1380
- if (missingUids.length === 0) return { recovered, serverUids, dateBounded };
1380
+ if (missingUids.length === 0) return { recoveredCount: 0, serverUids, dateBounded };
1381
1381
  // Cap so a misbehaving response / brand-new account doesn't pull tens
1382
1382
  // of thousands at once; the rest resumes next cycle. Higher UID ≈ more
1383
- // recent under stable UIDVALIDITY, so prefer newest when capping.
1383
+ // recent under stable UIDVALIDITY, so prefer newest when capping — the
1384
+ // user cares most about recent mail and the deficit gate keeps calling
1385
+ // us until the backlog is drained.
1384
1386
  const BACKFILL_CHUNK_SIZE = 100;
1385
1387
  const RECONCILE_FETCH_CAP = 5000;
1386
1388
  let toFetch = missingUids;
1387
1389
  if (missingUids.length > RECONCILE_FETCH_CAP) {
1388
- console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; will resume next cycle`);
1390
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; resumes next cycle`);
1389
1391
  toFetch = missingUids.slice().sort((a: number, b: number) => b - a).slice(0, RECONCILE_FETCH_CAP);
1390
1392
  } else {
1391
1393
  console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — fetching`);
1392
1394
  }
1395
+ // Store each chunk as it arrives so a mid-stream connection drop (the
1396
+ // "Not connected" / desync class on this server) keeps the progress it
1397
+ // already made instead of losing the whole batch. Stop on the first
1398
+ // chunk error; the deficit gate calls us again next cycle to resume.
1399
+ let recoveredCount = 0;
1393
1400
  for (let i = 0; i < toFetch.length; i += BACKFILL_CHUNK_SIZE) {
1394
1401
  const chunk = toFetch.slice(i, i + BACKFILL_CHUNK_SIZE);
1395
- const got = await client.fetchMessages(folderPath, chunk.join(","), { source: false });
1396
- recovered.push(...got);
1397
- console.log(` ${folderPath}: backfill ${recovered.length}/${toFetch.length}`);
1402
+ try {
1403
+ const got = await client.fetchMessages(folderPath, chunk.join(","), { source: false });
1404
+ recoveredCount += await this.storeMessages(accountId, folderId, folder, got, 0);
1405
+ } catch (e: any) {
1406
+ console.error(` ${folderPath}: backfill chunk ${i}-${i + chunk.length} failed (${e?.message || e}) — keeping ${recoveredCount} so far, resuming next cycle`);
1407
+ break;
1408
+ }
1409
+ console.log(` ${folderPath}: backfill ${Math.min(i + BACKFILL_CHUNK_SIZE, toFetch.length)}/${toFetch.length} (stored ${recoveredCount})`);
1398
1410
  }
1399
- return { recovered, serverUids, dateBounded };
1411
+ if (recoveredCount > 0) {
1412
+ this.db.recalcFolderCounts(folderId);
1413
+ this.emit("folderCountsChanged", accountId, {});
1414
+ }
1415
+ return { recoveredCount, serverUids, dateBounded };
1400
1416
  }
1401
1417
 
1402
1418
  async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
@@ -1611,20 +1627,31 @@ export class ImapManager extends EventEmitter {
1611
1627
  // set-diff on the first sync after boot, then at most once
1612
1628
  // per RECONCILE_THROTTLE_MS so the fast path stays fast.
1613
1629
  let backfilledCount = 0;
1630
+ // Run the set-diff when EITHER the folder has a known
1631
+ // deficit (local count < server count → messages are
1632
+ // missing, keep reconciling every cycle until caught up) OR
1633
+ // the throttle window elapsed (periodic safety sweep for
1634
+ // gaps that don't move the count, e.g. a desync-dropped
1635
+ // message replaced by a later arrival). On success advance
1636
+ // the throttle; on FAILURE leave it so the next cycle
1637
+ // retries instead of locking out for RECONCILE_THROTTLE_MS
1638
+ // (Bob 2026-06-20: boot-time "Not connected" failure locked
1639
+ // INBOX recovery out for 15 min).
1640
+ const localCount = this.db.getMessageCount(accountId, folderId);
1641
+ const hasDeficit = statusMessageCount !== null && localCount < statusMessageCount;
1614
1642
  const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
1615
- if (Date.now() - lastRecon > RECONCILE_THROTTLE_MS) {
1616
- this.lastReconcileMs.set(folderId, Date.now());
1643
+ const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
1644
+ if (hasDeficit || throttleElapsed) {
1617
1645
  try {
1618
1646
  const sd = await this.fetchServerOnlyUids(
1619
- client, accountId, folderId, folder.path, statusMessageCount,
1647
+ client, accountId, folderId, folder, statusMessageCount,
1620
1648
  new Set(newOnes.map((m: any) => m.uid)),
1621
1649
  );
1622
- if (sd.recovered.length > 0) {
1623
- backfilledCount = await this.storeMessages(accountId, folderId, folder, sd.recovered, highestUid);
1650
+ backfilledCount = sd.recoveredCount;
1651
+ if (backfilledCount > 0) {
1624
1652
  console.log(` [qresync] ${accountId}/${folder.path}: set-diff backfilled ${backfilledCount} server-only UID(s)`);
1625
- this.db.recalcFolderCounts(folderId);
1626
- this.emit("folderCountsChanged", accountId, {});
1627
1653
  }
1654
+ this.lastReconcileMs.set(folderId, Date.now());
1628
1655
  } catch (e: any) {
1629
1656
  console.error(` [qresync] ${accountId}/${folder.path}: set-diff backfill failed: ${e?.message || e}`);
1630
1657
  }
@@ -1704,23 +1731,24 @@ export class ImapManager extends EventEmitter {
1704
1731
  // historical messages in one cycle. Hit the cap and the next
1705
1732
  // sync picks up the rest.
1706
1733
  try {
1707
- // Shared with the QRESYNC fast path (fetchServerOnlyUids).
1708
- // Exclude the UIDs we just fetched via fetchMessagesSinceUid —
1709
- // they're in `messages` but not yet in the DB, so they'd
1710
- // otherwise look "missing" and get re-fetched.
1734
+ // Shared with the QRESYNC fast path (fetchServerOnlyUids). It
1735
+ // stores recovered messages itself (chunk-at-a-time, so a
1736
+ // dropped connection keeps partial progress). Exclude the UIDs
1737
+ // we just fetched via fetchMessagesSinceUid — they're in
1738
+ // `messages`, about to be stored by the batch loop below, so
1739
+ // they'd otherwise look "missing" and get re-fetched.
1711
1740
  const sd = await this.fetchServerOnlyUids(
1712
- client, accountId, folderId, folder.path, statusMessageCount,
1741
+ client, accountId, folderId, folder, statusMessageCount,
1713
1742
  new Set(messages.map(m => m.uid)),
1714
1743
  );
1715
- messages.push(...sd.recovered);
1716
1744
  // Stash the server UID list for the deletion-reconciliation
1717
1745
  // block below — no point hitting the server a second time.
1718
1746
  serverUidsCached = sd.serverUids;
1719
1747
  serverUidsAreDateBounded = sd.dateBounded;
1748
+ this.lastReconcileMs.set(folderId, Date.now());
1720
1749
  } catch (e: any) {
1721
1750
  console.error(` ${folder.path}: reconciliation failed: ${e.message}`);
1722
1751
  }
1723
- this.lastReconcileMs.set(folderId, Date.now());
1724
1752
 
1725
1753
  // Date-based backfill via SEARCH SINCE was here. Removed —
1726
1754
  // set-diff above already covers the same case (any server UID
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.102",
3
+ "version": "0.1.103",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.102",
9
+ "version": "0.1.103",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",