Search covered four columns with LIKE and never looked at message bodies.
This adds an FTS5 index over subject, sender, recipients and both bodies,
kept in sync by triggers, and a query grammar that both the search box and
the public API understand.
- Migration 0030 creates `messages_fts` as an external-content table (bodies
are read from `messages`, not stored twice) with insert/update/delete
triggers, and rebuilds it once so existing mail is indexed.
- `src/lib/search/query-utils.ts` parses `from:`, `to:` (covers Cc),
`subject:`, `has:attachment`, `is:unread|read|starred`, `after:`/`before:`,
quoted phrases and `-negation`, and builds a MATCH expression with every
user value quoted so input cannot alter the query. Plain terms match by
prefix. The legacy `title:` and `:unread` forms still work.
- `buildSearchConditions` turns that into a `rowid IN (SELECT rowid FROM
messages_fts WHERE messages_fts MATCH ?)` predicate plus column filters,
used by `/api/messages` and now `/api/v1/messages?q=`.
- The search box is debounced (250 ms) so a typed word costs one request.
- `GET`/`POST /api/admin/search-index` reports index vs table counts and
rebuilds on demand.
- The bootstrap schema splitter now keeps trigger bodies whole, and the
backup coverage check skips the FTS shadow tables (derived data that
repopulates on restore). While there, `mailbox_aliases` was missing from
BACKUP_TABLES, which made every backup fail on main; it is added.
Verified on local D1: all operators return the expected rows against the
seed data, hostile input (`x" OR "y`, `term)(`) is matched literally,
a draft edit and delete update the index immediately, rebuild is a no-op
on a consistent index, and a backup completes with the index present.
tsc (11) and eslint (105) unchanged from main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>