@bobfrankston/mailx-store 0.1.57 → 0.1.59

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/db.d.ts +7 -0
  2. package/db.js +70 -3
  3. package/package.json +5 -5
package/db.d.ts CHANGED
@@ -301,6 +301,13 @@ export declare class MailxDB {
301
301
  * message_folders. Omitted/false for Gmail (label = folder, many per
302
302
  * message). Set true by the IMAP storeMessages path. */
303
303
  exclusive?: boolean;
304
+ /** UIDs the server currently lists for THIS folder, when the caller
305
+ * just enumerated them (the set-diff backfill path). Lets move-detect
306
+ * distinguish a real move (old uid gone) from a DUPLICATE DELIVERY
307
+ * (same Message-ID at two live uids in one folder — e.g. a message
308
+ * addressed to two aliases of the same mailbox). Without this the
309
+ * single row ping-pongs between the two uids every sync cycle. */
310
+ liveServerUids?: Set<number>;
304
311
  }): number;
305
312
  /** Backfill the FTS5 `body_text` column for a message after its body
306
313
  * has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
package/db.js CHANGED
@@ -540,6 +540,20 @@ export class MailxDB {
540
540
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_acct_msgid ON messages(account_id, message_id)");
541
541
  }
542
542
  catch { /* already exists */ }
543
+ // GLOBAL date-order indexes for the unified-inbox streaming walk.
544
+ // Every folder-scoped index leads with account_id or folder_id, so a
545
+ // cross-folder `ORDER BY sent_date DESC` had NO usable index and
546
+ // SQLite sorted all ~139k inbox rows into a temp B-tree on EVERY
547
+ // unified page fetch — a fixed ~750ms per call (profiled 2026-07-14;
548
+ // user-visible as "scroll pauses at the bottom then continues", the
549
+ // load-more gap outrunning its 1000px lead). With these, the walk
550
+ // streams straight off the index: ~1ms for a 100-row page. One per
551
+ // date basis (sent_date default, date for the Received toggle).
552
+ try {
553
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_sentdate_global ON messages(sent_date DESC)");
554
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_date_global ON messages(date DESC)");
555
+ }
556
+ catch { /* already exists */ }
543
557
  // is_replied: set when ANY other message in this account has in_reply_to
544
558
  // pointing at this row's message_id. Primary source of truth for the ↩
545
559
  // marker — \Answered is plan B (some servers strip it, Gmail labels
@@ -1828,7 +1842,48 @@ export class MailxDB {
1828
1842
  // surface transient move-dups. Keeping the collapse for now.
1829
1843
  if (msg.messageId) {
1830
1844
  const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
1831
- if (moved) {
1845
+ // Duplicate delivery, NOT a move: same folder, different uid, and
1846
+ // the caller's server UID list confirms the OLD uid is still in the
1847
+ // mailbox. Rebinding here would ping-pong the row between the two
1848
+ // uids on every sync (each cycle sees the unbound uid as
1849
+ // "server-only", refetches it, and rebinds). Fall through to a
1850
+ // fresh insert instead — both copies get their own row, matching
1851
+ // what the server (and every other client) actually shows, and the
1852
+ // set-diff converges.
1853
+ const sameFolderDup = !!moved && moved.folder_id === msg.folderId && moved.uid !== msg.uid
1854
+ && !!msg.liveServerUids?.has(moved.uid);
1855
+ // Cross-folder copy, NOT a move (2026-07-16). Self-addressed mail
1856
+ // legitimately carries ONE Message-ID in both INBOX and Sent — a
1857
+ // Sent backfill "moving" the INBOX row to Sent stole thousands of
1858
+ // INBOX rows, and the next INBOX sync saw 877 "server-only" UIDs,
1859
+ // refetched them, rebound them back … an infinite cross-folder
1860
+ // flap that hammered Dovecot, flip-flopped read/unread flags, and
1861
+ // starved the interactive body-fetch lane ("Fetching body from
1862
+ // server…" for minutes). Evidence available at this call site:
1863
+ // the source folder's membership row — its last_seen_at refreshes
1864
+ // every time that folder's sync confirms the UID is still on the
1865
+ // server. A recently-confirmed source instance means the message
1866
+ // exists in BOTH folders → insert a second row (matches server
1867
+ // truth). Reconcile converges either way: a real move's source
1868
+ // membership is dropped by the source folder's next sync and the
1869
+ // orphaned row is deleted. The window covers hot folders (INBOX
1870
+ // confirms every ~30 s); a stale or missing membership falls
1871
+ // through to the rebind, preserving UUID continuity for genuine
1872
+ // server-side moves.
1873
+ let crossFolderCopy = false;
1874
+ if (moved && !sameFolderDup && moved.folder_id !== msg.folderId) {
1875
+ const SOURCE_CONFIRM_WINDOW_MS = 10 * 60_000;
1876
+ const src = this.db.prepare("SELECT message_row_id, last_seen_at FROM message_folders WHERE folder_id = ? AND uid = ?").get(moved.folder_id, moved.uid);
1877
+ crossFolderCopy = !!src && src.message_row_id === moved.id
1878
+ && (Date.now() - src.last_seen_at) < SOURCE_CONFIRM_WINDOW_MS;
1879
+ }
1880
+ if (sameFolderDup && moved) {
1881
+ console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: uid ${moved.uid} still on server — duplicate copy at uid ${msg.uid}, inserting second row`);
1882
+ }
1883
+ else if (crossFolderCopy && moved) {
1884
+ console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: source folder ${moved.folder_id}/uid ${moved.uid} recently confirmed on server — cross-folder copy at folder ${msg.folderId}/uid ${msg.uid}, inserting second row`);
1885
+ }
1886
+ else if (moved) {
1832
1887
  console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
1833
1888
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
1834
1889
  this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
@@ -2061,7 +2116,11 @@ export class MailxDB {
2061
2116
  const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
2062
2117
  const cachedTotal = this._unifiedTotalCache.get(totalKey);
2063
2118
  let total;
2064
- if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
2119
+ // 15s TTL (was 4s): the COUNT is a ~450ms GROUP BY scan of every inbox
2120
+ // row, and background refreshes land often enough that a 4s TTL
2121
+ // recomputed it nearly every fetch. Staleness only affects the
2122
+ // page-count indicator / load-more end detection, never row data.
2123
+ if (cachedTotal && (Date.now() - cachedTotal.at) < 15_000) {
2065
2124
  total = cachedTotal.total;
2066
2125
  }
2067
2126
  else {
@@ -2085,9 +2144,17 @@ export class MailxDB {
2085
2144
  // idx_messages_folder_date supplies the date order. Deep pages walk more
2086
2145
  // (bounded by offset+pageSize survivors) but remain far cheaper than a
2087
2146
  // full window sort, and are rare in the unified inbox.
2147
+ // CROSS JOIN forces messages as the OUTER table so the plan drives off
2148
+ // idx_messages_sentdate_global / idx_messages_date_global (global
2149
+ // date-DESC order, no temp B-tree — the `, m.id` tiebreak only sorts
2150
+ // within equal-date runs) and probes message_folders per row. The
2151
+ // planner can't see our early `break`, so with a plain JOIN it
2152
+ // "optimizes" for full output: drives from mf's folder index and
2153
+ // sorts ALL ~139k inbox rows before yielding row 1 (~750ms/page,
2154
+ // profiled 2026-07-14). Forced order: ~1ms.
2088
2155
  const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2089
2156
  FROM messages m
2090
- JOIN message_folders mf ON mf.message_row_id = m.id
2157
+ CROSS JOIN message_folders mf ON mf.message_row_id = m.id
2091
2158
  WHERE mf.folder_id IN (${placeholders})${flagFilter}
2092
2159
  ORDER BY ${dateCol} DESC, m.id DESC`);
2093
2160
  // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.22",
13
- "@bobfrankston/mailx-settings": "^0.1.33",
12
+ "@bobfrankston/mailx-types": "^0.1.24",
13
+ "@bobfrankston/mailx-settings": "^0.1.35",
14
14
  "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
16
16
  },
@@ -29,8 +29,8 @@
29
29
  },
30
30
  ".transformedSnapshot": {
31
31
  "dependencies": {
32
- "@bobfrankston/mailx-types": "^0.1.22",
33
- "@bobfrankston/mailx-settings": "^0.1.33",
32
+ "@bobfrankston/mailx-types": "^0.1.24",
33
+ "@bobfrankston/mailx-settings": "^0.1.35",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }