@bobfrankston/rmfmail 1.2.157 → 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.
- package/bin/mailx.js +5 -0
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +5 -0
- package/package.json +1 -1
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-core/index.d.ts.map +1 -1
- package/packages/mailx-core/index.js +3 -0
- package/packages/mailx-core/index.js.map +1 -1
- package/packages/mailx-core/index.ts +3 -0
- package/packages/mailx-store/db.d.ts +30 -0
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +321 -18
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +272 -18
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-7576 → node_modules.npmglobalize-stash-12736}/.package-lock.json +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3723
|
-
|
|
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
|
-
|
|
3730
|
-
|
|
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
|
-
|
|
3733
|
-
|
|
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
|
-
|
|
3736
|
-
|
|
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)
|