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:
@@ -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