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:
2026-07-23 23:00:08 +02:00
co-authored by Claude Opus 4.8
parent b4e07f11aa
commit 49738740c0
37 changed files with 1526 additions and 108 deletions
+21 -2
View File
@@ -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)
)