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:
@@ -274,7 +274,7 @@ export const EmailList = () => {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
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"
|
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>}
|
{isSearching && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>}
|
||||||
|
|||||||
@@ -154,28 +154,98 @@ function syncFtsRow(db: Database, id: string, subject: string, sender: string, r
|
|||||||
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
|
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
|
// Gmail-style query parsing. Free text → full-text (prefix-AND). Operators:
|
||||||
// suffixed with * for prefix matching.
|
// from:/to:/subject:/body: → FTS5 column filters
|
||||||
function toFtsQuery(q: string): string {
|
// has:attachment, is:unread/read, label:X, before:/after:YYYY-MM-DD → SQL filters on `emails`
|
||||||
const terms = q.trim().split(/\s+/).filter(Boolean);
|
// Unknown operators fall back to free text. Quoted values (from:"a b") match as an exact phrase.
|
||||||
return terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(' ');
|
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 } {
|
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record<string, unknown>[]; total: number } {
|
||||||
const match = toFtsQuery(q);
|
const { fts, where, params } = parseEmailQuery(q);
|
||||||
if (!match) return { rows: [], total: 0 };
|
if (!fts && where.length === 0) return { rows: [], total: 0 };
|
||||||
const rows = db
|
|
||||||
.query(
|
const filterSql = ['e.deleted = 0', ...where].join(' AND ');
|
||||||
`SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id
|
|
||||||
WHERE emails_fts MATCH ? AND e.deleted = 0
|
// Full-text present → join the FTS index; structured-filters-only → query `emails` directly.
|
||||||
ORDER BY e.date DESC LIMIT ? OFFSET ?`,
|
if (fts) {
|
||||||
)
|
const rows = db
|
||||||
.all(match, limit, offset) as Record<string, unknown>[];
|
.query(
|
||||||
const total = (
|
`SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id
|
||||||
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 {
|
WHERE emails_fts MATCH ? AND ${filterSql}
|
||||||
c: number;
|
ORDER BY e.date DESC LIMIT ? OFFSET ?`,
|
||||||
}
|
)
|
||||||
).c;
|
.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 };
|
return { rows, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user