@bobfrankston/mailx-store 0.1.72 → 0.1.76

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 +15 -2
  2. package/db.js +62 -16
  3. package/package.json +1 -1
  4. package/store.d.ts +15 -0
  5. package/store.js +79 -5
package/db.d.ts CHANGED
@@ -274,8 +274,12 @@ export declare class MailxDB {
274
274
  * During the additive migration we ALSO have to handle the legacy
275
275
  * case where the row's folder_id matches but no message_folders row
276
276
  * was migrated for it (race during initial population, etc.) — we
277
- * fall back to the messages.folder_id check to clean up consistently. */
278
- dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string): boolean;
277
+ * fall back to the messages.folder_id check to clean up consistently.
278
+ *
279
+ * `reason`/`source` override the audit text — deleteMessage routes user
280
+ * deletes through here, and "reconcile: server missing this UID" would
281
+ * be a lie in that audit trail. */
282
+ dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string, reason?: string, source?: string): boolean;
279
283
  upsertMessage(msg: {
280
284
  accountId: string;
281
285
  folderId: number;
@@ -320,6 +324,15 @@ export declare class MailxDB {
320
324
  * so that lookup could miss the row entirely and silently leave the
321
325
  * body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
322
326
  * body, search found nothing). */
327
+ /** Length of the indexed FTS body_text for a row (0 = never backfilled
328
+ * or row missing). Cheap rowid lookup — used to skip redundant
329
+ * re-index work on every message view and by the backfill sweep. */
330
+ ftsBodyLength(rowId: number): number;
331
+ /** Cursor page over messages for the FTS body backfill sweep. */
332
+ listBodyRowsAfter(afterId: number, limit: number): Array<{
333
+ id: number;
334
+ bodyPath: string;
335
+ }>;
323
336
  updateFtsBody(rowId: number, bodyText: string): void;
324
337
  /** List view: messages currently in (account, folder).
325
338
  * Joins through `message_folders` so the UID + folder location come
package/db.js CHANGED
@@ -1675,8 +1675,12 @@ export class MailxDB {
1675
1675
  * During the additive migration we ALSO have to handle the legacy
1676
1676
  * case where the row's folder_id matches but no message_folders row
1677
1677
  * was migrated for it (race during initial population, etc.) — we
1678
- * fall back to the messages.folder_id check to clean up consistently. */
1679
- dropFolderMembership(accountId, folderId, uid, folderPath) {
1678
+ * fall back to the messages.folder_id check to clean up consistently.
1679
+ *
1680
+ * `reason`/`source` override the audit text — deleteMessage routes user
1681
+ * deletes through here, and "reconcile: server missing this UID" would
1682
+ * be a lie in that audit trail. */
1683
+ dropFolderMembership(accountId, folderId, uid, folderPath, reason, source) {
1680
1684
  // First find the messages row this membership pointed at.
1681
1685
  const mf = this.db.prepare("SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?").get(folderId, uid);
1682
1686
  let rowId = null;
@@ -1711,8 +1715,8 @@ export class MailxDB {
1711
1715
  this.audit({
1712
1716
  kind: "delete-membership",
1713
1717
  accountId, folderId, uid,
1714
- reason: `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1715
- source: `dropFolderMembership (${folderPath || ""})`,
1718
+ reason: reason || `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1719
+ source: source || `dropFolderMembership (${folderPath || ""})`,
1716
1720
  });
1717
1721
  return true;
1718
1722
  }
@@ -1726,8 +1730,8 @@ export class MailxDB {
1726
1730
  accountId, folderId, uid,
1727
1731
  messageId: env.message_id || undefined,
1728
1732
  subject: env.subject || undefined,
1729
- reason: "reconcile: server missing this UID after grace, no other folder memberships",
1730
- source: `dropFolderMembership (${folderPath || ""})`,
1733
+ reason: reason || "reconcile: server missing this UID after grace, no other folder memberships",
1734
+ source: source || `dropFolderMembership (${folderPath || ""})`,
1731
1735
  });
1732
1736
  console.log(` [reconcile-delete] ${accountId} ${folderPath || folderId}/${uid} msgid=${env.message_id || "?"} (no other memberships) — body ${env.body_path || "(none)"}`);
1733
1737
  }
@@ -1991,6 +1995,22 @@ export class MailxDB {
1991
1995
  * so that lookup could miss the row entirely and silently leave the
1992
1996
  * body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
1993
1997
  * body, search found nothing). */
1998
+ /** Length of the indexed FTS body_text for a row (0 = never backfilled
1999
+ * or row missing). Cheap rowid lookup — used to skip redundant
2000
+ * re-index work on every message view and by the backfill sweep. */
2001
+ ftsBodyLength(rowId) {
2002
+ try {
2003
+ const r = this.db.prepare("SELECT length(body_text) AS l FROM messages_fts WHERE rowid = ?").get(rowId);
2004
+ return Number(r?.l || 0);
2005
+ }
2006
+ catch {
2007
+ return 0;
2008
+ }
2009
+ }
2010
+ /** Cursor page over messages for the FTS body backfill sweep. */
2011
+ listBodyRowsAfter(afterId, limit) {
2012
+ return this.db.prepare("SELECT id, body_path AS bodyPath FROM messages WHERE id > ? ORDER BY id LIMIT ?").all(afterId, limit);
2013
+ }
1994
2014
  updateFtsBody(rowId, bodyText) {
1995
2015
  if (!rowId)
1996
2016
  return;
@@ -2294,13 +2314,19 @@ export class MailxDB {
2294
2314
  * hitting the message in `_Spam` (correct) and hitting nothing
2295
2315
  * because the legacy messages.folder_id still points at INBOX. */
2296
2316
  getMessageByUid(accountId, uid, folderId) {
2317
+ // Alias mf.folder_id over m.folder_id too — the envelope must carry
2318
+ // the identity of the membership that MATCHED, not the legacy primary
2319
+ // location, or a caller doing delete/move/flag with the returned
2320
+ // folderId operates on a different folder than the one it looked up
2321
+ // (the two drift: local moves keep the original uid, re-binds update
2322
+ // one table but not the other).
2297
2323
  const sql = folderId != null
2298
- ? `SELECT m.*, mf.uid AS uid
2324
+ ? `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2299
2325
  FROM messages m
2300
2326
  JOIN message_folders mf ON mf.message_row_id = m.id
2301
2327
  WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
2302
2328
  LIMIT 1`
2303
- : `SELECT m.*, mf.uid AS uid
2329
+ : `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2304
2330
  FROM messages m
2305
2331
  JOIN message_folders mf ON mf.message_row_id = m.id
2306
2332
  WHERE m.account_id = ? AND mf.uid = ?
@@ -2868,14 +2894,31 @@ export class MailxDB {
2868
2894
  * Reason is propagated into the audit_log so the DB carries an
2869
2895
  * authoritative trail of every removal. */
2870
2896
  deleteMessage(accountId, folderId, uid, reason, source) {
2871
- // Get message_id + subject before deleting so the audit row carries
2872
- // enough context to identify what was removed.
2873
- const msg = folderId != null
2874
- ? this.db.prepare("SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, folderId, uid)
2875
- : this.db.prepare("SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
2876
- const r = folderId != null
2877
- ? this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").run(accountId, folderId, uid)
2878
- : this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
2897
+ if (folderId != null) {
2898
+ // Resolve through message_folders the SAME identity that
2899
+ // getMessageByUid and every list query use. The legacy
2900
+ // messages.folder_id/uid columns can drift from the membership
2901
+ // rows (local moves keep the original uid, server re-binds update
2902
+ // one table but not the other), and a delete keyed only on the
2903
+ // legacy columns silently matches ZERO rows on a drifted message:
2904
+ // the membership keeps it visible in the UI and it becomes
2905
+ // undeletable forever (Eleanor 2026-07-29: ElkinWs uid 125482 in
2906
+ // Trash "refuses to delete" — every attempt no-op'd here while
2907
+ // the sync action reported success). dropFolderMembership drops
2908
+ // the membership, orphan-cleans the messages row, and falls back
2909
+ // to the legacy columns for pre-migration rows.
2910
+ const cleaned = this.dropFolderMembership(accountId, folderId, uid, undefined, reason || "deleteMessage (no reason)", source || "db.deleteMessage");
2911
+ if (!cleaned) {
2912
+ // A delete that deletes nothing is the "refuses to delete"
2913
+ // symptom — never let it pass silently.
2914
+ console.error(` [delete-miss] ${accountId} folder=${folderId} uid=${uid}: no membership or legacy row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
2915
+ }
2916
+ this.recalcFolderCounts(folderId);
2917
+ return;
2918
+ }
2919
+ // No folderId (legacy callers): fall back to the messages-table key.
2920
+ const msg = this.db.prepare("SELECT id, folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
2921
+ const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
2879
2922
  if (r.changes && msg) {
2880
2923
  this.audit({
2881
2924
  kind: "delete-msg",
@@ -2888,6 +2931,9 @@ export class MailxDB {
2888
2931
  source: source || "db.deleteMessage",
2889
2932
  });
2890
2933
  }
2934
+ else if (!r.changes) {
2935
+ console.error(` [delete-miss] ${accountId} uid=${uid} (no folderId): no messages row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
2936
+ }
2891
2937
  // Refresh folder counts
2892
2938
  if (msg)
2893
2939
  this.recalcFolderCounts(msg.folder_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.72",
3
+ "version": "0.1.76",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/store.d.ts CHANGED
@@ -89,6 +89,21 @@ export declare class Store {
89
89
  private getCachedAllowlist;
90
90
  private getCachedSettings;
91
91
  invalidateConfigCaches(): void;
92
+ /** Set on read-only Store instances (the db-worker serving UI reads):
93
+ * receives (rowId, bodyText) when getMessage parses a body whose FTS
94
+ * body_text is still empty, so the writable side can apply
95
+ * updateFtsBody. Without this the worker's UPDATE fails on its
96
+ * query_only connection — silently, for months (2026-07-27). */
97
+ onFtsBodyBackfill: ((rowId: number, bodyText: string) => void) | null;
98
+ /** Background sweep: index the body text of every message whose body is
99
+ * on disk but whose FTS row has an empty body_text. Historical debt
100
+ * from the read-only-worker backfill bug — 179k of 190k rows on Bob's
101
+ * store. Paced (one parse per ~100 ms + chunk pauses) so the shared
102
+ * parse-worker lane stays responsive for interactive views; safe to
103
+ * re-run every boot — already-indexed rows cost one rowid lookup and
104
+ * are skipped, so a completed sweep re-scans in seconds. Never runs on
105
+ * a read-only connection. */
106
+ runFtsBodyBackfillSweep(): Promise<void>;
92
107
  constructor(
93
108
  /** SQLite metadata index. Exposed as a public field — sync clients
94
109
  * (mailx-imap, mailx-sync) read/write through it during the
package/store.js CHANGED
@@ -22,7 +22,7 @@
22
22
  import * as fs from "node:fs";
23
23
  import { parseSerial } from "./parse-serial.js";
24
24
  import { storeBus } from "./bus.js";
25
- import { sanitizeHtml } from "@bobfrankston/mailx-types";
25
+ import { sanitizeHtml, htmlToPlainText } from "@bobfrankston/mailx-types";
26
26
  import { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
27
27
  import { sniffAndFixCharset } from "./charset.js";
28
28
  /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
@@ -127,6 +127,61 @@ export class Store {
127
127
  this._allowlistCache = null;
128
128
  this._settingsCache = null;
129
129
  }
130
+ /** Set on read-only Store instances (the db-worker serving UI reads):
131
+ * receives (rowId, bodyText) when getMessage parses a body whose FTS
132
+ * body_text is still empty, so the writable side can apply
133
+ * updateFtsBody. Without this the worker's UPDATE fails on its
134
+ * query_only connection — silently, for months (2026-07-27). */
135
+ onFtsBodyBackfill = null;
136
+ /** Background sweep: index the body text of every message whose body is
137
+ * on disk but whose FTS row has an empty body_text. Historical debt
138
+ * from the read-only-worker backfill bug — 179k of 190k rows on Bob's
139
+ * store. Paced (one parse per ~100 ms + chunk pauses) so the shared
140
+ * parse-worker lane stays responsive for interactive views; safe to
141
+ * re-run every boot — already-indexed rows cost one rowid lookup and
142
+ * are skipped, so a completed sweep re-scans in seconds. Never runs on
143
+ * a read-only connection. */
144
+ async runFtsBodyBackfillSweep() {
145
+ if (this.db.readOnly)
146
+ return;
147
+ const CHUNK = 200;
148
+ let last = 0, scanned = 0, indexed = 0, failed = 0;
149
+ const t0 = Date.now();
150
+ for (;;) {
151
+ const rows = this.db.listBodyRowsAfter(last, CHUNK);
152
+ if (rows.length === 0)
153
+ break;
154
+ for (const r of rows) {
155
+ last = r.id;
156
+ scanned++;
157
+ if (!r.bodyPath)
158
+ continue;
159
+ if (this.db.ftsBodyLength(r.id) > 0)
160
+ continue;
161
+ try {
162
+ if (!await this.bodyStore.hasByPath(r.bodyPath))
163
+ continue;
164
+ const raw = await this.bodyStore.readByPath(r.bodyPath);
165
+ const parsed = await parseSerial(sniffAndFixCharset(raw));
166
+ const text = parsed.text || (parsed.html ? htmlToPlainText(parsed.html) : "");
167
+ if (text) {
168
+ this.db.updateFtsBody(r.id, text);
169
+ indexed++;
170
+ }
171
+ }
172
+ catch {
173
+ failed++;
174
+ }
175
+ // Pace: yield the parse lane between messages so a user click
176
+ // never waits behind more than one sweep parse.
177
+ await new Promise(res => setTimeout(res, 100));
178
+ }
179
+ if (scanned % 5000 < CHUNK) {
180
+ console.log(` [fts-backfill] ${scanned} scanned, ${indexed} indexed, ${failed} failed (${Math.round((Date.now() - t0) / 60000)} min)`);
181
+ }
182
+ }
183
+ console.log(` [fts-backfill] sweep complete: ${indexed} bodies indexed, ${failed} failed, ${scanned} rows scanned in ${Math.round((Date.now() - t0) / 60000)} min`);
184
+ }
130
185
  constructor(
131
186
  /** SQLite metadata index. Exposed as a public field — sync clients
132
187
  * (mailx-imap, mailx-sync) read/write through it during the
@@ -337,11 +392,30 @@ export class Store {
337
392
  // backfill, searches miss any word that only appears deeper in the
338
393
  // body. Fire-and-forget — failures are non-fatal, never block the
339
394
  // user's preview render.
340
- if (bodyText && envelope.id) {
341
- try {
342
- this.db.updateFtsBody(envelope.id, bodyText);
395
+ //
396
+ // HTML-only mail has no parsed.text — derive it from the HTML so
397
+ // those bodies are searchable too (most marketing/newsletter mail).
398
+ //
399
+ // On the READ-ONLY connection (db-worker serving UI reads) the
400
+ // UPDATE cannot run — for months it failed silently here and left
401
+ // body_text empty for every message whose body arrived outside the
402
+ // prefetch path (Bob 2026-07-27: "orwell" found 1 of 6 thread
403
+ // messages; 179k of 190k FTS rows had no body text). Route through
404
+ // onFtsBodyBackfill so the writable main thread applies it.
405
+ const ftsText = bodyText || (bodyHtml ? htmlToPlainText(bodyHtml) : "");
406
+ if (ftsText && envelope.id && this.db.ftsBodyLength(envelope.id) === 0) {
407
+ if (this.db.readOnly) {
408
+ try {
409
+ this.onFtsBodyBackfill?.(envelope.id, ftsText.slice(0, 64_000));
410
+ }
411
+ catch { /* */ }
412
+ }
413
+ else {
414
+ try {
415
+ this.db.updateFtsBody(envelope.id, ftsText);
416
+ }
417
+ catch { /* */ }
343
418
  }
344
- catch { /* */ }
345
419
  }
346
420
  let hasRemoteContent = false;
347
421
  // Filter out "spurious" attachments: mailing-list footers and signature