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
+34 -4
View File
@@ -39,6 +39,20 @@ def _recipient_emails(message: Message) -> set[str]:
return emails
def _parent_refs(message: Message) -> list[str]:
"""Non-synthetic parent ids this message explicitly declares (References +
In-Reply-To). A non-empty result means the message states its parentage, so it
must not be subject-merged into an unrelated conversation (§7.3)."""
refs: list[str] = list(message.references_hdr)
if message.in_reply_to:
refs.append(message.in_reply_to)
out: list[str] = []
for r in refs:
if r and not is_synthetic(r) and r not in out:
out.append(r)
return out
def _candidate_ids(message: Message) -> list[str]:
candidates: list[str] = list(message.references_hdr)
if message.in_reply_to:
@@ -59,17 +73,26 @@ async def _find_by_references(
user_id: uuid.UUID,
message_id: uuid.UUID,
candidates: list[str],
parent_refs: list[str],
own_id: str | None,
) -> list[uuid.UUID]:
"""Threads joined by reference (SPEC §7.2.2).
Matches bidirectionally: existing messages whose id is one of my candidate ids
(I reference them / duplicate id), OR existing messages that reference *my* id
(handles a late-arriving parent). Synthetic own-ids are never used to join (§7.1).
(I reference them / duplicate id), existing messages that reference *my* id
(late-arriving parent), OR existing messages that share one of my declared
parents — a sibling reply to the same original, even when neither the parent
nor a mutual reference is present. Synthetic ids are never used to join (§7.1).
"""
conditions: list[ColumnElement[bool]] = []
if candidates:
conditions.append(Message.message_id_hdr.in_(candidates))
if parent_refs:
# Siblings: an existing message that references, or is a reply to, one of
# my parents. This groups replies to a common (possibly absent) original
# without letting the subject fallback merge replies to *different* ones.
conditions.append(Message.references_hdr.overlap(parent_refs))
conditions.append(Message.in_reply_to.in_(parent_refs))
if own_id is not None and not is_synthetic(own_id):
# own_id = ANY(references_hdr): an existing message that references me.
conditions.append(literal(own_id) == sa_any(Message.references_hdr))
@@ -191,10 +214,17 @@ async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
Returns the resulting thread id and sets ``message.thread_id``.
"""
candidates = _candidate_ids(message)
parent_refs = _parent_refs(message)
own_id = message.message_id_hdr
thread_ids = await _find_by_references(session, message.user_id, message.id, candidates, own_id)
thread_ids = await _find_by_references(
session, message.user_id, message.id, candidates, parent_refs, own_id
)
if not thread_ids:
# Subject fallback only for messages that declare no parentage (a broken/absent
# References chain). A message that explicitly replies to a specific parent is
# never subject-merged into an unrelated thread — e.g. two auto-replies to
# different originals that happen to share the same subject (§7.3).
if not thread_ids and not parent_refs:
subject_thread = await _find_by_subject(session, message.user_id, message)
if subject_thread is not None:
thread_ids = [subject_thread]