@bobfrankston/rmfmail 1.2.42 → 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.
@@ -44,6 +44,12 @@ interface HostSemaphore {
44
44
  * lost-ack doesn't arrive while the first copy is still being processed. */
45
45
  const OUTBOX_RETRY_DELAY_MS = 60000;
46
46
 
47
+ /** How often the QRESYNC fast path runs a full set-difference reconcile to
48
+ * heal gaps the high-water-mark fetch can't see. The first sync of a folder
49
+ * after boot always runs one (the map is empty); thereafter it's throttled to
50
+ * this interval so the QRESYNC speedup is preserved in steady state. */
51
+ const RECONCILE_THROTTLE_MS = 15 * 60 * 1000;
52
+
47
53
  /** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
48
54
  function parseRetryInfo(raw: string): { attemptCount: number; nextAttemptAt: number } {
49
55
  const headerEnd = raw.search(/\r?\n\r?\n/);
@@ -1316,6 +1322,99 @@ export class ImapManager extends EventEmitter {
1316
1322
  * expect their syncFolder for `Sent` to actually run. */
1317
1323
  private syncFolderLocks = new Map<string, Promise<number>>();
1318
1324
 
1325
+ /** Per-folder timestamp of the last set-difference reconcile. Drives the
1326
+ * throttle for the QRESYNC-path gap backfill (see fetchServerOnlyUids /
1327
+ * syncFolder QRESYNC branch). Empty at boot, so the first sync of each
1328
+ * folder after a restart always runs a reconcile — which is exactly when
1329
+ * a daemon-was-down gap needs to be healed. */
1330
+ private lastReconcileMs = new Map<number, number>();
1331
+
1332
+ /**
1333
+ * Set-difference backfill. Fetch every server UID (within the history
1334
+ * window) that we don't already have locally and aren't fetching this
1335
+ * cycle. This is the ONLY mechanism that recovers a message which is on
1336
+ * the server but missing locally — the high-water-mark fetch
1337
+ * (fetchMessagesSinceUid) cannot, because it only looks above the local
1338
+ * highest UID. Gaps it heals: a message that arrived while the daemon was
1339
+ * down (so it sits below the watermark once the next poll advances it), or
1340
+ * one dropped mid-stream by a response desync. Returns the recovered
1341
+ * messages (caller stores them) plus the server UID list it fetched, so the
1342
+ * caller's deletion-reconcile can reuse it instead of paying for a second
1343
+ * UID SEARCH round-trip.
1344
+ */
1345
+ private async fetchServerOnlyUids(
1346
+ client: any,
1347
+ accountId: string,
1348
+ folderId: number,
1349
+ folder: { id: number; path: string },
1350
+ statusMessageCount: number | null,
1351
+ excludeUids: Set<number>,
1352
+ ): Promise<{ recoveredCount: number; serverUids: number[] | null; dateBounded: boolean }> {
1353
+ const folderPath = folder.path;
1354
+ const existingUids = this.db.getUidsForFolder(accountId, folderId);
1355
+ // Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
1356
+ // deletions of OLDER messages are reflected; large folders (134k INBOX)
1357
+ // get a date-bounded query so we don't enumerate the whole mailbox.
1358
+ const SMALL_FOLDER_THRESHOLD = 500;
1359
+ const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1360
+ const historyDays = getHistoryDays(accountId);
1361
+ const SINCE_DAYS = historyDays > 0 ? historyDays : 1825;
1362
+ const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1363
+ let serverUids: number[];
1364
+ let dateBounded: boolean;
1365
+ const __t0 = Date.now();
1366
+ if (isSmallFolder) {
1367
+ console.log(` [sync-uids] ${accountId}/${folderPath}: small folder (server=${statusMessageCount}) — getUids ALL`);
1368
+ serverUids = await client.getUids(folderPath);
1369
+ dateBounded = false;
1370
+ } else {
1371
+ console.log(` [sync-uids] ${accountId}/${folderPath}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1372
+ serverUids = typeof client.getUidsSince === "function"
1373
+ ? await client.getUidsSince(folderPath, sinceDate)
1374
+ : await client.getUids(folderPath);
1375
+ dateBounded = true;
1376
+ }
1377
+ console.log(` [sync-uids] ${accountId}/${folderPath}: ${serverUids.length} UIDs in ${Date.now() - __t0}ms`);
1378
+ const existingSet = new Set(existingUids);
1379
+ const missingUids = serverUids.filter((uid: number) => !existingSet.has(uid) && !excludeUids.has(uid));
1380
+ if (missingUids.length === 0) return { recoveredCount: 0, serverUids, dateBounded };
1381
+ // Cap so a misbehaving response / brand-new account doesn't pull tens
1382
+ // of thousands at once; the rest resumes next cycle. Higher UID ≈ more
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.
1386
+ const BACKFILL_CHUNK_SIZE = 100;
1387
+ const RECONCILE_FETCH_CAP = 5000;
1388
+ let toFetch = missingUids;
1389
+ if (missingUids.length > RECONCILE_FETCH_CAP) {
1390
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — capped at ${RECONCILE_FETCH_CAP}; resumes next cycle`);
1391
+ toFetch = missingUids.slice().sort((a: number, b: number) => b - a).slice(0, RECONCILE_FETCH_CAP);
1392
+ } else {
1393
+ console.log(` ${folderPath}: ${missingUids.length} server-only UIDs — fetching`);
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;
1400
+ for (let i = 0; i < toFetch.length; i += BACKFILL_CHUNK_SIZE) {
1401
+ const chunk = toFetch.slice(i, i + BACKFILL_CHUNK_SIZE);
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})`);
1410
+ }
1411
+ if (recoveredCount > 0) {
1412
+ this.db.recalcFolderCounts(folderId);
1413
+ this.emit("folderCountsChanged", accountId, {});
1414
+ }
1415
+ return { recoveredCount, serverUids, dateBounded };
1416
+ }
1417
+
1319
1418
  async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
1320
1419
  const lockKey = `${accountId}:${folderId}`;
1321
1420
  const inflight = this.syncFolderLocks.get(lockKey);
@@ -1515,6 +1614,49 @@ export class ImapManager extends EventEmitter {
1515
1614
  console.log(` [qr-phase] ${folder.path}: storeMessages(${newOnes.length}) in ${Date.now() - __t2}ms`);
1516
1615
  }
1517
1616
 
1617
+ // SET-DIFF GAP RECOVERY (bounded + throttled). The
1618
+ // fetchMessagesSinceUid above is a high-water-mark fetch —
1619
+ // it CANNOT recover a server UID that's missing below our
1620
+ // highest (a message that arrived while the daemon was down,
1621
+ // now under the watermark the next poll advances) or one
1622
+ // dropped mid-stream by a response desync. QRESYNC otherwise
1623
+ // returns here, making such gaps permanent (Bob 2026-06-20:
1624
+ // "messages before 7am are missing" — daemon crashed
1625
+ // overnight; the catch-up since-fetch lost the low UIDs of
1626
+ // the range and QRESYNC never reconciled). Run a full
1627
+ // set-diff on the first sync after boot, then at most once
1628
+ // per RECONCILE_THROTTLE_MS so the fast path stays fast.
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;
1642
+ const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
1643
+ const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
1644
+ if (hasDeficit || throttleElapsed) {
1645
+ try {
1646
+ const sd = await this.fetchServerOnlyUids(
1647
+ client, accountId, folderId, folder, statusMessageCount,
1648
+ new Set(newOnes.map((m: any) => m.uid)),
1649
+ );
1650
+ backfilledCount = sd.recoveredCount;
1651
+ if (backfilledCount > 0) {
1652
+ console.log(` [qresync] ${accountId}/${folder.path}: set-diff backfilled ${backfilledCount} server-only UID(s)`);
1653
+ }
1654
+ this.lastReconcileMs.set(folderId, Date.now());
1655
+ } catch (e: any) {
1656
+ console.error(` [qresync] ${accountId}/${folder.path}: set-diff backfill failed: ${e?.message || e}`);
1657
+ }
1658
+ }
1659
+
1518
1660
  // Persist new watermark — next resync starts here.
1519
1661
  if (qr.newHighestModSeq !== undefined) {
1520
1662
  this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
@@ -1531,7 +1673,7 @@ export class ImapManager extends EventEmitter {
1531
1673
 
1532
1674
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1533
1675
  console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
1534
- return newOnes.length;
1676
+ return newOnes.length + backfilledCount;
1535
1677
  }
1536
1678
  } catch (qrErr: any) {
1537
1679
  // QRESYNC failed (server quirk, network glitch, etc.) — log
@@ -1588,90 +1730,22 @@ export class ImapManager extends EventEmitter {
1588
1730
  // brand-new account doesn't try to pull tens of thousands of
1589
1731
  // historical messages in one cycle. Hit the cap and the next
1590
1732
  // sync picks up the rest.
1591
- const existingUids = this.db.getUidsForFolder(accountId, folderId);
1592
1733
  try {
1593
- // Date-bound the server UID query when we have a history
1594
- // window. UID SEARCH ALL on a 134k-message INBOX returns
1595
- // 134k integers and triggers a heavyweight full-folder
1596
- // scan on the server; UID SEARCH SINCE <date> returns only
1597
- // UIDs in the window we actually care about. For
1598
- // historyDays=0 ("keep everything") we still bound to the
1599
- // last 5 years on incremental syncs — enough that we never
1600
- // miss an in-flight message, without enumerating decades of
1601
- // archive every cycle. First sync gets all UIDs (no anchor
1602
- // yet so we have to compare against the empty local set
1603
- // anyway).
1604
- // Small folders (Drafts, Sent, Outbox, Trash, …) get a FULL
1605
- // UID fetch instead of date-bounded. Without this, server-side
1606
- // deletions of OLDER messages (e.g., user emptied Trash from
1607
- // Thunderbird) are never reflected — date-bound filters them
1608
- // out of the comparison entirely. Bob 2026-05-12: "I deleted
1609
- // my drafts and Thunderbird shows that but rmfmail is not
1610
- // acknowledging the deletions." Threshold tuned conservatively
1611
- // — UID SEARCH ALL on a 500-message folder is sub-second; the
1612
- // pathology being avoided is the same call on 130k+ INBOX.
1613
- const SMALL_FOLDER_THRESHOLD = 500;
1614
- const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1615
- const SINCE_DAYS = effectiveDays > 0 ? effectiveDays : 1825;
1616
- const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1617
- let allServerUids: number[];
1618
- const __uidsT0 = Date.now();
1619
- if (isSmallFolder) {
1620
- console.log(` [sync-uids] ${accountId}/${folder.path}: small folder (server=${statusMessageCount}) — getUids ALL`);
1621
- allServerUids = await (client as any).getUids(folder.path);
1622
- serverUidsAreDateBounded = false;
1623
- } else {
1624
- console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0,10)}...`);
1625
- allServerUids = typeof (client as any).getUidsSince === "function"
1626
- ? await (client as any).getUidsSince(folder.path, sinceDate)
1627
- : await (client as any).getUids(folder.path);
1628
- serverUidsAreDateBounded = true;
1629
- }
1630
- console.log(` [sync-uids] ${accountId}/${folder.path}: ${allServerUids.length} UIDs in ${Date.now() - __uidsT0}ms`);
1631
- // Stash for the deletion-reconciliation block below — we
1632
- // already have the server UID list, no point hitting the
1633
- // server a second time.
1634
- serverUidsCached = allServerUids;
1635
- const existingSet = new Set(existingUids);
1636
- const newSet = new Set(messages.map(m => m.uid));
1637
- const missingUids = allServerUids.filter((uid: number) =>
1638
- !existingSet.has(uid) && !newSet.has(uid)
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.
1740
+ const sd = await this.fetchServerOnlyUids(
1741
+ client, accountId, folderId, folder, statusMessageCount,
1742
+ new Set(messages.map(m => m.uid)),
1639
1743
  );
1640
- // Backfill chunk size. Use the passed-in `client` directly
1641
- // (NOT a nested withConnection) syncFolder is now wrapped
1642
- // in withConnection at the call site, so the slow lane is
1643
- // already locked for our duration. A nested withConnection
1644
- // would deadlock waiting for the slot we hold.
1645
- const BACKFILL_CHUNK_SIZE = 100;
1646
- if (missingUids.length > 0 && missingUids.length <= 5000) {
1647
- let minU = existingUids[0] ?? 0;
1648
- for (let i = 1; i < existingUids.length; i++) if (existingUids[i] < minU) minU = existingUids[i];
1649
- console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
1650
- let recoveredTotal = 0;
1651
- for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1652
- const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1653
- const range = chunk.join(",");
1654
- const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
1655
- messages.push(...recovered);
1656
- recoveredTotal += recovered.length;
1657
- console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
1658
- }
1659
- } else if (missingUids.length > 5000) {
1660
- console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
1661
- // Vanilla IMAP under stable UIDVALIDITY: higher UID =
1662
- // later assignment ≈ more recent message (Dovecot/
1663
- // Cyrus). Gmail-API path is separate (no temporal
1664
- // meaning to its hash-UIDs).
1665
- const cappedSlice = missingUids.sort((a: number, b: number) => b - a).slice(0, 5000);
1666
- let recoveredTotal = 0;
1667
- for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1668
- const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
1669
- const recovered = await (client as any).fetchMessages(folder.path, chunk.join(","), { source: false });
1670
- messages.push(...recovered);
1671
- recoveredTotal += recovered.length;
1672
- console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
1673
- }
1674
- }
1744
+ // Stash the server UID list for the deletion-reconciliation
1745
+ // block below no point hitting the server a second time.
1746
+ serverUidsCached = sd.serverUids;
1747
+ serverUidsAreDateBounded = sd.dateBounded;
1748
+ this.lastReconcileMs.set(folderId, Date.now());
1675
1749
  } catch (e: any) {
1676
1750
  console.error(` ${folder.path}: reconciliation failed: ${e.message}`);
1677
1751
  }
@@ -3018,7 +3092,7 @@ export class ImapManager extends EventEmitter {
3018
3092
  * Server fetch goes through the unified ops queue on the fast lane —
3019
3093
  * the user clicked, they're waiting, this jumps ahead of any background
3020
3094
  * prefetch sitting in the slow lane. */
3021
- async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null> {
3095
+ async fetchMessageBody(accountId: string, folderId: number, uid: number, force = false): Promise<Buffer | null> {
3022
3096
  // Belt-and-braces against `UID FETCH 0`. The IMAP server rejects it as
3023
3097
  // BAD "Invalid uidset" and the connection slot is consumed for the
3024
3098
  // round-trip. The enqueue path now guards too — this catches direct
@@ -3043,7 +3117,17 @@ export class ImapManager extends EventEmitter {
3043
3117
  // delayed until the client timed out. The 5min→12h backoff (shared with
3044
3118
  // prefetch via isPrefetchEmpty) stops the tight re-fetch loop; the body
3045
3119
  // simply shows as unavailable until the backoff lapses and one retry runs.
3046
- if (this.isPrefetchEmpty(accountId, folderId, uid)) return null;
3120
+ // EXCEPT an interactive click (`force`): the user is staring at a blank
3121
+ // preview demanding THIS body NOW. Honoring a *background* prefetch
3122
+ // failure's backoff for an explicit user action is a local-first
3123
+ // violation — it left a real INBOX message (bobma uid 4967554,
3124
+ // 2026-06-19) showing an eternal "getting body" spinner for up to 12 h
3125
+ // because one earlier prefetch chunk returned 0 bodies (an iflow parser
3126
+ // miss, not a real expunge — siblings recovered on retry). A forced
3127
+ // single-UID fetch re-attempts immediately; if it genuinely fails it
3128
+ // re-arms the backoff below and the reconciler emits bodyFetchError so
3129
+ // the viewer shows a banner instead of spinning forever.
3130
+ if (!force && this.isPrefetchEmpty(accountId, folderId, uid)) return null;
3047
3131
 
3048
3132
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
3049
3133
  if (!folder) return null;
@@ -3066,6 +3150,9 @@ export class ImapManager extends EventEmitter {
3066
3150
  if (!raw) return null;
3067
3151
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
3068
3152
  this.db.updateBodyPath(accountId, folderId, uid, bodyPath);
3153
+ // A forced fetch that beat a stale backoff just healed the row;
3154
+ // drop the prefetch-empty record so it doesn't suppress the next tick.
3155
+ this.clearPrefetchEmpty(accountId, folderId, uid);
3069
3156
  this.emit("bodyCached", accountId, uid);
3070
3157
  return raw;
3071
3158
  } catch (e: any) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.100",
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.100",
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.100",
3
+ "version": "0.1.103",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1 +1 @@
1
- {"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAC9B;;sDAEkD;IAClD,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;8DAC0D;IAC1D,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;0EACsE;IACtE,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,mEAAmE;IACnE,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;yCACqC;IACrC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACnC;AAYD,qBAAa,UAAU;IAcf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,KAAK;IAfjB,OAAO,CAAC,IAAI,CAA8B;IAC1C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,WAAW,CAA+C;IAClE,OAAO,CAAC,aAAa,CAA+C;IACpE,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,eAAe,CAAK;IAC5B;;;wEAGoE;IACpE,OAAO,CAAC,UAAU,CAAgE;gBAGtE,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,EACxB,IAAI,GAAE,iBAAsB;IAKhC,KAAK,IAAI,IAAI;IAyCb,IAAI,IAAI,IAAI;IAOZ;oDACgD;IAChD,OAAO,CAAC,iBAAiB;IAMzB;;;gDAG4C;IAC5C,OAAO,CAAC,eAAe;IAoBvB;oEACgE;IAChE,OAAO,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IAOjC;;2DAEuD;IACvD,OAAO,CAAC,eAAe;IA8GvB,yEAAyE;IACzE,IAAI,IAAI,IAAI;CAGf"}
1
+ {"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAC9B;;sDAEkD;IAClD,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;8DAC0D;IAC1D,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;0EACsE;IACtE,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,mEAAmE;IACnE,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;yCACqC;IACrC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACnC;AAYD,qBAAa,UAAU;IAcf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,KAAK;IAfjB,OAAO,CAAC,IAAI,CAA8B;IAC1C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,WAAW,CAA+C;IAClE,OAAO,CAAC,aAAa,CAA+C;IACpE,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,eAAe,CAAK;IAC5B;;;wEAGoE;IACpE,OAAO,CAAC,UAAU,CAAgE;gBAGtE,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,EACxB,IAAI,GAAE,iBAAsB;IAKhC,KAAK,IAAI,IAAI;IAyCb,IAAI,IAAI,IAAI;IAOZ;oDACgD;IAChD,OAAO,CAAC,iBAAiB;IAMzB;;;gDAG4C;IAC5C,OAAO,CAAC,eAAe;IAoBvB;oEACgE;IAChE,OAAO,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IAOjC;;2DAEuD;IACvD,OAAO,CAAC,eAAe;IAmHvB,yEAAyE;IACzE,IAAI,IAAI,IAAI;CAGf"}
@@ -171,8 +171,13 @@ export class Reconciler {
171
171
  // the same dead socket.
172
172
  const FETCH_TIMEOUT_MS = 90_000;
173
173
  let timeoutHandle;
174
+ // An interactive click forces the fetch past the per-message
175
+ // prefetch-empty backoff — the user explicitly wants THIS
176
+ // body now, and a background prefetch miss must not suppress
177
+ // it (see fetchMessageBody's `force` note, bobma 4967554).
178
+ const force = next.lane === "interactive";
174
179
  const raw = await Promise.race([
175
- this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid),
180
+ this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid, force),
176
181
  new Promise((_, rej) => {
177
182
  timeoutHandle = setTimeout(() => rej(new Error(`body fetch timeout after ${FETCH_TIMEOUT_MS / 1000}s`)), FETCH_TIMEOUT_MS);
178
183
  }),
@@ -1 +1 @@
1
- {"version":3,"file":"reconciler.js","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAyBrD,MAAM,QAAQ,GAAgC;IAC1C,oBAAoB,EAAE,CAAC;IACvB,oBAAoB,EAAE,KAAK;IAC3B,kBAAkB,EAAE,MAAM;IAC1B,sBAAsB,EAAE,KAAK;IAC7B,6BAA6B,EAAE,CAAC;IAChC,wBAAwB,EAAE,QAAQ,EAAI,SAAS;IAC/C,sBAAsB,EAAE,EAAE;CAC7B,CAAC;AAEF,MAAM,OAAO,UAAU;IAcP;IACA;IACA;IAfJ,IAAI,CAA8B;IAClC,OAAO,GAAG,KAAK,CAAC;IAChB,WAAW,GAA0C,IAAI,CAAC;IAC1D,aAAa,GAA0C,IAAI,CAAC;IAC5D,cAAc,GAA0C,IAAI,CAAC;IAC7D,eAAe,GAAG,CAAC,CAAC;IAC5B;;;wEAGoE;IAC5D,UAAU,GAAG,EAAE,cAAc,EAAE,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,CAAC,EAAE,CAAC;IAElF,YACY,EAAW,EACX,WAAwB,EACxB,KAAgB,EACxB,OAA0B,EAAE;QAHpB,OAAE,GAAF,EAAE,CAAS;QACX,gBAAW,GAAX,WAAW,CAAa;QACxB,UAAK,GAAL,KAAK,CAAW;QAGxB,IAAI,CAAC,IAAI,GAAG,EAAE,GAAG,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC;IACzC,CAAC;IAED,KAAK;QACD,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO;QACzB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QAEpB,+DAA+D;QAC/D,+DAA+D;QAC/D,8DAA8D;QAC9D,6DAA6D;QAC7D,4DAA4D;QAC5D,iDAAiD;QACjD,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE;YAChC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC;YACzC,MAAM,IAAI,GAAG,EAAE,GAAG,MAAM,EAAE,eAAe,EAAE,IAAI,CAAC,eAAe,EAAE,CAAC;YAClE,IAAI,IAAI,CAAC,cAAc,KAAK,IAAI,CAAC,UAAU,CAAC,cAAc;mBACnD,IAAI,CAAC,WAAW,KAAK,IAAI,CAAC,UAAU,CAAC,WAAW;mBAChD,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC,UAAU,CAAC,eAAe,EAAE,CAAC;gBAC9D,OAAO;YACX,CAAC;YACD,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;YACvB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,CAAC;QACpD,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;QAEnC,iEAAiE;QACjE,8DAA8D;QAC9D,+DAA+D;QAC/D,6BAA6B;QAC7B,MAAM,YAAY,GAAG,GAAS,EAAE,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;QACxD,UAAU,CAAC,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QAC3D,IAAI,CAAC,aAAa,GAAG,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;QAE7E,gEAAgE;QAChE,iEAAiE;QACjE,kCAAkC;QAClC,MAAM,SAAS,GAAG,GAAS,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC;QACvD,UAAU,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAAG,kCAAkC;QACnE,IAAI,CAAC,cAAc,GAAG,WAAW,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,wBAAwB,CAAC,CAAC;QAEjF,4BAA4B;QAC5B,IAAI,CAAC,eAAe,EAAE,CAAC;IAC3B,CAAC;IAED,IAAI;QACA,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;QACrB,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAAC,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QAAC,CAAC;QACnF,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YAAC,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;QAAC,CAAC;QACzF,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YAAC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAAC,CAAC;IAChG,CAAC;IAED;oDACgD;IACxC,iBAAiB;QACrB,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,sBAAsB,GAAG,SAAS,CAAC;QACzE,MAAM,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;QAC1C,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,eAAe,IAAI,CAAC,IAAI,CAAC,sBAAsB,OAAO,CAAC,CAAC;IAC7G,CAAC;IAED;;;gDAG4C;IACpC,eAAe;QACnB,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,OAAO;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC;QACzC,IAAI,MAAM,CAAC,WAAW,IAAI,IAAI,CAAC,IAAI,CAAC,6BAA6B,EAAE,CAAC;YAChE,kEAAkE;YAClE,OAAO;QACX,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC;YACvC,IAAI,CAAC,WAAW,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAC/C,OAAO,CAAC,KAAK,CAAC,gBAAgB,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC,CAC/D,CAAC;YACF,8DAA8D;YAC9D,6DAA6D;YAC7D,8CAA8C;YAC9C,IAAI,CAAC,WAAW,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CACjD,OAAO,CAAC,KAAK,CAAC,gBAAgB,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC,CAC/D,CAAC;QACN,CAAC;IACL,CAAC;IAED;oEACgE;IAChE,OAAO,CAAC,SAAkB;QACtB,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC;QACtE,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACpB,IAAI,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAS,CAAC,CAAC,CAAC;QACrE,CAAC;IACL,CAAC;IAED;;2DAEuD;IAC/C,eAAe;QACnB,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,OAAO;QAC1B,OAAO,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,IAAI,CAAC,oBAAoB,EAAE,CAAC;YAC3D,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC;YACxC,IAAI,CAAC,IAAI;gBAAE,OAAO;YAClB,IAAI,CAAC,eAAe,EAAE,CAAC;YACvB,CAAC,KAAK,IAAI,EAAE;gBACR,IAAI,CAAC;oBACD,yDAAyD;oBACzD,sDAAsD;oBACtD,0DAA0D;oBAC1D,kDAAkD;oBAClD,qDAAqD;oBACrD,oDAAoD;oBACpD,4DAA4D;oBAC5D,wDAAwD;oBACxD,uDAAuD;oBACvD,wBAAwB;oBACxB,MAAM,gBAAgB,GAAG,MAAM,CAAC;oBAChC,IAAI,aAAwD,CAAC;oBAC7D,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAA6B;wBACvD,IAAI,CAAC,WAAW,CAAC,gBAAgB,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC;wBAC1E,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE;4BACzB,aAAa,GAAG,UAAU,CACtB,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,4BAA4B,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,EAC5E,gBAAgB,CACnB,CAAC;wBACN,CAAC,CAAC;qBACL,CAAC,CAAC;oBACH,IAAI,aAAa;wBAAE,YAAY,CAAC,aAAa,CAAC,CAAC;oBAC/C,IAAI,GAAG,EAAE,CAAC;wBACN,wDAAwD;wBACxD,mDAAmD;wBACnD,8CAA8C;wBAC9C,MAAM,GAAG,GAAQ,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;wBAClF,MAAM,OAAO,GAAuB,GAAG,EAAE,IAAI,CAAC;wBAC9C,QAAQ,CAAC,OAAO,CAAC;4BACb,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,OAAO,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,QAAQ,EAAE;4BACjE,IAAI,EAAE,eAAe;4BACrB,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO;yBAC7E,CAAC,CAAC;oBACP,CAAC;yBAAM,CAAC;wBACJ,mDAAmD;wBACnD,qDAAqD;wBACrD,qDAAqD;wBACrD,qDAAqD;wBACrD,iDAAiD;wBACjD,+CAA+C;wBAC/C,iDAAiD;wBACjD,oDAAoD;wBACpD,uCAAuC;wBACvC,MAAM,GAAG,GAAQ,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;wBAClF,MAAM,OAAO,GAAuB,GAAG,EAAE,IAAI,CAAC;wBAC9C,QAAQ,CAAC,OAAO,CAAC;4BACb,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,OAAO,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,QAAQ,EAAE;4BACjE,IAAI,EAAE,gBAAgB;4BACtB,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO;4BAC1E,KAAK,EAAE,wEAAwE;4BAC/E,SAAS,EAAE,KAAK;yBACnB,CAAC,CAAC;oBACP,CAAC;gBACL,CAAC;gBAAC,OAAO,GAAQ,EAAE,CAAC;oBAChB,IAAI,GAAG,EAAE,UAAU,EAAE,CAAC;wBAClB,IAAI,CAAC;4BACD,MAAM,GAAG,GAAQ,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;4BAClF,MAAM,OAAO,GAAuB,GAAG,EAAE,IAAI,CAAC;4BAC9C,IAAI,CAAC,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,wCAAwC,EAAE,0BAA0B,CAAC,CAAC;4BACrI,IAAI,CAAC,EAAE,CAAC,kBAAkB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;4BAC1C,QAAQ,CAAC,OAAO,CAAC;gCACb,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,OAAO,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,QAAQ,EAAE;gCACjE,IAAI,EAAE,gBAAgB;gCACtB,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO;6BAC7E,CAAC,CAAC;wBACP,CAAC;wBAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC;oBACrB,CAAC;yBAAM,CAAC;wBACJ,MAAM,GAAG,GAAG,GAAG,EAAE,OAAO,IAAI,mBAAmB,CAAC;wBAChD,oDAAoD;wBACpD,iDAAiD;wBACjD,gDAAgD;wBAChD,oDAAoD;wBACpD,MAAM,SAAS,GAAG,4GAA4G,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;wBACzI,oDAAoD;wBACpD,gDAAgD;wBAChD,qDAAqD;wBACrD,mCAAmC;wBACnC,IAAI,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC;4BACjD,kDAAkD;4BAClD,kDAAkD;4BAClD,WAAW;4BACX,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;4BACnD,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,eAAe,EAAE,EAAE,SAAS,CAAC,CAAC;wBACxD,CAAC;6BAAM,CAAC;4BACJ,MAAM,GAAG,GAAQ,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;4BAClF,MAAM,OAAO,GAAuB,GAAG,EAAE,IAAI,CAAC;4BAC9C,QAAQ,CAAC,OAAO,CAAC;gCACb,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,OAAO,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,QAAQ,EAAE;gCACjE,IAAI,EAAE,gBAAgB;gCACtB,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO;gCAC1E,KAAK,EAAE,GAAG,EAAE,SAAS;6BACxB,CAAC,CAAC;wBACP,CAAC;oBACL,CAAC;gBACL,CAAC;wBAAS,CAAC;oBACP,IAAI,CAAC,eAAe,EAAE,CAAC;oBACvB,IAAI,CAAC,eAAe,EAAE,CAAC;gBAC3B,CAAC;YACL,CAAC,CAAC,EAAE,CAAC;QACT,CAAC;IACL,CAAC;IAED,yEAAyE;IACzE,IAAI;QACA,IAAI,CAAC,eAAe,EAAE,CAAC;IAC3B,CAAC;CACJ"}
1
+ {"version":3,"file":"reconciler.js","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"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"}
@@ -199,8 +199,13 @@ export class Reconciler {
199
199
  // the same dead socket.
200
200
  const FETCH_TIMEOUT_MS = 90_000;
201
201
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
202
+ // An interactive click forces the fetch past the per-message
203
+ // prefetch-empty backoff — the user explicitly wants THIS
204
+ // body now, and a background prefetch miss must not suppress
205
+ // it (see fetchMessageBody's `force` note, bobma 4967554).
206
+ const force = next.lane === "interactive";
202
207
  const raw = await Promise.race<Uint8Array | string | null>([
203
- this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid),
208
+ this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid, force),
204
209
  new Promise<null>((_, rej) => {
205
210
  timeoutHandle = setTimeout(
206
211
  () => rej(new Error(`body fetch timeout after ${FETCH_TIMEOUT_MS / 1000}s`)),
@@ -0,0 +1,75 @@
1
+ # Instance model — design plan (2026-06-16)
2
+
3
+ ## Decision (Bob)
4
+ Model **B**: one record **per instance**, not per Message-ID. A copy of a message in
5
+ two folders is **two independent instances** with independent state (read/flag/star,
6
+ body, and — critically — **editable content**). Message-IDs are therefore **NOT globally
7
+ unique**; every code path that assumed "Message-ID → one row" must be made deliberate.
8
+ Bob's emphasis: **database integrity**.
9
+
10
+ ## Identity
11
+ - **Instance = one `messages` row.** Natural key `(account_id, folder_id, uid)` (already
12
+ the schema's UNIQUE). Each row keeps its own stable `uuid`, `flags_json`, `body_path`,
13
+ and content (subject/body/etc. — may diverge once edited).
14
+ - `message_id` becomes a **non-unique indexed attribute**, used for threading and for
15
+ *display* dedup only — never as an identity key.
16
+ - **Drop the move-detect Message-ID collapse.** A new `(folder, uid)` is ALWAYS its own
17
+ instance, even when the same `message_id` exists in another folder.
18
+
19
+ ## message_folders
20
+ Redundant under B (instance already = a `messages` row with folder_id/uid). It becomes
21
+ 1:1 with `messages`. Plan: **retire it** — revert reads to `messages.folder_id`/`uid`,
22
+ removing the dual-representation integrity hazard. Staged so reads never break mid-flight.
23
+
24
+ ## Server truth & move vs copy (reconcile)
25
+ - An instance exists iff the server lists `(folder, uid)`. Per-folder reconcile drops
26
+ instances the server no longer confirms. This cleans accidental stale-move-sources
27
+ while **preserving real copies** (server confirms both) — no exclusivity.
28
+ - **Server-side MOVE** (uid vanished from A, appeared in B, same message_id, A had exactly
29
+ ONE such instance): transfer the old instance's local state (uuid, flags, body_path,
30
+ local edits) to the new instance, then drop the old. Ambiguous case (copies present) →
31
+ treat as a fresh instance + re-fetch. Edits survive unambiguous moves.
32
+
33
+ ## Copy / Edit operations
34
+ - **Copy to folder** (user action or server-side sieve copy): a new instance. User copy
35
+ queues a server `COPY`; sieve copy simply appears as a second server-confirmed instance.
36
+ - **Edit instance**: mutates only that instance's row + its own body file. No other
37
+ instance is touched. (Whether edits push to the server is a separate question.)
38
+
39
+ ## Display dedup
40
+ - Unified "All Inboxes" still collapses copies by `message_id` for display, but each
41
+ instance is independently selectable/openable/editable.
42
+ - An **edited** instance (content diverged from its siblings) is shown **separately** —
43
+ flag `edited=1` excludes it from the dedup group.
44
+
45
+ ## No migration — rebuild
46
+ Per the standing rule (local store is a cache): **wipe local + re-sync** into the new
47
+ model rather than backfill 136k rows. Avoids fragile migration code. One full re-sync.
48
+
49
+ ## Integrity safeguards (Bob's ask)
50
+ - `verifyInstances()` self-check on boot + a `-verify` command: every `messages` row has a
51
+ valid folder; no two rows share `(account, folder, uid)`; `uuid` globally unique; every
52
+ referenced `body_path` exists; `thread_id` resolvable. Log + count violations.
53
+ - All instance writes go through one chokepoint (`upsertInstance`) — no scattered INSERTs.
54
+ - Audit-log every state transfer on move and every reconcile drop (already partly done).
55
+
56
+ ## Staged execution (each stage compiles, ships, is verifiable; no broken intermediates)
57
+ 1. **Plan + integrity scaffold** (this doc) + `verifyInstances()` read-only checker (no
58
+ behavior change) — measure current violations as a baseline.
59
+ 2. **Stop the collapse**: `upsertMessage` creates a new instance per `(folder,uid)`;
60
+ remove cross-folder rebind. Keep same-folder uid-update. Gate behind rebuild.
61
+ 3. **Reconcile = server truth** per folder (drop unconfirmed; preserve copies); move-state
62
+ transfer for the unambiguous single-instance move.
63
+ 4. **Retire `message_folders`**: point reads back at `messages.folder_id`/`uid`; drop the
64
+ join + its boot migration.
65
+ 5. **Copy/Edit**: explicit copy-to-folder (new instance + server COPY) and per-instance
66
+ edit; `edited` flag + display-dedup exclusion.
67
+ 6. **Rebuild + verify** on the live DB; confirm copies preserved, accidents gone, 0
68
+ integrity violations.
69
+
70
+ ## Open sub-decisions (confirm before stage 2)
71
+ - **A.** Retire `message_folders` (recommended — kills the dual representation) vs keep it.
72
+ - **B.** Move-state transfer for unambiguous moves (recommended — preserves edits) vs
73
+ always-fresh-instance on move (simpler, loses local read-state/edits on a server move).
74
+ - **C.** Do instance **edits push to the server** (APPEND new + delete old), or stay
75
+ local-only annotations? (Affects whether an edit changes the server message_id.)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",