@bobfrankston/rmfmail 1.2.155 → 1.2.158

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.
@@ -205,8 +205,14 @@ const SCHEMA = `
205
205
  -- the index stores its own text (we INSERT it explicitly on upsert), so
206
206
  -- there's nothing to dereference and DELETE/INSERT/reindex all work. The
207
207
  -- migrateFtsSchema() pass below rebuilds an existing external-content table.
208
+ -- Tokenizer is TRIGRAM (not unicode61): unicode61 indexes whole words, so
209
+ -- "opens" prefix-matched "opensprinkler" but "sprinkler" found nothing
210
+ -- (Bob 2026-07-19). Trigram matches any >=3-char substring; searchMessages
211
+ -- falls back to LIKE for 1-2 char terms. Existing DBs with the old
212
+ -- tokenizer are converted in the background by startFtsTrigramMigration().
208
213
  CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
209
- subject, from_name, from_address, to_text, cc_text, body_text
214
+ subject, from_name, from_address, to_text, cc_text, body_text,
215
+ tokenize='trigram remove_diacritics 1'
210
216
  );
211
217
 
212
218
  CREATE TABLE IF NOT EXISTS sync_actions (
@@ -1953,14 +1959,24 @@ export class MailxDB {
1953
1959
  // + from/to. Standalone FTS5 → delete + insert. body_text
1954
1960
  // backfills when the body parses.
1955
1961
  try {
1956
- this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(existing.id);
1957
- this.db.prepare(
1958
- "INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, '')"
1959
- ).run(
1962
+ const vals = [
1960
1963
  existing.id, msg.subject || "", msg.from?.name || "", msg.from?.address || "",
1961
1964
  (msg.to || []).map(a => `${a.name || ""} ${a.address || ""}`).join(" "),
1962
1965
  (msg.cc || []).map(a => `${a.name || ""} ${a.address || ""}`).join(" "),
1963
- );
1966
+ ] as const;
1967
+ this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(existing.id);
1968
+ this.db.prepare(
1969
+ "INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, '')"
1970
+ ).run(...vals);
1971
+ // Mirror into the trigram-migration shadow so the swap
1972
+ // doesn't lose this re-index (see startFtsTrigramMigration).
1973
+ if (this.ftsTriShadowExists()) {
1974
+ try {
1975
+ this.db.prepare(
1976
+ "INSERT OR REPLACE INTO messages_fts_tri (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, '')"
1977
+ ).run(...vals);
1978
+ } catch { /* shadow may have just been swapped away */ }
1979
+ }
1964
1980
  } catch { /* best-effort */ }
1965
1981
  }
1966
1982
  // Refresh membership last_seen_at — server confirmed this UID
@@ -2118,6 +2134,14 @@ export class MailxDB {
2118
2134
  this.db.prepare(
2119
2135
  "INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
2120
2136
  ).run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
2137
+ // Mirror into the trigram-migration shadow (see startFtsTrigramMigration).
2138
+ if (this.ftsTriShadowExists()) {
2139
+ try {
2140
+ this.db.prepare(
2141
+ "INSERT OR REPLACE INTO messages_fts_tri (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
2142
+ ).run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
2143
+ } catch { /* shadow may have just been swapped away */ }
2144
+ }
2121
2145
  } catch { /* FTS insert may fail on rebuild, non-fatal */ }
2122
2146
 
2123
2147
  return rowId;
@@ -2141,6 +2165,14 @@ export class MailxDB {
2141
2165
  this.db.prepare(
2142
2166
  "UPDATE messages_fts SET body_text = ? WHERE rowid = ?",
2143
2167
  ).run(capped, rowId);
2168
+ // Mirror into the trigram-migration shadow (see startFtsTrigramMigration).
2169
+ // No-op if the copy loop hasn't reached this rowid yet — the chunk
2170
+ // will carry the (also-updated) old-table value across later.
2171
+ if (this.ftsTriShadowExists()) {
2172
+ try {
2173
+ this.db.prepare("UPDATE messages_fts_tri SET body_text = ? WHERE rowid = ?").run(capped, rowId);
2174
+ } catch { /* shadow may have just been swapped away */ }
2175
+ }
2144
2176
  } catch { /* FTS update is best-effort */ }
2145
2177
  }
2146
2178
 
@@ -2742,7 +2774,7 @@ export class MailxDB {
2742
2774
  if (!row || !/content\s*=\s*messages/i.test(row.sql)) return; // already standalone
2743
2775
  console.log(" [db] messages_fts had broken external-content schema — rebuilding standalone + reindexing");
2744
2776
  this.db.exec("DROP TABLE IF EXISTS messages_fts");
2745
- this.db.exec("CREATE VIRTUAL TABLE messages_fts USING fts5(subject, from_name, from_address, to_text, cc_text, body_text)");
2777
+ this.db.exec("CREATE VIRTUAL TABLE messages_fts USING fts5(subject, from_name, from_address, to_text, cc_text, body_text, tokenize='trigram remove_diacritics 1')");
2746
2778
  const addrText = (j: string): string => {
2747
2779
  try { return (JSON.parse(j || "[]") as any[]).map(a => `${a.name || ""} ${a.address || ""}`).join(" "); }
2748
2780
  catch { return ""; }
@@ -2760,6 +2792,121 @@ export class MailxDB {
2760
2792
  }
2761
2793
  }
2762
2794
 
2795
+ /** True while the trigram migration's shadow table exists — the FTS write
2796
+ * paths (upsert / updateFtsBody) mirror into it so the copy loop never
2797
+ * chases a moving target. TTL-cached: upserts run hundreds/sec during
2798
+ * sync and the answer only changes twice in the DB's lifetime. Checked
2799
+ * via sqlite_master (not an in-memory flag) because the migration runs on
2800
+ * the main writer while upserts run on the sync worker's connection. */
2801
+ private triShadowCache: { at: number; exists: boolean } | null = null;
2802
+ private ftsTriShadowExists(): boolean {
2803
+ const now = Date.now();
2804
+ if (this.triShadowCache && now - this.triShadowCache.at < 2000) return this.triShadowCache.exists;
2805
+ let exists = false;
2806
+ try {
2807
+ exists = !!this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='messages_fts_tri'").get();
2808
+ } catch { /* treat as absent */ }
2809
+ this.triShadowCache = { at: now, exists };
2810
+ return exists;
2811
+ }
2812
+
2813
+ /** Does messages_fts still use the old unicode61 word tokenizer? Checked
2814
+ * per-call (not cached): the answer flips mid-session when the background
2815
+ * migration swaps tables, and searchMessages must switch query dialects
2816
+ * the moment it does — including on the read-worker's own connection. */
2817
+ private ftsIsTrigram(): boolean {
2818
+ try {
2819
+ const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'").get() as { sql: string } | undefined;
2820
+ return !!row && /trigram/i.test(row.sql || "");
2821
+ } catch { return false; }
2822
+ }
2823
+
2824
+ ftsNeedsTrigramMigration(): boolean {
2825
+ if (this.readOnly) return false;
2826
+ try {
2827
+ const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'").get() as { sql: string } | undefined;
2828
+ return !!row && !/trigram/i.test(row.sql || "");
2829
+ } catch { return false; }
2830
+ }
2831
+
2832
+ /** One-time background conversion of messages_fts from the unicode61 word
2833
+ * tokenizer to trigram (substring search — "sprinkler" now finds
2834
+ * "OpenSprinkler", Bob 2026-07-19). Word tokens only match by prefix, so
2835
+ * any mid-word search silently found nothing.
2836
+ *
2837
+ * Runs chunked on the main writer (~3-4 min for 190k rows, measured) so
2838
+ * boot isn't blocked and sync writes stay responsive between chunks. The
2839
+ * existing index keeps serving searches the whole time; concurrent FTS
2840
+ * writes land in BOTH tables via ftsTriShadowExists() mirroring. The
2841
+ * final swap (DROP + RENAME) is a single transaction, so a crash at any
2842
+ * point leaves either the old table (migration restarts next boot) or
2843
+ * the finished trigram table — never neither. Stored body_text survives
2844
+ * because the copy reads it straight out of the old standalone table.
2845
+ * Call ONLY from the long-lived daemon: a one-shot CLI command would sit
2846
+ * alive for minutes finishing the copy. */
2847
+ startFtsTrigramMigration(): void {
2848
+ if (!this.ftsNeedsTrigramMigration()) return;
2849
+ const COLS = "rowid, subject, from_name, from_address, to_text, cc_text, body_text";
2850
+ try {
2851
+ // A leftover shadow from an interrupted run may be stale (writes
2852
+ // that happened while no daemon mirrored them) — start clean.
2853
+ this.db.exec("DROP TABLE IF EXISTS messages_fts_tri");
2854
+ this.db.exec("CREATE VIRTUAL TABLE messages_fts_tri USING fts5(subject, from_name, from_address, to_text, cc_text, body_text, tokenize='trigram remove_diacritics 1')");
2855
+ } catch (e: any) {
2856
+ console.error(` [db] FTS trigram migration setup failed: ${e?.message || e}`);
2857
+ return;
2858
+ }
2859
+ let total = 0;
2860
+ try { total = (this.db.prepare("SELECT COUNT(*) AS c FROM messages_fts").get() as any)?.c || 0; } catch { /* */ }
2861
+ console.log(` [db] FTS trigram migration started: ${total} rows in background (substring search when done)`);
2862
+ const CHUNK = 500; // ~0.35s of tokenization per chunk (measured on Bob's DB)
2863
+ let last = 0, copied = 0, chunkN = 0;
2864
+ const step = (): void => {
2865
+ try {
2866
+ const rows = this.db.prepare("SELECT rowid FROM messages_fts WHERE rowid > ? ORDER BY rowid LIMIT ?").all(last, CHUNK) as { rowid: number }[];
2867
+ if (rows.length === 0) { finish(); return; }
2868
+ const hi = rows[rows.length - 1].rowid;
2869
+ // Single INSERT..SELECT — atomic against the sync worker's
2870
+ // mirrored writes (WAL serializes writers), so old and shadow
2871
+ // can't diverge inside a chunk. OR REPLACE because mirrored
2872
+ // writes may have already landed rows past the frontier.
2873
+ this.db.prepare(
2874
+ `INSERT OR REPLACE INTO messages_fts_tri (${COLS}) SELECT ${COLS} FROM messages_fts WHERE rowid > ? AND rowid <= ?`
2875
+ ).run(last, hi);
2876
+ last = hi; copied += rows.length; chunkN++;
2877
+ if (chunkN % 40 === 0) {
2878
+ console.log(` [db] FTS trigram migration: ${copied}/${total}`);
2879
+ // The copy writes ~1 GB through the WAL in total — keep it drained.
2880
+ try { this.db.exec("PRAGMA wal_checkpoint(PASSIVE)"); } catch { /* */ }
2881
+ }
2882
+ setTimeout(step, 100); // yield the writer between chunks
2883
+ } catch (e: any) {
2884
+ console.error(` [db] FTS trigram migration chunk failed (${e?.message || e}) — retrying in 5s`);
2885
+ setTimeout(step, 5000);
2886
+ }
2887
+ };
2888
+ const finish = (): void => {
2889
+ try {
2890
+ this.db.exec("BEGIN IMMEDIATE");
2891
+ // Rows past the frontier that landed after the last chunk are
2892
+ // already mirrored into the shadow, but belt-and-braces: if any
2893
+ // exist, loop once more instead of trusting the mirror.
2894
+ const strag = (this.db.prepare("SELECT COUNT(*) AS c FROM messages_fts WHERE rowid > ?").get(last) as any)?.c || 0;
2895
+ if (strag > 0) { this.db.exec("ROLLBACK"); setTimeout(step, 100); return; }
2896
+ this.db.exec("DROP TABLE messages_fts");
2897
+ this.db.exec("ALTER TABLE messages_fts_tri RENAME TO messages_fts");
2898
+ this.db.exec("COMMIT");
2899
+ console.log(` [db] FTS trigram migration complete: ${copied} rows — substring search active`);
2900
+ try { this.db.exec("PRAGMA wal_checkpoint(PASSIVE)"); } catch { /* */ }
2901
+ } catch (e: any) {
2902
+ try { this.db.exec("ROLLBACK"); } catch { /* */ }
2903
+ console.error(` [db] FTS trigram migration swap failed (${e?.message || e}) — retrying in 5s`);
2904
+ setTimeout(finish, 5000);
2905
+ }
2906
+ };
2907
+ setTimeout(step, 3000); // let boot (folder loads, first sync) settle first
2908
+ }
2909
+
2763
2910
  /** Record a prefetch failure (0-body fetch / store-write fail) for a UID,
2764
2911
  * incrementing its backoff count. Persisted so it survives restarts. */
2765
2912
  recordPrefetchFailure(accountId: string, folderId: number, uid: number): void {
@@ -3701,10 +3848,57 @@ export class MailxDB {
3701
3848
  // Strip FTS5 metacharacters from a user term. A stray `;` `(` `:` `"`
3702
3849
  // etc. is a hard `fts5: syntax error` (Bob 2026-05-21 log). FTS5's
3703
3850
  // tokenizer splits on non-word chars anyway, so removing them loses no
3704
- // real matching ability.
3851
+ // real matching ability. (unicode61 dialect only — see isTri below.)
3705
3852
  const ftsClean = (t: string): string => t.replace(/["';:(){}\[\]^~\\/]/g, "").trim();
3706
3853
 
3707
- for (const part of parts) {
3854
+ // ── Tokenizer dialects ──
3855
+ // The index is migrating from unicode61 (word tokens, prefix-only
3856
+ // matching — "sprinkler" could never find "opensprinkler") to trigram
3857
+ // (substring matching, Bob 2026-07-19). Which dialect to emit depends
3858
+ // on which table is live RIGHT NOW: the background migration swaps it
3859
+ // mid-session, and this runs on the read-worker's own connection, so
3860
+ // ask sqlite_master rather than trusting boot-time state.
3861
+ //
3862
+ // Trigram dialect rules:
3863
+ // • every term is emitted as a QUOTED string — FTS5 then matches it
3864
+ // as a case-folded substring anywhere in the text, punctuation and
3865
+ // all ("192.55.226" matches literally; no dot-splitting, no `*`).
3866
+ // Only embedded double-quotes need stripping.
3867
+ // • terms under 3 chars can't form a trigram and MATCH nothing, so
3868
+ // they fall back to a LIKE over the header columns. Headers only:
3869
+ // scanning preview/to/cc for a 1-2 char pattern measured ~5s on
3870
+ // 190k rows vs ~0.3s worst-case for headers, and short terms are
3871
+ // transient search-as-you-type states anyway.
3872
+ const isTri = this.ftsIsTrigram();
3873
+ const triClean = (t: string): string => t.replace(/"/g, "").trim();
3874
+ const triQuote = (t: string): string => `"${t}"`;
3875
+ const likeEsc = (t: string): string => t.replace(/[~%_]/g, m => `~${m}`);
3876
+ // One OR-group per call: multiple terms land in the SAME group so an
3877
+ // all-short alternation ("ab|cd") stays an OR, not an AND of groups.
3878
+ const pushLike = (cols: string[], terms: string | string[]): void => {
3879
+ const list = Array.isArray(terms) ? terms : [terms];
3880
+ const clauses: string[] = [];
3881
+ for (const t of list) {
3882
+ for (const c of cols) {
3883
+ clauses.push(`${c} LIKE ? ESCAPE '~'`);
3884
+ extraParams.push(`%${likeEsc(t)}%`);
3885
+ }
3886
+ }
3887
+ if (clauses.length) extraWhere.push("(" + clauses.join(" OR ") + ")");
3888
+ };
3889
+ const LIKE_FROM = ["m.from_name", "m.from_address"];
3890
+ const LIKE_TO = ["m.to_json", "m.cc_json"];
3891
+ const LIKE_CC = ["m.cc_json"];
3892
+ const LIKE_SUBJ = ["m.subject"];
3893
+ const LIKE_ANY = ["m.subject", "m.from_name", "m.from_address"];
3894
+
3895
+ for (let pi = 0; pi < parts.length; pi++) {
3896
+ const part = parts[pi];
3897
+ // A 1-2 char term next to a user-typed OR must be DROPPED, not
3898
+ // LIKE'd: LIKE clauses AND against the MATCH, so "ab OR sprinkler"
3899
+ // would intersect down to zero. Over-matching (just "sprinkler")
3900
+ // beats returning nothing. (Trigram dialect only.)
3901
+ const orAdjacent = parts[pi - 1] === "OR" || parts[pi + 1] === "OR";
3708
3902
  const fromMatch = part.match(/^from:(.+)$/i);
3709
3903
  const toMatch = part.match(/^to:(.+)$/i);
3710
3904
  const ccMatch = part.match(/^cc:(.+)$/i);
@@ -3719,21 +3913,45 @@ export class MailxDB {
3719
3913
  if (fromMatch) {
3720
3914
  // FTS5 column-group `{c1 c2}:term` matches term in either
3721
3915
  // column without the parens that break a trailing implicit-AND.
3722
- const term = ftsClean(fromMatch[1]);
3723
- if (term) frags.push({ s: `{from_name from_address}:${term}*`, op: false });
3916
+ if (isTri) {
3917
+ const term = triClean(fromMatch[1]);
3918
+ if (term.length >= 3) frags.push({ s: `{from_name from_address}:${triQuote(term)}`, op: false });
3919
+ else if (term && !orAdjacent) pushLike(LIKE_FROM, term);
3920
+ } else {
3921
+ const term = ftsClean(fromMatch[1]);
3922
+ if (term) frags.push({ s: `{from_name from_address}:${term}*`, op: false });
3923
+ }
3724
3924
  } else if (toMatch) {
3725
3925
  // `to:` deliberately spans To AND Cc — when someone searches
3726
3926
  // "to:bob" they mean "addressed to bob", and being Cc'd counts.
3727
3927
  // Use `cc:` for a Cc-only match. (Bcc isn't indexed: it's absent
3728
3928
  // on received mail by design, and only meaningful in Sent.)
3729
- const term = ftsClean(toMatch[1]);
3730
- if (term) frags.push({ s: `{to_text cc_text}:${term}*`, op: false });
3929
+ if (isTri) {
3930
+ const term = triClean(toMatch[1]);
3931
+ if (term.length >= 3) frags.push({ s: `{to_text cc_text}:${triQuote(term)}`, op: false });
3932
+ else if (term && !orAdjacent) pushLike(LIKE_TO, term);
3933
+ } else {
3934
+ const term = ftsClean(toMatch[1]);
3935
+ if (term) frags.push({ s: `{to_text cc_text}:${term}*`, op: false });
3936
+ }
3731
3937
  } else if (ccMatch) {
3732
- const term = ftsClean(ccMatch[1]);
3733
- if (term) frags.push({ s: `cc_text:${term}*`, op: false });
3938
+ if (isTri) {
3939
+ const term = triClean(ccMatch[1]);
3940
+ if (term.length >= 3) frags.push({ s: `cc_text:${triQuote(term)}`, op: false });
3941
+ else if (term && !orAdjacent) pushLike(LIKE_CC, term);
3942
+ } else {
3943
+ const term = ftsClean(ccMatch[1]);
3944
+ if (term) frags.push({ s: `cc_text:${term}*`, op: false });
3945
+ }
3734
3946
  } else if (subjectMatch) {
3735
- const term = ftsClean(subjectMatch[1]);
3736
- if (term) frags.push({ s: `subject:${term}*`, op: false });
3947
+ if (isTri) {
3948
+ const term = triClean(subjectMatch[1]);
3949
+ if (term.length >= 3) frags.push({ s: `subject:${triQuote(term)}`, op: false });
3950
+ else if (term && !orAdjacent) pushLike(LIKE_SUBJ, term);
3951
+ } else {
3952
+ const term = ftsClean(subjectMatch[1]);
3953
+ if (term) frags.push({ s: `subject:${term}*`, op: false });
3954
+ }
3737
3955
  } else if (dateMatch || afterMatch || beforeMatch) {
3738
3956
  const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
3739
3957
  const valStr = dateMatch ? dateMatch[2] : (afterMatch ? afterMatch[1] : beforeMatch![1]);
@@ -3764,6 +3982,31 @@ export class MailxDB {
3764
3982
  // (one of them being any word starting with "AND") — so a real
3765
3983
  // match like "Peter Hoddie" + "github" returned zero hits.
3766
3984
  frags.push({ s: part, op: true });
3985
+ } else if (isTri) {
3986
+ // Unqualified, trigram dialect — quoted substring match. A
3987
+ // user-quoted "foo bar" phrase survives as one literal
3988
+ // substring including the space (real phrase search, which
3989
+ // the unicode61 dialect approximated with AND'd words).
3990
+ const term = part.replace(/^\/|\/$/g, "");
3991
+ if (term.includes("|")) {
3992
+ const alts = term.split("|").map(t => triClean(t)).filter(Boolean);
3993
+ const long = alts.filter(t => t.length >= 3);
3994
+ const short = alts.filter(t => t.length < 3);
3995
+ if (long.length) {
3996
+ // Mixed `abc|xy`: drop the short alternates. An OR
3997
+ // can't span MATCH and LIKE (LIKE would AND against
3998
+ // the MATCH and turn the union into an intersection
3999
+ // that returns nothing).
4000
+ frags.push({ s: `(${long.map(triQuote).join(" OR ")})`, op: false });
4001
+ } else if (short.length && !orAdjacent) {
4002
+ // All-short: one OR'd LIKE group keeps `ab|cd` a union.
4003
+ pushLike(LIKE_ANY, short);
4004
+ }
4005
+ } else {
4006
+ const t = triClean(term);
4007
+ if (t.length >= 3) frags.push({ s: triQuote(t), op: false });
4008
+ else if (t && !orAdjacent) pushLike(LIKE_ANY, t);
4009
+ }
3767
4010
  } else {
3768
4011
  // Unqualified — search everything. Strip /regex-literal/
3769
4012
  // slashes and FTS5 metacharacters before wildcarding.
@@ -3792,6 +4035,13 @@ export class MailxDB {
3792
4035
  }
3793
4036
  }
3794
4037
 
4038
+ // A term diverted to the LIKE fallback can strand a user-typed
4039
+ // operator at the edge of the MATCH string ("ab OR sprinkler" → the
4040
+ // "ab" became a LIKE, leaving "OR sprinkler" — an FTS5 syntax error
4041
+ // that would silently empty the whole search). Trim dangling operators.
4042
+ while (frags.length && frags[0].op) frags.shift();
4043
+ while (frags.length && frags[frags.length - 1].op) frags.pop();
4044
+
3795
4045
  // Join fragments. Two adjacent value fragments need an explicit `AND`
3796
4046
  // (implicit-AND after a `(...)` / `{...}:` group is an FTS5 syntax
3797
4047
  // error); a user operator fragment glues itself, so no insert around it.
@@ -3912,8 +4162,12 @@ export class MailxDB {
3912
4162
  // Recreating it here used to quietly reintroduce that bug on every
3913
4163
  // -reindex (Bob 2026-06-05 "search missed to/cc/body").
3914
4164
  try { this.db.exec("DROP TABLE IF EXISTS messages_fts"); } catch { /* ignore */ }
4165
+ // Trigram tokenization is ~10x slower to build than unicode61, so a
4166
+ // full -reindex on a 190k-row DB takes minutes. Explicit user command,
4167
+ // runs once — substring search is worth it.
3915
4168
  this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
3916
- subject, from_name, from_address, to_text, cc_text, body_text
4169
+ subject, from_name, from_address, to_text, cc_text, body_text,
4170
+ tokenize='trigram remove_diacritics 1'
3917
4171
  )`);
3918
4172
 
3919
4173
  // Use a single transaction + prepared statement for speed (~50x faster than individual inserts)