M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -10,12 +10,14 @@ _backend: SearchBackend | None = None
|
||||
|
||||
|
||||
def get_search_backend() -> SearchBackend:
|
||||
"""Return the process-wide SearchBackend (``pg`` in M0-M1, ``meili`` in M2)."""
|
||||
"""Return the process-wide SearchBackend (``pg`` or ``meili`` per SEARCH_BACKEND)."""
|
||||
global _backend
|
||||
if _backend is None:
|
||||
which = get_settings().search_backend
|
||||
if which == "meili":
|
||||
# TODO(spec): Meilisearch backend lands in M2.
|
||||
raise NotImplementedError("meili backend is implemented in M2")
|
||||
_backend = PgSearchBackend()
|
||||
from app.search.meili import MeiliSearchBackend
|
||||
|
||||
_backend = MeiliSearchBackend()
|
||||
else:
|
||||
_backend = PgSearchBackend()
|
||||
return _backend
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# INTERFACE VERSION: 1
|
||||
"""Meilisearch SearchBackend (SPEC §5.2, §8; M2).
|
||||
|
||||
Typo-tolerant free-text search with highlighted snippets over a single ``messages``
|
||||
index. Every query is filtered by ``user_id`` (invariant I-7). Structured §8.2
|
||||
operators that map to filterable attributes — ``is`` / ``has`` / ``in`` / date /
|
||||
size — become Meilisearch filters; the field operators (``from`` / ``to`` / ``cc``
|
||||
/ ``subject`` / ``filename``) fold into the query text, since Meilisearch 1.8 has
|
||||
no substring filter (the PG backend remains available for exact operator queries).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime as dt
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from meilisearch_python_sdk import AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
|
||||
|
||||
_INDEX = "messages"
|
||||
|
||||
# Priority order matters: subject/senders rank above the body.
|
||||
_SEARCHABLE = [
|
||||
"subject",
|
||||
"from_names",
|
||||
"from_emails",
|
||||
"to_names",
|
||||
"to_emails",
|
||||
"cc_names",
|
||||
"cc_emails",
|
||||
"body",
|
||||
"attachment_names",
|
||||
]
|
||||
_FILTERABLE = [
|
||||
"user_id",
|
||||
"account_id",
|
||||
"special",
|
||||
"has_attachments",
|
||||
"is_unread",
|
||||
"is_starred",
|
||||
"date",
|
||||
"size_bytes",
|
||||
]
|
||||
_TEXT_FIELD_OPS = {"from", "to", "cc", "subject", "filename"}
|
||||
_RELATIVE_DAYS = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
def _emails(field: list[dict[str, str]]) -> list[str]:
|
||||
return [(a.get("email") or "").lower() for a in field if a.get("email")]
|
||||
|
||||
|
||||
def _names(field: list[dict[str, str]]) -> list[str]:
|
||||
return [a.get("name") or "" for a in field if a.get("name")]
|
||||
|
||||
|
||||
def _to_doc(doc: SearchDoc) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(doc.message_id),
|
||||
"thread_id": str(doc.thread_id),
|
||||
"user_id": str(doc.user_id),
|
||||
"account_id": str(doc.account_id),
|
||||
"special": doc.mailbox_special_use or "",
|
||||
"subject": doc.subject,
|
||||
"body": doc.body_text,
|
||||
"from_names": _names(doc.from_),
|
||||
"from_emails": _emails(doc.from_),
|
||||
"to_names": _names(doc.to),
|
||||
"to_emails": _emails(doc.to),
|
||||
"cc_names": _names(doc.cc),
|
||||
"cc_emails": _emails(doc.cc),
|
||||
"attachment_names": doc.attachment_names,
|
||||
"has_attachments": doc.has_attachments,
|
||||
"is_unread": doc.is_unread,
|
||||
"is_starred": doc.is_starred,
|
||||
"date": doc.date,
|
||||
"size_bytes": doc.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _parse_date_unix(value: str) -> int | None:
|
||||
try:
|
||||
d = dt.datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=dt.UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
return int(d.timestamp())
|
||||
|
||||
|
||||
def _parse_relative_days(value: str) -> int | None:
|
||||
m = re.fullmatch(r"(\d+)([dwmy])", value.strip().lower())
|
||||
return int(m.group(1)) * _RELATIVE_DAYS[m.group(2)] if m else None
|
||||
|
||||
|
||||
def _parse_size(value: str) -> int | None:
|
||||
m = re.fullmatch(r"(\d+)([km])", value.strip().lower())
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)) * (1024 if m.group(2) == "k" else 1024 * 1024)
|
||||
|
||||
|
||||
def _op_filter(key: str, value: str) -> str | None:
|
||||
"""A single §8.2 operator -> a Meilisearch filter fragment (or None)."""
|
||||
now = int(dt.datetime.now(dt.UTC).timestamp())
|
||||
if key == "in":
|
||||
return f'special = "{value.lower()}"'
|
||||
if key == "is":
|
||||
v = value.lower()
|
||||
if v == "unread":
|
||||
return "is_unread = true"
|
||||
if v == "read":
|
||||
return "is_unread = false"
|
||||
if v == "starred":
|
||||
return "is_starred = true"
|
||||
return None # 'answered' is not indexed in SearchDoc
|
||||
if key == "has":
|
||||
return "has_attachments = true" if value.lower() == "attachment" else None
|
||||
if key == "before":
|
||||
u = _parse_date_unix(value)
|
||||
return f"date < {u}" if u is not None else None
|
||||
if key == "after":
|
||||
u = _parse_date_unix(value)
|
||||
return f"date >= {u}" if u is not None else None
|
||||
if key == "newer_than":
|
||||
days = _parse_relative_days(value)
|
||||
return f"date >= {now - days * 86400}" if days is not None else None
|
||||
if key == "older_than":
|
||||
days = _parse_relative_days(value)
|
||||
return f"date < {now - days * 86400}" if days is not None else None
|
||||
if key == "larger":
|
||||
n = _parse_size(value)
|
||||
return f"size_bytes > {n}" if n is not None else None
|
||||
if key == "smaller":
|
||||
n = _parse_size(value)
|
||||
return f"size_bytes < {n}" if n is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _build_filter(groups: list[list[dict[str, Any]]], user_id: UUID) -> str:
|
||||
ands = [f'user_id = "{user_id}"']
|
||||
for group in groups:
|
||||
ors: list[str] = []
|
||||
for clause in group:
|
||||
if clause.get("type") == "text":
|
||||
continue
|
||||
frag = _op_filter(str(clause.get("key")), str(clause.get("value", "")))
|
||||
if frag is None:
|
||||
continue
|
||||
ors.append(f"NOT ({frag})" if clause.get("negate") else frag)
|
||||
if ors:
|
||||
ands.append("(" + " OR ".join(ors) + ")")
|
||||
return " AND ".join(ands)
|
||||
|
||||
|
||||
def _query_text(groups: list[list[dict[str, Any]]]) -> str:
|
||||
"""Free text for Meili's `q`: text terms (negated as -term) + folded field ops."""
|
||||
terms: list[str] = []
|
||||
for group in groups:
|
||||
for clause in group:
|
||||
value = str(clause.get("value", "")).strip()
|
||||
if not value:
|
||||
continue
|
||||
if clause.get("type") == "text":
|
||||
terms.append(f"-{value}" if clause.get("negate") else value)
|
||||
elif clause.get("key") in _TEXT_FIELD_OPS and not clause.get("negate"):
|
||||
terms.append(value)
|
||||
return " ".join(terms).strip()
|
||||
|
||||
|
||||
class MeiliSearchBackend(SearchBackend):
|
||||
def __init__(self) -> None:
|
||||
cfg = get_settings()
|
||||
self._client = AsyncClient(cfg.meili_url, cfg.meili_key or None)
|
||||
self._index = self._client.index(_INDEX)
|
||||
self._ready = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
"""Create the index and apply settings once (idempotent)."""
|
||||
if self._ready:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._ready:
|
||||
return
|
||||
indexes = await self._client.get_indexes()
|
||||
names = {i.uid for i in indexes} if indexes else set()
|
||||
if _INDEX not in names:
|
||||
await self._client.create_index(_INDEX, primary_key="id")
|
||||
await self._index.update_searchable_attributes(_SEARCHABLE)
|
||||
await self._index.update_sortable_attributes(["date"])
|
||||
task = await self._index.update_filterable_attributes(_FILTERABLE)
|
||||
await self._client.wait_for_task(task.task_uid)
|
||||
self._ready = True
|
||||
|
||||
async def index(self, doc: SearchDoc) -> None:
|
||||
await self._ensure()
|
||||
await self._index.add_documents([_to_doc(doc)])
|
||||
|
||||
async def delete(self, message_id: UUID) -> None:
|
||||
await self._ensure()
|
||||
await self._index.delete_document(str(message_id))
|
||||
|
||||
async def delete_user(self, user_id: UUID) -> None:
|
||||
await self._ensure()
|
||||
await self._index.delete_documents_by_filter(f'user_id = "{user_id}"')
|
||||
|
||||
async def query(
|
||||
self, q: SearchQuery, *, limit: int, cursor: str | None
|
||||
) -> tuple[list[SearchHit], str | None]:
|
||||
await self._ensure()
|
||||
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
|
||||
assert isinstance(groups, list)
|
||||
offset = _decode_cursor(cursor)
|
||||
res = await self._index.search(
|
||||
_query_text(groups),
|
||||
filter=_build_filter(groups, q.user_id),
|
||||
offset=offset,
|
||||
limit=limit + 1,
|
||||
sort=["date:desc"], # stable order for offset paging; typo matching still applies
|
||||
attributes_to_retrieve=["id", "thread_id"],
|
||||
attributes_to_highlight=["subject", "body"],
|
||||
attributes_to_crop=["body"],
|
||||
crop_length=30,
|
||||
highlight_pre_tag="<mark>",
|
||||
highlight_post_tag="</mark>",
|
||||
show_ranking_score=True,
|
||||
)
|
||||
rows = res.hits
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
hits = [_to_hit(row) for row in rows]
|
||||
next_cursor = _encode_cursor(offset + limit) if has_more else None
|
||||
return hits, next_cursor
|
||||
|
||||
|
||||
def _to_hit(row: dict[str, Any]) -> SearchHit:
|
||||
fmt = row.get("_formatted") or {}
|
||||
highlights: dict[str, str] = {}
|
||||
subject = fmt.get("subject") or ""
|
||||
if "<mark>" in subject:
|
||||
highlights["subject"] = subject
|
||||
body = fmt.get("body") or ""
|
||||
if "<mark>" in body:
|
||||
highlights["body"] = body
|
||||
return SearchHit(
|
||||
message_id=UUID(row["id"]),
|
||||
thread_id=UUID(row["thread_id"]),
|
||||
score=float(row.get("_rankingScore") or 0.0),
|
||||
highlights=highlights,
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(offset: int) -> str:
|
||||
return base64.urlsafe_b64encode(str(offset).encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str | None) -> int:
|
||||
if not cursor:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(base64.urlsafe_b64decode(cursor.encode()).decode()))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
@@ -23,6 +23,22 @@ from app.models import Attachment, Mailbox, Message
|
||||
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
|
||||
|
||||
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
|
||||
|
||||
|
||||
def _prefix_tsquery(value: str) -> ColumnElement[object]:
|
||||
"""A prefix ``tsquery`` for search-as-you-type: ``invo`` finds ``invoice``.
|
||||
|
||||
We can't tokenize in Python and match the index: Postgres' parser keeps an
|
||||
email/host/URL as a *single* lexeme (``lukas.remis@gmail.com``), so splitting
|
||||
on word chars would never match it. Instead we re-parse the term with the
|
||||
same ``plainto_tsquery`` used at index time, then append ``:*`` to each
|
||||
lexeme to make it a prefix. ``plainto_tsquery`` strips tsquery operators and
|
||||
quotes, so this is injection- and error-safe on hostile input; an all-symbol
|
||||
term yields the empty query (matches nothing).
|
||||
"""
|
||||
lexemes = cast(func.plainto_tsquery(_TS_CONFIG, value), Text)
|
||||
prefixed = func.regexp_replace(lexemes, r"'([^']+)'", r"'\1':*", "g")
|
||||
return func.to_tsquery(_TS_CONFIG, prefixed)
|
||||
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
@@ -95,7 +111,10 @@ class PgSearchBackend(SearchBackend):
|
||||
def _clause_condition(self, clause: dict[str, object]) -> ColumnElement[bool] | None:
|
||||
value = str(clause.get("value", ""))
|
||||
if clause.get("type") == "text":
|
||||
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
|
||||
if clause.get("phrase"):
|
||||
# Quoted phrase: keep exact-adjacency semantics, no prefix.
|
||||
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
|
||||
return Message.body_indexed.op("@@")(_prefix_tsquery(value))
|
||||
key = clause.get("key")
|
||||
cond = self._filter_condition(str(key), value)
|
||||
return cond
|
||||
@@ -172,7 +191,7 @@ class PgSearchBackend(SearchBackend):
|
||||
conditions = self._build_conditions(groups)
|
||||
|
||||
rank = (
|
||||
func.ts_rank_cd(Message.body_indexed, func.websearch_to_tsquery(_TS_CONFIG, q.text))
|
||||
func.ts_rank_cd(Message.body_indexed, _prefix_tsquery(q.text))
|
||||
if q.text
|
||||
else literal(0.0)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user