Skip to content

Commit 4abd208

Browse files
feat(search): msgvault-grade search — weighted FTS, body backfill, pgvector hybrid semantic search, MCP server
Port msgvault's proven search architecture into Mailflow, end to end: - Weighted lexical ranking: subject > sender > body (10:4:1 setweight A-D + ts_rank_cd), prefix matching as you type, NULL-safe ordering during backfill, served by a GIN index on a new trigger-maintained search_fts column filled by a resumable drainer (fast-DDL migrations only — no boot-blocking table rewrite). Free-text ranks by relevance; filter-only queries stay date-ordered. Stopword-safe term matching; quoted phrases. - Body materialization: provider-gated background IMAP drainer fills body_text (apple/yahoo/generic on; Gmail/PurelyMail/Microsoft off per their throttle history) with circuit breakers, snippet-indexer sequencing, poison-message forward progress, and background_jobs progress rows. - Embeddings (msgvault port): any OpenAI-compatible /v1/embeddings endpoint via AI settings (encrypted key, host-validated, masked reads); fingerprinted generations (config change => clean rebuild, generations never mix); crash-safe scan-and-fill worker with CAS stamps, 24h backstop, poison downshift, single-flight lock, activation-on-coverage, and config-fingerprint guards; HNSW per-dimension partial indexes; a content-change trigger re-embeds late-arriving bodies. Admin settings UI with explicit opt-in privacy copy, Test/Build buttons with live progress, and local-embeddings guidance — all in 7 locales. Stock Postgres still boots clean with semantic disabled. - Hybrid search: single-query BM25∪ANN reciprocal-rank fusion behind an explicit in-input Semantic toggle (lexical stays the default; silent lexical fallback while the index builds). Subject boost gated on a live BM25 leg; folder scope, negation, and pagination applied to all modes; hnsw.ef_search sized for the fused candidate pool. Eval-verified on a real 18k-message mailbox (paraphrase Recall@20: lexical 0% -> hybrid 76%; keyword MRR 0.43, best of all modes). - MCP: bearer-tokened Streamable-HTTP /mcp endpoint (SHA-256-hashed tokens minted in Profile, Origin-validated, per-token rate-limited) exposing 12 msgvault-parity tools incl. search_in_message (keyword + vector) and semantic_search_messages; every call scoped to the token owner's accounts; staged-only soft deletion with a no-enforceable-filter guard. Deployment: compose ships pgvector/pgvector:pg16 (same PG16 major — the data dir mounts as-is). Run REINDEX DATABASE after the first boot on the new image: the alpine(musl)->Debian(glibc) collation change can misorder existing text btree indexes (the app logs a loud warning when it detects this). Migrations 0035-0041 are fast-DDL, crash-idempotent, boot-safe. The legacy search_vector column survives until a follow-up removes it after the search_fts backfill completes on real installs. Verified: backend 1111 unit tests + gated pgvector integration suites; frontend 1402 tests, lint/build clean; deployed and exercised against a real 18k-message mailbox (search, embeddings build ~27 msg/s, hybrid, MCP round-trips + Origin/rate-limit guards); independent per-phase review gates plus a full-diff Codex audit and three msgvault-parity/correctness audits — all findings fixed pre-merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5aa9578 commit 4abd208

134 files changed

Lines changed: 14729 additions & 459 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ Thumbs.db
3131

3232
.superpowers/
3333
output/
34+
35+
# Search-eval query cache — paraphrases of real mail; the harness regenerates it

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ docker compose up -d
186186

187187
To pin to a specific version instead of `latest`, add `MAILFLOW_VERSION=1.9.0` to your `.env`.
188188

189+
> **Upgrading an existing install across the Postgres image change** (`postgres:16-alpine``pgvector/pgvector:pg16`): the new image uses a different C library (musl → glibc), which changes text collation order. Reindex once after the switch or text indexes can silently return wrong results — the backend also prints this warning at startup when it detects the mismatch:
190+
>
191+
> ```bash
192+
> docker compose exec postgres psql -U mailflow -d mailflow \
193+
> -c 'REINDEX DATABASE "mailflow";' \
194+
> -c 'ALTER DATABASE "mailflow" REFRESH COLLATION VERSION;'
195+
> ```
196+
189197
---
190198
191199
## Option B — Build from source
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- Weighted full-text search column (search_fts) + version stamp, kept fresh by
2+
-- a BEFORE trigger. Fast, metadata-only DDL only (README D1): the nullable
3+
-- columns force no table rewrite on PG16, and the function/trigger are instant.
4+
-- Pre-existing rows are populated by the resumable drainer (ftsBackfill.js);
5+
-- the GIN index is built CONCURRENTLY in the separate no-transaction migration
6+
-- 0037 (a $$-quoted plpgsql body cannot survive the no-transaction ; splitter,
7+
-- so the trigger and the CONCURRENTLY index MUST live in different files).
8+
--
9+
-- The setweight(...) expression MUST stay identical to
10+
-- lexicalRepo.searchFtsExpr('NEW'); fts_version = 1 matches lexicalRepo.FTS_VERSION.
11+
-- Idempotent (IF NOT EXISTS / CREATE OR REPLACE / DROP IF EXISTS) because a
12+
-- crash before the schema_migrations INSERT retries this migration.
13+
14+
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_fts tsvector;
15+
ALTER TABLE messages ADD COLUMN IF NOT EXISTS fts_version int;
16+
17+
CREATE OR REPLACE FUNCTION messages_search_fts_refresh() RETURNS trigger AS $$
18+
BEGIN
19+
-- Skip recompute on an UPDATE that changes none of the indexed source
20+
-- columns (read/star flag flips, snippet-only writes), so the trigger does
21+
-- not tax hot sync UPSERTs; this also lets the backfill's explicit SET win.
22+
IF TG_OP = 'UPDATE'
23+
AND NEW.subject IS NOT DISTINCT FROM OLD.subject
24+
AND NEW.from_name IS NOT DISTINCT FROM OLD.from_name
25+
AND NEW.from_email IS NOT DISTINCT FROM OLD.from_email
26+
AND NEW.to_addresses IS NOT DISTINCT FROM OLD.to_addresses
27+
AND NEW.cc_addresses IS NOT DISTINCT FROM OLD.cc_addresses
28+
AND NEW.body_text IS NOT DISTINCT FROM OLD.body_text
29+
THEN
30+
RETURN NEW;
31+
END IF;
32+
33+
BEGIN
34+
NEW.search_fts := setweight(to_tsvector('english', coalesce(NEW.subject,'')), 'A') ||
35+
setweight(to_tsvector('english', coalesce(NEW.from_name,'') || ' ' || coalesce(NEW.from_email,'')), 'B') ||
36+
setweight(to_tsvector('english', coalesce(NEW.to_addresses::text,'') || ' ' || coalesce(NEW.cc_addresses::text,'')), 'C') ||
37+
setweight(to_tsvector('english', LEFT(coalesce(NEW.body_text,''), 600000)), 'D');
38+
NEW.fts_version := 1;
39+
EXCEPTION WHEN program_limit_exceeded THEN
40+
-- Even with the 600k LEFT cap, a pathologically dense/multibyte body can
41+
-- exceed Postgres's ~1MB tsvector limit (SQLSTATE 54000). Never fail the
42+
-- row write: leave search_fts NULL so the message still persists and stays
43+
-- findable via the ILIKE fallback; the backfill's row-by-row skip stamps it.
44+
NEW.search_fts := NULL;
45+
NEW.fts_version := NULL;
46+
END;
47+
48+
RETURN NEW;
49+
END;
50+
$$ LANGUAGE plpgsql;
51+
52+
DROP TRIGGER IF EXISTS trg_messages_search_fts ON messages;
53+
CREATE TRIGGER trg_messages_search_fts
54+
BEFORE INSERT OR UPDATE ON messages
55+
FOR EACH ROW EXECUTE FUNCTION messages_search_fts_refresh();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Generic progress substrate for observable background drainers (first consumer:
2+
-- the FTS backfill). Plain table, fast DDL. One row per (kind, account); global
3+
-- jobs use a NULL account_id, COALESCE'd to '' in the unique index so the upsert
4+
-- has a single conflict target for both cases.
5+
6+
CREATE TABLE IF NOT EXISTS background_jobs (
7+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
8+
kind VARCHAR(64) NOT NULL,
9+
account_id UUID REFERENCES email_accounts(id) ON DELETE CASCADE,
10+
state VARCHAR(20) NOT NULL DEFAULT 'idle',
11+
processed BIGINT NOT NULL DEFAULT 0,
12+
total BIGINT NOT NULL DEFAULT 0,
13+
last_error TEXT,
14+
started_at TIMESTAMPTZ,
15+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
16+
);
17+
18+
CREATE UNIQUE INDEX IF NOT EXISTS ux_background_jobs_kind_account
19+
ON background_jobs (kind, COALESCE(account_id::text, ''));
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- no-transaction
2+
--
3+
-- GIN index that serves `search_fts @@ tsquery`, plus a partial btree that lets
4+
-- the backfill drainer (and any "needs backfill" probe) find not-yet-stamped
5+
-- rows without a full seq scan; it self-prunes as fts_version = 1 fills in.
6+
-- CONCURRENTLY must run outside a transaction, so these live apart from 0035's
7+
-- trigger. Idempotent via IF NOT EXISTS (retried if a crash precedes the
8+
-- schema_migrations INSERT).
9+
--
10+
-- DROP ... IF EXISTS before each CREATE: a cancelled or crashed CREATE INDEX
11+
-- CONCURRENTLY leaves an INVALID index under the target name. On retry, plain
12+
-- IF NOT EXISTS sees that name and silently skips the create, recording the
13+
-- migration as done while the scan stays unindexed forever. This file only re-runs
14+
-- after such a failure — in which case the index is either absent (drop is a no-op)
15+
-- or invalid (drop removes the dead stub) — so dropping first is safe and cheap.
16+
17+
DROP INDEX CONCURRENTLY IF EXISTS idx_messages_search_fts;
18+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_search_fts
19+
ON messages USING GIN (search_fts);
20+
21+
DROP INDEX CONCURRENTLY IF EXISTS idx_messages_fts_stale_v1;
22+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_fts_stale_v1
23+
ON messages (date DESC)
24+
WHERE fts_version IS DISTINCT FROM 1;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- Vector-substrate CAS columns + last_modified trigger (slice 04).
2+
-- Extension-independent, fast DDL only. NO vector-typed DDL here — the
3+
-- embeddings/index_generations/embed_watermark/embed_runs tables and the HNSW
4+
-- index are created by ensureVectorSchema() at startup (README invariant).
5+
-- Transactional migration (NOT -- no-transaction): the no-transaction runner
6+
-- splits on ';' and would break the dollar-quoted function below.
7+
8+
-- last_modified: content-change CAS token the embed worker compares to detect a
9+
-- late-arriving body invalidating a stale subject-only embedding. NOT NULL DEFAULT
10+
-- now() is metadata-only on PG16 (now() is STABLE → one stored missing-value, no
11+
-- table rewrite).
12+
ALTER TABLE messages ADD COLUMN IF NOT EXISTS last_modified TIMESTAMPTZ NOT NULL DEFAULT now();
13+
14+
-- embed_gen: the index generation this row is embedded under. NULL = needs embedding.
15+
-- Plain BIGINT soft-stamp (no FK): a generation can be retired/deleted while stamps linger.
16+
ALTER TABLE messages ADD COLUMN IF NOT EXISTS embed_gen BIGINT;
17+
18+
-- Bump last_modified AND clear embed_gen when an embedding-input column changes. The
19+
-- WHEN clause filters at the C level so unchanged-content UPDATEs (the hot re-sync path)
20+
-- and stamp-only UPDATEs (the worker setting embed_gen) skip the function entirely.
21+
-- Clearing embed_gen is what re-surfaces a late-arriving body: a row embedded
22+
-- subject-only and stamped, whose body later lands (phase-2 drainer / on-open fetch),
23+
-- has its stamp cleared here so the NULL-only embed scan re-finds it and the idempotent
24+
-- upsert replaces the stale chunks. The CAS only guards the read→stamp window; this
25+
-- trigger covers the post-stamp case (Mailflow's late-arriving bodies — msgvault's rows
26+
-- are immutable after ingest, so it never needed this). Together with createGeneration's
27+
-- stamp-reset (which handles generation rebuilds: unchanged content, new fingerprint),
28+
-- the invariant is exact: embed_gen IS NULL ⟺ the row needs embedding.
29+
CREATE OR REPLACE FUNCTION messages_bump_last_modified() RETURNS trigger AS $$
30+
BEGIN
31+
NEW.last_modified := now();
32+
NEW.embed_gen := NULL;
33+
RETURN NEW;
34+
END;
35+
$$ LANGUAGE plpgsql;
36+
37+
DROP TRIGGER IF EXISTS trg_messages_last_modified ON messages;
38+
CREATE TRIGGER trg_messages_last_modified
39+
BEFORE UPDATE ON messages
40+
FOR EACH ROW
41+
WHEN (NEW.subject IS DISTINCT FROM OLD.subject
42+
OR NEW.body_text IS DISTINCT FROM OLD.body_text
43+
OR NEW.body_html IS DISTINCT FROM OLD.body_html)
44+
EXECUTE FUNCTION messages_bump_last_modified();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- no-transaction
2+
-- Partial index over the embed-scan's steady-state hot predicate. Once a generation
3+
-- reaches full coverage, the only live rows still needing work are newly-arrived
4+
-- messages with embed_gen IS NULL, so the 60s scheduler scan (scanForEmbedding) should
5+
-- touch O(pending), not O(mailbox). Built CONCURRENTLY (hence -- no-transaction) so it
6+
-- never blocks boot on a large messages table; extension-independent and cheap to
7+
-- maintain (only the sparse NULL set is indexed). Idempotent (IF NOT EXISTS) because a
8+
-- crash before the schema_migrations INSERT retries the migration.
9+
--
10+
-- DROP ... IF EXISTS before the CREATE: a cancelled or crashed CREATE INDEX
11+
-- CONCURRENTLY leaves an INVALID index under this name, and plain IF NOT EXISTS would
12+
-- then silently skip the create on retry — recording the migration as done while the
13+
-- embed scan stays unindexed forever. This file only re-runs after such a failure, so
14+
-- the index is either absent (drop is a no-op) or invalid (drop clears the dead stub).
15+
DROP INDEX CONCURRENTLY IF EXISTS idx_messages_embed_pending;
16+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_embed_pending
17+
ON messages (id) WHERE embed_gen IS NULL;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- MCP API tokens. We store only a SHA-256 hash of the token; the plaintext is
2+
-- shown to the operator exactly once at mint time and is never recoverable.
3+
CREATE TABLE IF NOT EXISTS api_tokens (
4+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+
token_hash TEXT NOT NULL UNIQUE,
7+
name TEXT NOT NULL,
8+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9+
last_used_at TIMESTAMPTZ
10+
);
11+
12+
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Staged (not executed) MCP deletions. stage_deletion records a batch; a separate
2+
-- session-authenticated execute step flips messages.is_deleted (soft delete). No
3+
-- tool ever hard-deletes. Renumbered to 0041 (0040 is api_tokens) per the README
4+
-- migration-numbering rule.
5+
CREATE TABLE IF NOT EXISTS mcp_deletion_batches (
6+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+
description TEXT NOT NULL DEFAULT '',
9+
status TEXT NOT NULL DEFAULT 'staged',
10+
message_count INTEGER NOT NULL DEFAULT 0,
11+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12+
executed_at TIMESTAMPTZ
13+
);
14+
15+
CREATE TABLE IF NOT EXISTS mcp_deletion_batch_messages (
16+
batch_id UUID NOT NULL REFERENCES mcp_deletion_batches(id) ON DELETE CASCADE,
17+
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
18+
PRIMARY KEY (batch_id, message_id)
19+
);
20+
21+
CREATE INDEX IF NOT EXISTS idx_mcp_deletion_batches_user_id ON mcp_deletion_batches(user_id);

0 commit comments

Comments
 (0)