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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user