@bobfrankston/mailx-store 0.1.55 → 0.1.57

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 (5) hide show
  1. package/db.d.ts +46 -2
  2. package/db.js +188 -14
  3. package/package.json +5 -5
  4. package/store.d.ts +1 -1
  5. package/store.js +2 -2
package/db.d.ts CHANGED
@@ -4,6 +4,18 @@
4
4
  * Message bodies are NOT here -- they live in the MessageStore backend.
5
5
  */
6
6
  import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
7
+ /** Sanitize a harvested display name. A real display name carries none of
8
+ * `@ < > |` — their presence means an address parser mis-split a header
9
+ * (e.g. a List-Id / "domain | Name@addr" fragment) and the "name" now
10
+ * embeds structural mail punctuation or a whole email address. The worst
11
+ * case is an embedded address that DIFFERS from this contact's own email:
12
+ * autocomplete then shows a TRUSTED name while sending ELSEWHERE. Live
13
+ * example (Bob 2026-06-22): a `discovered` row name `bob.ma | Bob@bob.ma`
14
+ * was wired to `miyakot@kt.rim.or.jp`, so test mail Bob addressed to
15
+ * himself silently went to a stranger in Japan ("MIA"). Drop the name;
16
+ * keep the contact (the email is still useful) — autocomplete falls back
17
+ * to showing the bare address, which the user won't mistake for someone. */
18
+ export declare function cleanContactName(name: string): string;
7
19
  /** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
8
20
  * once on settings load; invalid regexes are skipped with a warning. */
9
21
  export declare function setContactsDenyPatterns(patterns: string[]): void;
@@ -187,6 +199,26 @@ export declare class MailxDB {
187
199
  }[];
188
200
  updateLastSync(accountId: string, timestamp: number): void;
189
201
  upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
202
+ /** Rename a folder row's path (+leaf name) and rewrite every descendant
203
+ * folder whose path lives under the old prefix. A single IMAP RENAME on the
204
+ * server renames child mailboxes too (RFC 3501), and Gmail/Outlook rename
205
+ * cascades the same way, so the local mirror must move the whole subtree in
206
+ * lockstep. All in one transaction so a crash can't leave half the subtree
207
+ * pointing at the old prefix.
208
+ *
209
+ * `newName` is the new LEAF name written to the renamed row's `name`
210
+ * column; descendant rows keep their own leaf names (only the prefix
211
+ * changes). The (account_id, path) UNIQUE constraint means the caller must
212
+ * have already verified newPath is free. */
213
+ renameFolderPath(accountId: string, oldPath: string, newPath: string, newName: string, delimiter: string): void;
214
+ /** Update only a folder's display `name`, leaving `path` untouched. For
215
+ * providers whose `path` is an opaque, rename-stable id (Outlook/Graph:
216
+ * path = folder id), a rename/reparent changes the display name but NOT
217
+ * the id — so synthesizing a new hierarchical path (as renameFolderPath
218
+ * does) would corrupt the id-as-path invariant every later Graph op relies
219
+ * on. This updates the name in place. No descendant rewrite: opaque-id
220
+ * children don't carry the parent in their path. */
221
+ renameFolderName(accountId: string, folderId: number, newName: string): void;
190
222
  getFolders(accountId: string): Folder[];
191
223
  /** Append a row to the audit_log table. Every destructive operation on
192
224
  * the messages table writes here so "where did the rows go?" has an
@@ -252,6 +284,9 @@ export declare class MailxDB {
252
284
  inReplyTo: string;
253
285
  references: string[];
254
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;
255
290
  subject: string;
256
291
  from: EmailAddress;
257
292
  to: EmailAddress[];
@@ -290,7 +325,7 @@ export declare class MailxDB {
290
325
  /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
291
326
  * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
292
327
  private _unifiedTotalCache;
293
- getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
328
+ getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
294
329
  /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
295
330
  * identity) and `bodyPath` (authoritative on-disk location) in addition
296
331
  * to the server-binding metadata. */
@@ -464,6 +499,12 @@ export declare class MailxDB {
464
499
  preview: string;
465
500
  body_path: string;
466
501
  }[];
502
+ /** Count local rows in a folder sharing one Message-ID. The Sent-sweep
503
+ * uses this as a sanity cross-check before re-APPENDing a "missing"
504
+ * message: >1 local copies mean earlier duplicates synced IN from the
505
+ * server, so the server-side header search that just said "missing"
506
+ * was lying (the 3,704-duplicate loop, 2026-07-02). */
507
+ countMessagesByMessageId(accountId: string, folderId: number, messageId: string): number;
467
508
  /** Rebind a local row to a different server UID without re-upserting.
468
509
  * Used by the Sent-sweep to repair optimistic-insert mispredictions
469
510
  * (local row was inserted at the predicted UIDNEXT but the server
@@ -673,7 +714,10 @@ export declare class MailxDB {
673
714
  deleteContactByGoogleId(googleId: string): number;
674
715
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
675
716
  searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
676
- /** Rebuild FTS index from existing messages */
717
+ /** Rebuild FTS index from existing messages.
718
+ * body_text is seeded from `preview` here (all db.ts can reach); the full
719
+ * message body is backfilled separately by mailx-imap's backfillFtsBodies()
720
+ * pass, which can read the cached .eml. */
677
721
  rebuildSearchIndex(): number;
678
722
  /** Queue a local action for later sync to IMAP */
679
723
  queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
package/db.js CHANGED
@@ -76,6 +76,25 @@ function isJunkContact(email, name) {
76
76
  }
77
77
  return false;
78
78
  }
79
+ /** Sanitize a harvested display name. A real display name carries none of
80
+ * `@ < > |` — their presence means an address parser mis-split a header
81
+ * (e.g. a List-Id / "domain | Name@addr" fragment) and the "name" now
82
+ * embeds structural mail punctuation or a whole email address. The worst
83
+ * case is an embedded address that DIFFERS from this contact's own email:
84
+ * autocomplete then shows a TRUSTED name while sending ELSEWHERE. Live
85
+ * example (Bob 2026-06-22): a `discovered` row name `bob.ma | Bob@bob.ma`
86
+ * was wired to `miyakot@kt.rim.or.jp`, so test mail Bob addressed to
87
+ * himself silently went to a stranger in Japan ("MIA"). Drop the name;
88
+ * keep the contact (the email is still useful) — autocomplete falls back
89
+ * to showing the bare address, which the user won't mistake for someone. */
90
+ export function cleanContactName(name) {
91
+ const n = (name || "").trim().replace(/^"(.*)"$/, "$1").trim();
92
+ if (!n)
93
+ return "";
94
+ if (/[@<>|]/.test(n))
95
+ return "";
96
+ return n;
97
+ }
79
98
  /** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
80
99
  * once on settings load; invalid regexes are skipped with a warning. */
81
100
  export function setContactsDenyPatterns(patterns) {
@@ -537,6 +556,44 @@ export class MailxDB {
537
556
  // parsed (existing IMAP-synced rows that predate the prefetch-reparse
538
557
  // fix, where has_attachments stays 0 forever). NULL = never parsed.
539
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 */ }
540
597
  // One-shot is_replied backfill on every boot. SQL-only, indexed
541
598
  // both sides via idx_messages_message_id and idx_messages_in_reply_to,
542
599
  // so a 100k-row mailbox finishes in milliseconds. Idempotent — after
@@ -815,18 +872,29 @@ export class MailxDB {
815
872
  if (last === TARGET)
816
873
  return;
817
874
  let dropped = 0;
875
+ let renamed = 0;
818
876
  try {
819
877
  const rows = this.db.prepare("SELECT id, email, name FROM contacts WHERE source IN ('discovered','sent','received')").all();
820
878
  const del = this.db.prepare("DELETE FROM contacts WHERE id = ?");
879
+ const blank = this.db.prepare("UPDATE contacts SET name = '' WHERE id = ?");
821
880
  for (const r of rows) {
822
881
  if (isJunkContact(r.email || "", r.name || "")) {
823
882
  del.run(r.id);
824
883
  dropped++;
884
+ continue;
885
+ }
886
+ // Blank mis-parsed display names that embed `@ < > |` — these
887
+ // can impersonate one identity while sending to another (the
888
+ // "bob.ma | Bob@bob.ma" → miyakot@kt.rim.or.jp cross-wire,
889
+ // Bob 2026-06-22). Keep the contact; drop only the unsafe name.
890
+ if (r.name && cleanContactName(r.name) !== r.name) {
891
+ blank.run(r.id);
892
+ renamed++;
825
893
  }
826
894
  }
827
895
  this.setKv("contacts", "purge_v", TARGET);
828
- if (dropped > 0)
829
- console.log(` [contacts] one-shot purge: dropped ${dropped} junk rows (${TARGET})`);
896
+ if (dropped > 0 || renamed > 0)
897
+ console.log(` [contacts] one-shot purge: dropped ${dropped} junk rows, blanked ${renamed} unsafe names (${TARGET})`);
830
898
  }
831
899
  catch (e) {
832
900
  console.warn(`[contacts] one-shot purge failed: ${e.message}`);
@@ -1340,6 +1408,48 @@ export class MailxDB {
1340
1408
  const result = this.db.prepare("INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)").run(accountId, folderPath, name, specialUse, delimiter);
1341
1409
  return Number(result.lastInsertRowid);
1342
1410
  }
1411
+ /** Rename a folder row's path (+leaf name) and rewrite every descendant
1412
+ * folder whose path lives under the old prefix. A single IMAP RENAME on the
1413
+ * server renames child mailboxes too (RFC 3501), and Gmail/Outlook rename
1414
+ * cascades the same way, so the local mirror must move the whole subtree in
1415
+ * lockstep. All in one transaction so a crash can't leave half the subtree
1416
+ * pointing at the old prefix.
1417
+ *
1418
+ * `newName` is the new LEAF name written to the renamed row's `name`
1419
+ * column; descendant rows keep their own leaf names (only the prefix
1420
+ * changes). The (account_id, path) UNIQUE constraint means the caller must
1421
+ * have already verified newPath is free. */
1422
+ renameFolderPath(accountId, oldPath, newPath, newName, delimiter) {
1423
+ const childPrefix = oldPath + delimiter;
1424
+ this.db.exec("BEGIN");
1425
+ try {
1426
+ // The folder itself — path + leaf name both change.
1427
+ this.db.prepare("UPDATE folders SET path = ?, name = ? WHERE account_id = ? AND path = ?").run(newPath, newName, accountId, oldPath);
1428
+ // Descendants — rewrite only the path prefix, keep their leaf names.
1429
+ // SUBSTR(path, len(childPrefix)+1) is the part after the old prefix.
1430
+ const descendants = this.db.prepare("SELECT id, path FROM folders WHERE account_id = ? AND path LIKE ? ESCAPE '\\'").all(accountId, childPrefix.replace(/[\\%_]/g, "\\$&") + "%");
1431
+ const upd = this.db.prepare("UPDATE folders SET path = ? WHERE id = ?");
1432
+ for (const d of descendants) {
1433
+ const rest = d.path.slice(oldPath.length); // includes the leading delimiter
1434
+ upd.run(newPath + rest, d.id);
1435
+ }
1436
+ this.db.exec("COMMIT");
1437
+ }
1438
+ catch (e) {
1439
+ this.db.exec("ROLLBACK");
1440
+ throw e;
1441
+ }
1442
+ }
1443
+ /** Update only a folder's display `name`, leaving `path` untouched. For
1444
+ * providers whose `path` is an opaque, rename-stable id (Outlook/Graph:
1445
+ * path = folder id), a rename/reparent changes the display name but NOT
1446
+ * the id — so synthesizing a new hierarchical path (as renameFolderPath
1447
+ * does) would corrupt the id-as-path invariant every later Graph op relies
1448
+ * on. This updates the name in place. No descendant rewrite: opaque-id
1449
+ * children don't carry the parent in their path. */
1450
+ renameFolderName(accountId, folderId, newName) {
1451
+ this.db.prepare("UPDATE folders SET name = ? WHERE account_id = ? AND id = ?").run(newName, accountId, folderId);
1452
+ }
1343
1453
  getFolders(accountId) {
1344
1454
  const rows = this.db.prepare("SELECT * FROM folders WHERE account_id = ? ORDER BY path").all(accountId);
1345
1455
  const folders = rows.map(r => ({
@@ -1685,7 +1795,7 @@ export class MailxDB {
1685
1795
  // subject HERE, but only when the existing mid is empty AND the
1686
1796
  // incoming msg actually has one — never clobber a good value.
1687
1797
  if (msg.messageId && (!existing.message_id || existing.message_id === "")) {
1688
- 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);
1689
1799
  // The row was indexed with an empty subject (it had no envelope),
1690
1800
  // so search missed it. Re-index now that we have the real subject
1691
1801
  // + from/to. Standalone FTS5 → delete + insert. body_text
@@ -1760,10 +1870,10 @@ export class MailxDB {
1760
1870
  const result = this.db.prepare(`
1761
1871
  INSERT INTO messages (
1762
1872
  account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
1763
- date, subject, from_address, from_name, to_json, cc_json,
1873
+ date, sent_date, subject, from_address, from_name, to_json, cc_json,
1764
1874
  flags_json, size, has_attachments, preview, body_path, body_parsed_at, cached_at, provider_id, is_replied
1765
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1766
- `).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);
1875
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1876
+ `).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);
1767
1877
  const rowId = Number(result.lastInsertRowid);
1768
1878
  // Forward direction for ↩: if this message is a reply, mark the parent
1769
1879
  // as replied-to. Cheap indexed UPDATE; no-op when the parent isn't in
@@ -1821,7 +1931,10 @@ export class MailxDB {
1821
1931
  const offset = (page - 1) * pageSize;
1822
1932
  const sort = query.sort || "date";
1823
1933
  const sortDir = query.sortDir || "desc";
1824
- const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
1934
+ // Date basis: "sent" (Date: header, default clusters identical mail
1935
+ // and matches other clients) or "received" (per-account arrival time).
1936
+ const dateCol = query.dateBasis === "received" ? "m.date" : "m.sent_date";
1937
+ const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : dateCol;
1825
1938
  let where = "m.account_id = ? AND mf.folder_id = ?";
1826
1939
  const params = [query.accountId, query.folderId];
1827
1940
  if (query.search) {
@@ -1886,6 +1999,7 @@ export class MailxDB {
1886
1999
  references: JSON.parse(r.refs || "[]"),
1887
2000
  threadId: r.thread_id || undefined,
1888
2001
  date: r.date,
2002
+ sentDate: r.sent_date ?? r.date,
1889
2003
  subject: r.subject,
1890
2004
  from: { name: r.from_name, address: r.from_address },
1891
2005
  to: JSON.parse(r.to_json),
@@ -1904,8 +2018,13 @@ export class MailxDB {
1904
2018
  /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
1905
2019
  * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
1906
2020
  _unifiedTotalCache = new Map();
1907
- getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
2021
+ getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis = "sent") {
1908
2022
  const offset = (page - 1) * pageSize;
2023
+ // Date basis: sort/stream by the Date: header (sent, default) or the
2024
+ // per-account arrival time (received). sent_date is always non-null
2025
+ // (backfilled to date), and idx_messages_sentdate mirrors
2026
+ // idx_messages_folder_date, so the streaming walk stays O(page).
2027
+ const dateCol = dateBasis === "received" ? "m.date" : "m.sent_date";
1909
2028
  // Find all inbox folder IDs
1910
2029
  const inboxRows = this.db.prepare("SELECT id FROM folders WHERE special_use = 'inbox'").all();
1911
2030
  if (inboxRows.length === 0)
@@ -1970,7 +2089,7 @@ export class MailxDB {
1970
2089
  FROM messages m
1971
2090
  JOIN message_folders mf ON mf.message_row_id = m.id
1972
2091
  WHERE mf.folder_id IN (${placeholders})${flagFilter}
1973
- ORDER BY m.date DESC, m.id DESC`);
2092
+ ORDER BY ${dateCol} DESC, m.id DESC`);
1974
2093
  // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
1975
2094
  // whole inbox). Same semantics as the old inline correlated subqueries.
1976
2095
  const pendingStmt = this.db.prepare("SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
@@ -2011,6 +2130,7 @@ export class MailxDB {
2011
2130
  references: JSON.parse(r.refs || "[]"),
2012
2131
  threadId: r.thread_id || undefined,
2013
2132
  date: r.date,
2133
+ sentDate: r.sent_date ?? r.date,
2014
2134
  subject: r.subject,
2015
2135
  from: { name: r.from_name, address: r.from_address },
2016
2136
  to: JSON.parse(r.to_json),
@@ -2125,7 +2245,26 @@ export class MailxDB {
2125
2245
  * for code paths that mean it; everyone else must pass folderId. */
2126
2246
  updateMessageFlags(accountId, folderId, uid, flags) {
2127
2247
  if (folderId != null) {
2128
- this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, folderId, uid);
2248
+ // Resolve the row via its folder MEMBERSHIP, not messages.folder_id.
2249
+ // The message-id collapse (upsertMessage move-detect) keeps ONE
2250
+ // `messages` row per message_id whose `folder_id` is an arbitrary
2251
+ // "primary" folder, while the message appears in OTHER folders via
2252
+ // `message_folders`. A `WHERE folder_id = ?` update therefore MISSED
2253
+ // any message viewed in a folder other than its stored primary —
2254
+ // 0 rows changed, so star/mark-read silently did nothing locally and
2255
+ // the change "came right back" on the next render (Bob 2026-06-27).
2256
+ // Match the membership (the same join getMessageByUid uses).
2257
+ const r = this.db.prepare(`
2258
+ UPDATE messages SET flags_json = ? WHERE id = (
2259
+ SELECT m.id FROM messages m
2260
+ JOIN message_folders mf ON mf.message_row_id = m.id
2261
+ WHERE m.account_id = ? AND mf.folder_id = ? AND mf.uid = ? LIMIT 1
2262
+ )
2263
+ `).run(JSON.stringify(flags), accountId, folderId, uid);
2264
+ // Fallback for any legacy row lacking a membership entry.
2265
+ if (r.changes === 0) {
2266
+ this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, folderId, uid);
2267
+ }
2129
2268
  }
2130
2269
  else {
2131
2270
  this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, uid);
@@ -2425,6 +2564,19 @@ export class MailxDB {
2425
2564
  WHERE account_id = ? AND folder_id = ? AND cached_at >= ?
2426
2565
  AND message_id IS NOT NULL AND message_id <> ''`).all(accountId, folderId, sinceMs);
2427
2566
  }
2567
+ /** Count local rows in a folder sharing one Message-ID. The Sent-sweep
2568
+ * uses this as a sanity cross-check before re-APPENDing a "missing"
2569
+ * message: >1 local copies mean earlier duplicates synced IN from the
2570
+ * server, so the server-side header search that just said "missing"
2571
+ * was lying (the 3,704-duplicate loop, 2026-07-02). */
2572
+ countMessagesByMessageId(accountId, folderId, messageId) {
2573
+ if (!messageId)
2574
+ return 0;
2575
+ const r = this.db.prepare(`SELECT COUNT(*) AS c FROM messages m
2576
+ JOIN message_folders mf ON mf.message_row_id = m.id
2577
+ WHERE m.account_id = ? AND mf.folder_id = ? AND m.message_id = ?`).get(accountId, folderId, messageId);
2578
+ return r?.c | 0;
2579
+ }
2428
2580
  /** Rebind a local row to a different server UID without re-upserting.
2429
2581
  * Used by the Sent-sweep to repair optimistic-insert mispredictions
2430
2582
  * (local row was inserted at the predicted UIDNEXT but the server
@@ -2579,6 +2731,7 @@ export class MailxDB {
2579
2731
  return;
2580
2732
  if (isJunkContact(lower, name))
2581
2733
  return;
2734
+ name = cleanContactName(name);
2582
2735
  const now = Date.now();
2583
2736
  // discovered tier holds one row per email — bump if present, else
2584
2737
  // insert. Doesn't touch preferred or google rows for the same email;
@@ -2690,6 +2843,7 @@ export class MailxDB {
2690
2843
  return;
2691
2844
  if (this.isAddressDenylisted(email))
2692
2845
  return;
2846
+ name = cleanContactName(name);
2693
2847
  const e = agg.get(email);
2694
2848
  if (e) {
2695
2849
  e.cnt++;
@@ -3216,6 +3370,7 @@ export class MailxDB {
3216
3370
  for (const part of parts) {
3217
3371
  const fromMatch = part.match(/^from:(.+)$/i);
3218
3372
  const toMatch = part.match(/^to:(.+)$/i);
3373
+ const ccMatch = part.match(/^cc:(.+)$/i);
3219
3374
  const subjectMatch = part.match(/^subject:(.+)$/i);
3220
3375
  const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
3221
3376
  const afterMatch = part.match(/^after:(.+)$/i);
@@ -3231,10 +3386,19 @@ export class MailxDB {
3231
3386
  frags.push({ s: `{from_name from_address}:${term}*`, op: false });
3232
3387
  }
3233
3388
  else if (toMatch) {
3389
+ // `to:` deliberately spans To AND Cc — when someone searches
3390
+ // "to:bob" they mean "addressed to bob", and being Cc'd counts.
3391
+ // Use `cc:` for a Cc-only match. (Bcc isn't indexed: it's absent
3392
+ // on received mail by design, and only meaningful in Sent.)
3234
3393
  const term = ftsClean(toMatch[1]);
3235
3394
  if (term)
3236
3395
  frags.push({ s: `{to_text cc_text}:${term}*`, op: false });
3237
3396
  }
3397
+ else if (ccMatch) {
3398
+ const term = ftsClean(ccMatch[1]);
3399
+ if (term)
3400
+ frags.push({ s: `cc_text:${term}*`, op: false });
3401
+ }
3238
3402
  else if (subjectMatch) {
3239
3403
  const term = ftsClean(subjectMatch[1]);
3240
3404
  if (term)
@@ -3334,6 +3498,9 @@ export class MailxDB {
3334
3498
  ftsQuery += sep + frags[i].s;
3335
3499
  }
3336
3500
  ftsQuery = ftsQuery.trim();
3501
+ // (Header-scoped search is reachable via the `subject:`/`from:`/`to:`
3502
+ // qualifiers — no separate "headers only" mode needed, and body search
3503
+ // isn't slow enough to warrant one.)
3337
3504
  // No real FTS term — either qualifier-only ("is:flagged after:1w") or
3338
3505
  // every term sanitized away (user typed only punctuation). There is NO
3339
3506
  // valid FTS5 "match everything" string — `MATCH '*'` is itself an FTS5
@@ -3419,16 +3586,23 @@ export class MailxDB {
3419
3586
  return { items: [], total: 0, page, pageSize };
3420
3587
  }
3421
3588
  }
3422
- /** Rebuild FTS index from existing messages */
3589
+ /** Rebuild FTS index from existing messages.
3590
+ * body_text is seeded from `preview` here (all db.ts can reach); the full
3591
+ * message body is backfilled separately by mailx-imap's backfillFtsBodies()
3592
+ * pass, which can read the cached .eml. */
3423
3593
  rebuildSearchIndex() {
3424
- // Drop and recreate in case schema changed
3594
+ // Drop and recreate. MUST be the STANDALONE schema (no content=messages):
3595
+ // the external-content form indexes to_text/cc_text/body_text columns
3596
+ // that don't exist on `messages`, so content-dereferencing silently
3597
+ // drops those terms — the exact breakage migrateFtsSchema() repairs.
3598
+ // Recreating it here used to quietly reintroduce that bug on every
3599
+ // -reindex (Bob 2026-06-05 "search missed to/cc/body").
3425
3600
  try {
3426
3601
  this.db.exec("DROP TABLE IF EXISTS messages_fts");
3427
3602
  }
3428
3603
  catch { /* ignore */ }
3429
3604
  this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
3430
- subject, from_name, from_address, to_text, cc_text, body_text,
3431
- content=messages, content_rowid=id
3605
+ subject, from_name, from_address, to_text, cc_text, body_text
3432
3606
  )`);
3433
3607
  // Use a single transaction + prepared statement for speed (~50x faster than individual inserts)
3434
3608
  const insert = this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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.19",
13
- "@bobfrankston/mailx-settings": "^0.1.30",
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.19",
33
- "@bobfrankston/mailx-settings": "^0.1.30",
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) {