@bobfrankston/mailx-store 0.1.56 → 0.1.58

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.
package/db.d.ts CHANGED
@@ -284,6 +284,9 @@ export declare class MailxDB {
284
284
  inReplyTo: string;
285
285
  references: string[];
286
286
  date: number;
287
+ /** Date: header (send time). Defaults to `date` when the caller has no
288
+ * header date, so the column is never null. */
289
+ sentDate?: number;
287
290
  subject: string;
288
291
  from: EmailAddress;
289
292
  to: EmailAddress[];
@@ -298,6 +301,13 @@ export declare class MailxDB {
298
301
  * message_folders. Omitted/false for Gmail (label = folder, many per
299
302
  * message). Set true by the IMAP storeMessages path. */
300
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>;
301
311
  }): number;
302
312
  /** Backfill the FTS5 `body_text` column for a message after its body
303
313
  * has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
@@ -322,7 +332,7 @@ export declare class MailxDB {
322
332
  /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
323
333
  * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
324
334
  private _unifiedTotalCache;
325
- getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
335
+ getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
326
336
  /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
327
337
  * identity) and `bodyPath` (authoritative on-disk location) in addition
328
338
  * to the server-binding metadata. */
@@ -496,6 +506,12 @@ export declare class MailxDB {
496
506
  preview: string;
497
507
  body_path: string;
498
508
  }[];
509
+ /** Count local rows in a folder sharing one Message-ID. The Sent-sweep
510
+ * uses this as a sanity cross-check before re-APPENDing a "missing"
511
+ * message: >1 local copies mean earlier duplicates synced IN from the
512
+ * server, so the server-side header search that just said "missing"
513
+ * was lying (the 3,704-duplicate loop, 2026-07-02). */
514
+ countMessagesByMessageId(accountId: string, folderId: number, messageId: string): number;
499
515
  /** Rebind a local row to a different server UID without re-upserting.
500
516
  * Used by the Sent-sweep to repair optimistic-insert mispredictions
501
517
  * (local row was inserted at the predicted UIDNEXT but the server
package/db.js CHANGED
@@ -556,6 +556,44 @@ export class MailxDB {
556
556
  // parsed (existing IMAP-synced rows that predate the prefetch-reparse
557
557
  // fix, where has_attachments stays 0 forever). NULL = never parsed.
558
558
  this.addColumnIfMissing("messages", "body_parsed_at", "INTEGER");
559
+ // sent_date: the message's own Date: header (when it was SENT), distinct
560
+ // from `date` which is the per-account arrival time (IMAP INTERNALDATE /
561
+ // Gmail internalDate). Kept as its own column so the list can offer a
562
+ // Sent/Received date basis (Bob 2026-07-01) AND so identical messages
563
+ // delivered to multiple accounts cluster on send time. Backfilled to
564
+ // `date` for existing rows so it is ALWAYS non-null → a plain index sorts
565
+ // it as fast as the received-date path (no COALESCE, no hot-path
566
+ // regression of the getUnifiedInbox streaming walk). Real send times fill
567
+ // in as folders re-sync (or via `-repair`).
568
+ this.addColumnIfMissing("messages", "sent_date", "INTEGER");
569
+ // One-shot heal: strip header-fold whitespace from stored Message-IDs /
570
+ // In-Reply-To. A folded "Message-ID:\r\n\t<...>" header reached the DB
571
+ // as "\t<...>" (pre-trim iflow-direct envelope parser); the tab made
572
+ // every HEADER search for that id return 0 → the sent-sweep re-APPENDed
573
+ // "missing" messages forever (3,707 duplicates of one reply,
574
+ // 2026-07-02). Idempotent — after the first pass the WHERE matches
575
+ // nothing. char(9/10/13)+space covers all RFC 5322 fold whitespace.
576
+ try {
577
+ const ws = "char(9)||char(10)||char(13)||' '";
578
+ this.db.exec(`UPDATE messages SET message_id = TRIM(message_id, ${ws}) WHERE message_id <> TRIM(message_id, ${ws})`);
579
+ this.db.exec(`UPDATE messages SET in_reply_to = TRIM(in_reply_to, ${ws}) WHERE in_reply_to IS NOT NULL AND in_reply_to <> TRIM(in_reply_to, ${ws})`);
580
+ }
581
+ catch { /* non-fatal */ }
582
+ try {
583
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_folder_sentdate ON messages(account_id, folder_id, sent_date DESC)");
584
+ }
585
+ catch { /* already exists */ }
586
+ try {
587
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_sentdate ON messages(folder_id, sent_date DESC)");
588
+ }
589
+ catch { /* already exists */ }
590
+ try {
591
+ // One-shot backfill: seed sent_date from date wherever it's still
592
+ // null. Indexed IS NULL check keeps this near-instant after the first
593
+ // pass; new writes set sent_date directly so it converges to a no-op.
594
+ this.db.exec("UPDATE messages SET sent_date = date WHERE sent_date IS NULL");
595
+ }
596
+ catch { /* non-fatal */ }
559
597
  // One-shot is_replied backfill on every boot. SQL-only, indexed
560
598
  // both sides via idx_messages_message_id and idx_messages_in_reply_to,
561
599
  // so a 100k-row mailbox finishes in milliseconds. Idempotent — after
@@ -1757,7 +1795,7 @@ export class MailxDB {
1757
1795
  // subject HERE, but only when the existing mid is empty AND the
1758
1796
  // incoming msg actually has one — never clobber a good value.
1759
1797
  if (msg.messageId && (!existing.message_id || existing.message_id === "")) {
1760
- this.db.prepare("UPDATE messages SET message_id = ?, date = ?, subject = ? WHERE id = ?").run(msg.messageId, msg.date || Date.now(), msg.subject || "", existing.id);
1798
+ this.db.prepare("UPDATE messages SET message_id = ?, date = ?, sent_date = ?, subject = ? WHERE id = ?").run(msg.messageId, msg.date || Date.now(), msg.sentDate ?? msg.date ?? Date.now(), msg.subject || "", existing.id);
1761
1799
  // The row was indexed with an empty subject (it had no envelope),
1762
1800
  // so search missed it. Re-index now that we have the real subject
1763
1801
  // + from/to. Standalone FTS5 → delete + insert. body_text
@@ -1790,7 +1828,19 @@ export class MailxDB {
1790
1828
  // surface transient move-dups. Keeping the collapse for now.
1791
1829
  if (msg.messageId) {
1792
1830
  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);
1793
- if (moved) {
1831
+ // Duplicate delivery, NOT a move: same folder, different uid, and
1832
+ // the caller's server UID list confirms the OLD uid is still in the
1833
+ // mailbox. Rebinding here would ping-pong the row between the two
1834
+ // uids on every sync (each cycle sees the unbound uid as
1835
+ // "server-only", refetches it, and rebinds). Fall through to a
1836
+ // fresh insert instead — both copies get their own row, matching
1837
+ // what the server (and every other client) actually shows, and the
1838
+ // set-diff converges.
1839
+ if (moved && moved.folder_id === msg.folderId && moved.uid !== msg.uid
1840
+ && msg.liveServerUids?.has(moved.uid)) {
1841
+ console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: uid ${moved.uid} still on server — duplicate copy at uid ${msg.uid}, inserting second row`);
1842
+ }
1843
+ else if (moved) {
1794
1844
  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})`);
1795
1845
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
1796
1846
  this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
@@ -1832,10 +1882,10 @@ export class MailxDB {
1832
1882
  const result = this.db.prepare(`
1833
1883
  INSERT INTO messages (
1834
1884
  account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
1835
- date, subject, from_address, from_name, to_json, cc_json,
1885
+ date, sent_date, subject, from_address, from_name, to_json, cc_json,
1836
1886
  flags_json, size, has_attachments, preview, body_path, body_parsed_at, cached_at, provider_id, is_replied
1837
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1838
- `).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, msg.bodyPath ? Date.now() : null, Date.now(), msg.providerId || null, alreadyReplied ? 1 : 0);
1887
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1888
+ `).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.sentDate ?? msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, msg.bodyPath ? Date.now() : null, Date.now(), msg.providerId || null, alreadyReplied ? 1 : 0);
1839
1889
  const rowId = Number(result.lastInsertRowid);
1840
1890
  // Forward direction for ↩: if this message is a reply, mark the parent
1841
1891
  // as replied-to. Cheap indexed UPDATE; no-op when the parent isn't in
@@ -1893,7 +1943,10 @@ export class MailxDB {
1893
1943
  const offset = (page - 1) * pageSize;
1894
1944
  const sort = query.sort || "date";
1895
1945
  const sortDir = query.sortDir || "desc";
1896
- const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
1946
+ // Date basis: "sent" (Date: header, default clusters identical mail
1947
+ // and matches other clients) or "received" (per-account arrival time).
1948
+ const dateCol = query.dateBasis === "received" ? "m.date" : "m.sent_date";
1949
+ const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : dateCol;
1897
1950
  let where = "m.account_id = ? AND mf.folder_id = ?";
1898
1951
  const params = [query.accountId, query.folderId];
1899
1952
  if (query.search) {
@@ -1958,6 +2011,7 @@ export class MailxDB {
1958
2011
  references: JSON.parse(r.refs || "[]"),
1959
2012
  threadId: r.thread_id || undefined,
1960
2013
  date: r.date,
2014
+ sentDate: r.sent_date ?? r.date,
1961
2015
  subject: r.subject,
1962
2016
  from: { name: r.from_name, address: r.from_address },
1963
2017
  to: JSON.parse(r.to_json),
@@ -1976,8 +2030,13 @@ export class MailxDB {
1976
2030
  /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
1977
2031
  * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
1978
2032
  _unifiedTotalCache = new Map();
1979
- getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
2033
+ getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis = "sent") {
1980
2034
  const offset = (page - 1) * pageSize;
2035
+ // Date basis: sort/stream by the Date: header (sent, default) or the
2036
+ // per-account arrival time (received). sent_date is always non-null
2037
+ // (backfilled to date), and idx_messages_sentdate mirrors
2038
+ // idx_messages_folder_date, so the streaming walk stays O(page).
2039
+ const dateCol = dateBasis === "received" ? "m.date" : "m.sent_date";
1981
2040
  // Find all inbox folder IDs
1982
2041
  const inboxRows = this.db.prepare("SELECT id FROM folders WHERE special_use = 'inbox'").all();
1983
2042
  if (inboxRows.length === 0)
@@ -2042,7 +2101,7 @@ export class MailxDB {
2042
2101
  FROM messages m
2043
2102
  JOIN message_folders mf ON mf.message_row_id = m.id
2044
2103
  WHERE mf.folder_id IN (${placeholders})${flagFilter}
2045
- ORDER BY m.date DESC, m.id DESC`);
2104
+ ORDER BY ${dateCol} DESC, m.id DESC`);
2046
2105
  // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
2047
2106
  // whole inbox). Same semantics as the old inline correlated subqueries.
2048
2107
  const pendingStmt = this.db.prepare("SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
@@ -2083,6 +2142,7 @@ export class MailxDB {
2083
2142
  references: JSON.parse(r.refs || "[]"),
2084
2143
  threadId: r.thread_id || undefined,
2085
2144
  date: r.date,
2145
+ sentDate: r.sent_date ?? r.date,
2086
2146
  subject: r.subject,
2087
2147
  from: { name: r.from_name, address: r.from_address },
2088
2148
  to: JSON.parse(r.to_json),
@@ -2197,7 +2257,26 @@ export class MailxDB {
2197
2257
  * for code paths that mean it; everyone else must pass folderId. */
2198
2258
  updateMessageFlags(accountId, folderId, uid, flags) {
2199
2259
  if (folderId != null) {
2200
- this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, folderId, uid);
2260
+ // Resolve the row via its folder MEMBERSHIP, not messages.folder_id.
2261
+ // The message-id collapse (upsertMessage move-detect) keeps ONE
2262
+ // `messages` row per message_id whose `folder_id` is an arbitrary
2263
+ // "primary" folder, while the message appears in OTHER folders via
2264
+ // `message_folders`. A `WHERE folder_id = ?` update therefore MISSED
2265
+ // any message viewed in a folder other than its stored primary —
2266
+ // 0 rows changed, so star/mark-read silently did nothing locally and
2267
+ // the change "came right back" on the next render (Bob 2026-06-27).
2268
+ // Match the membership (the same join getMessageByUid uses).
2269
+ const r = this.db.prepare(`
2270
+ UPDATE messages SET flags_json = ? WHERE id = (
2271
+ SELECT m.id FROM messages m
2272
+ JOIN message_folders mf ON mf.message_row_id = m.id
2273
+ WHERE m.account_id = ? AND mf.folder_id = ? AND mf.uid = ? LIMIT 1
2274
+ )
2275
+ `).run(JSON.stringify(flags), accountId, folderId, uid);
2276
+ // Fallback for any legacy row lacking a membership entry.
2277
+ if (r.changes === 0) {
2278
+ this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, folderId, uid);
2279
+ }
2201
2280
  }
2202
2281
  else {
2203
2282
  this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, uid);
@@ -2497,6 +2576,19 @@ export class MailxDB {
2497
2576
  WHERE account_id = ? AND folder_id = ? AND cached_at >= ?
2498
2577
  AND message_id IS NOT NULL AND message_id <> ''`).all(accountId, folderId, sinceMs);
2499
2578
  }
2579
+ /** Count local rows in a folder sharing one Message-ID. The Sent-sweep
2580
+ * uses this as a sanity cross-check before re-APPENDing a "missing"
2581
+ * message: >1 local copies mean earlier duplicates synced IN from the
2582
+ * server, so the server-side header search that just said "missing"
2583
+ * was lying (the 3,704-duplicate loop, 2026-07-02). */
2584
+ countMessagesByMessageId(accountId, folderId, messageId) {
2585
+ if (!messageId)
2586
+ return 0;
2587
+ const r = this.db.prepare(`SELECT COUNT(*) AS c FROM messages m
2588
+ JOIN message_folders mf ON mf.message_row_id = m.id
2589
+ WHERE m.account_id = ? AND mf.folder_id = ? AND m.message_id = ?`).get(accountId, folderId, messageId);
2590
+ return r?.c | 0;
2591
+ }
2500
2592
  /** Rebind a local row to a different server UID without re-upserting.
2501
2593
  * Used by the Sent-sweep to repair optimistic-insert mispredictions
2502
2594
  * (local row was inserted at the predicted UIDNEXT but the server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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.20",
13
- "@bobfrankston/mailx-settings": "^0.1.31",
12
+ "@bobfrankston/mailx-types": "^0.1.22",
13
+ "@bobfrankston/mailx-settings": "^0.1.33",
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.20",
33
- "@bobfrankston/mailx-settings": "^0.1.31",
32
+ "@bobfrankston/mailx-types": "^0.1.22",
33
+ "@bobfrankston/mailx-settings": "^0.1.33",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }
package/store.d.ts CHANGED
@@ -130,7 +130,7 @@ export declare class Store {
130
130
  /** Paginated message list for a (account, folder, ...) query. */
131
131
  getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
132
132
  /** All-Inboxes view: union of every account's INBOX, paginated. */
133
- getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
133
+ getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
134
134
  /** Local FTS5 search. Server-scope search is the reconciler's job. */
135
135
  searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
136
136
  /** Read a fully-parsed message (envelope + body + attachments) entirely
package/store.js CHANGED
@@ -182,8 +182,8 @@ export class Store {
182
182
  return this.db.getMessages(query);
183
183
  }
184
184
  /** All-Inboxes view: union of every account's INBOX, paginated. */
185
- getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
186
- return this.db.getUnifiedInbox(page, pageSize, flaggedOnly);
185
+ getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis = "sent") {
186
+ return this.db.getUnifiedInbox(page, pageSize, flaggedOnly, dateBasis);
187
187
  }
188
188
  /** Local FTS5 search. Server-scope search is the reconciler's job. */
189
189
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {