email: Gmail-style search operators (from:/to:/subject:/body:/has:/is:/label:/before:/after:)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 18:23:53 +00:00
co-authored by Claude Opus 4.8
parent 066531555b
commit 1791e2e6a1
2 changed files with 90 additions and 20 deletions
@@ -274,7 +274,7 @@ export const EmailList = () => {
<input
value={search}
onChange={(ev) => 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 && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>}
+89 -19
View File
@@ -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<string, string> = { 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<string, unknown>[]; 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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${filterSql}`).get(...params) as { c: number }).c;
return { rows, total };
}