diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index 5169f5e0..ddc83f64 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -274,7 +274,7 @@ export const EmailList = () => { setSearch(ev.target.value)} - placeholder="Search mail…" + placeholder="Search — try from:, subject:, has:attachment…" className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40" /> {isSearching && {total} result{total === 1 ? '' : 's'}} diff --git a/src/servers/api/email/email-db.ts b/src/servers/api/email/email-db.ts index 2cf0362d..a0337182 100644 --- a/src/servers/api/email/email-db.ts +++ b/src/servers/api/email/email-db.ts @@ -154,28 +154,98 @@ function syncFtsRow(db: Database, id: string, subject: string, sender: string, r db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]); } -// Per-term prefix match, ANDed. Each term is quoted (so FTS operators in user input are literal) then -// suffixed with * for prefix matching. -function toFtsQuery(q: string): string { - const terms = q.trim().split(/\s+/).filter(Boolean); - return terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(' '); +// Gmail-style query parsing. Free text → full-text (prefix-AND). Operators: +// from:/to:/subject:/body: → FTS5 column filters +// has:attachment, is:unread/read, label:X, before:/after:YYYY-MM-DD → SQL filters on `emails` +// Unknown operators fall back to free text. Quoted values (from:"a b") match as an exact phrase. +const FTS_COLUMNS: Record = { from: 'sender', to: 'recipients', subject: 'subject', body: 'body' }; + +const ftsTerm = (value: string, column: string | null, prefix: boolean): string => { + const esc = value.replace(/"/g, '""'); + return `${column ? column + ':' : ''}"${esc}"${prefix ? '*' : ''}`; +}; + +const parseSearchDate = (v: string): string | null => { + const iso = /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +}; + +type ParsedQuery = { fts: string; where: string[]; params: string[] }; + +function parseEmailQuery(q: string): ParsedQuery { + const fts: string[] = []; + const where: string[] = []; + const params: string[] = []; + const re = /(\w+):("[^"]*"|\S+)|"([^"]*)"|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(q)) !== null) { + if (m[1]) { + const op = m[1].toLowerCase(); + const raw = m[2]!; + const quoted = raw.startsWith('"') && raw.endsWith('"'); + const val = quoted ? raw.slice(1, -1) : raw; + if (!val) continue; + const lower = val.toLowerCase(); + if (FTS_COLUMNS[op]) { + fts.push(ftsTerm(val, FTS_COLUMNS[op], !quoted)); + } else if (op === 'label') { + where.push('e.labels LIKE ?'); + params.push(`%${lower}%`); + } else if (op === 'has' && (lower === 'attachment' || lower === 'attachments')) { + where.push('e.attachment_count > 0'); + } else if (op === 'is' && (lower === 'unread' || lower === 'read')) { + where.push(lower === 'unread' ? 'e.read = 0' : 'e.read = 1'); + } else if (op === 'before' || op === 'older') { + const d = parseSearchDate(val); + if (d) { + where.push('e.date < ?'); + params.push(d); + } + } else if (op === 'after' || op === 'newer') { + const d = parseSearchDate(val); + if (d) { + where.push('e.date >= ?'); + params.push(d); + } + } else { + // Unknown operator — treat the whole "op:val" token as free text. + fts.push(ftsTerm(`${op}:${val}`, null, !quoted)); + } + } else if (m[3] !== undefined) { + if (m[3].trim()) fts.push(ftsTerm(m[3], null, false)); // quoted phrase → exact + } else if (m[4]) { + fts.push(ftsTerm(m[4], null, true)); // bare word → prefix + } + } + return { fts: fts.join(' '), where, params }; } export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record[]; total: number } { - const match = toFtsQuery(q); - if (!match) return { rows: [], total: 0 }; - const rows = db - .query( - `SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id - WHERE emails_fts MATCH ? AND e.deleted = 0 - ORDER BY e.date DESC LIMIT ? OFFSET ?`, - ) - .all(match, 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 e.deleted = 0').get(match) as { - c: number; - } - ).c; + const { fts, where, params } = parseEmailQuery(q); + if (!fts && where.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 }; + } + + 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; return { rows, total }; }