From 03f7bb81cfb1e012b9703e3bbca9c3bbac4960d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 23 Jul 2026 23:19:25 +0000 Subject: [PATCH] email: OR support in search (OR-separated branches; mixed FTS/SQL via id-IN subquery) Co-Authored-By: Claude Opus 4.8 --- src/servers/api/email/email-db.ts | 49 ++++++++++++++++--------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/servers/api/email/email-db.ts b/src/servers/api/email/email-db.ts index a0337182..0170aa5d 100644 --- a/src/servers/api/email/email-db.ts +++ b/src/servers/api/email/email-db.ts @@ -171,9 +171,11 @@ const parseSearchDate = (v: string): string | null => { return Number.isNaN(d.getTime()) ? null : d.toISOString(); }; -type ParsedQuery = { fts: string; where: string[]; params: string[] }; +// One AND-group of terms (a single OR branch). `fts` is the FTS5 MATCH expression for this branch; +// `where`/`params` are its structured SQL filters. +type Branch = { fts: string; where: string[]; params: string[] }; -function parseEmailQuery(q: string): ParsedQuery { +function parseBranch(q: string): Branch { const fts: string[] = []; const where: string[] = []; const params: string[] = []; @@ -222,30 +224,31 @@ function parseEmailQuery(q: string): ParsedQuery { } export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record[]; total: number } { - const { fts, where, params } = parseEmailQuery(q); - if (!fts && where.length === 0) return { rows: [], total: 0 }; + // Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word). + const branches = q + .split(/\s+OR\s+/) + .map(parseBranch) + .filter((b) => b.fts || b.where.length > 0); + if (branches.length === 0) return { rows: [], total: 0 }; - const filterSql = ['e.deleted = 0', ...where].join(' AND '); - - // Full-text present → join the FTS index; structured-filters-only → query `emails` directly. - if (fts) { - const rows = db - .query( - `SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id - WHERE emails_fts MATCH ? AND ${filterSql} - ORDER BY e.date DESC LIMIT ? OFFSET ?`, - ) - .all(fts, ...params, limit, offset) as Record[]; - const total = ( - db.query(`SELECT count(*) AS c FROM emails_fts JOIN emails e ON e.id = emails_fts.id WHERE emails_fts MATCH ? AND ${filterSql}`).get(fts, ...params) as { - c: number; - } - ).c; - return { rows, total }; + // Each branch becomes one self-contained condition: its FTS terms via an `id IN (FTS subquery)` so + // full-text and structured filters share a WHERE and branches can be OR'd. All ANDed within a branch. + const conds: string[] = []; + const params: string[] = []; + for (const b of branches) { + const parts: string[] = []; + if (b.fts) { + parts.push('e.id IN (SELECT emails_fts.id FROM emails_fts WHERE emails_fts MATCH ?)'); + params.push(b.fts); + } + parts.push(...b.where); + params.push(...b.params); + conds.push(`(${parts.join(' AND ')})`); } - const rows = db.query(`SELECT e.* FROM emails e WHERE ${filterSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record[]; - const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${filterSql}`).get(...params) as { c: number }).c; + const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`; + const rows = db.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record[]; + const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c; return { rows, total }; }