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
+19 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime
from datetime import date, datetime
from typing import Protocol
@@ -71,9 +71,25 @@ class MailProvider(Protocol):
async def list_mailboxes(self) -> list[RemoteMailbox]: ...
def backfill(
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
self,
mailbox: str,
*,
newest_first: bool = True,
batch_size: int = 200,
since: date | None = None,
before: date | None = None,
) -> AsyncIterator[list[RemoteMessage]]:
"""Yields batches until the mailbox is exhausted. Headers+body in one pass."""
"""Yields batches until the mailbox is exhausted. Headers+body in one pass.
``since``/``before`` bound the import by internal date (SPEC §12 M2)."""
...
async def message_count(self, mailbox: str, *, since: date | None = None) -> int:
"""Number of messages to import (progress denominator); whole mailbox or
the ``since`` window."""
...
async def newest_uid(self, mailbox: str) -> int:
"""Current high-water UID; used by the ``new`` window to skip history."""
...
async def poll(
+43 -2
View File
@@ -13,6 +13,7 @@ import contextlib
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import date
import aiosmtplib
from aioimaplib import aioimaplib
@@ -42,6 +43,21 @@ _CONNECT_TIMEOUT = 20
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)")
_COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)")
_IMAP_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def _imap_date(d: date) -> str:
"""RFC 3501 search date, e.g. ``5-Jul-2026`` (locale-independent)."""
return f"{d.day}-{_IMAP_MONTHS[d.month - 1]}-{d.year}"
def _date_criteria(since: date | None, before: date | None) -> str:
parts: list[str] = []
if since is not None:
parts.append(f"SINCE {_imap_date(since)}")
if before is not None:
parts.append(f"BEFORE {_imap_date(before)}")
return " ".join(parts) if parts else "ALL"
@dataclass(frozen=True)
@@ -184,12 +200,21 @@ class ImapSmtpProvider(MailProvider):
return 0
async def backfill(
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
self,
mailbox: str,
*,
newest_first: bool = True,
batch_size: int = 200,
since: date | None = None,
before: date | None = None,
) -> AsyncIterator[list[RemoteMessage]]:
"""Yield message batches. ``since``/``before`` bound the import by IMAP
internal date (``SINCE`` inclusive, ``BEFORE`` exclusive) so the caller
can sync a recent window and/or the older remainder (SPEC §12 M2)."""
info = await self._select(mailbox)
self._uidvalidity_seen[mailbox] = info.get("uidvalidity", 0)
imap = await self._connect()
search = await imap.uid_search("ALL")
search = await imap.uid_search(_date_criteria(since, before))
uids = parse_search_uids(search.lines)
uids.sort(reverse=newest_first)
for start in range(0, len(uids), batch_size):
@@ -198,6 +223,22 @@ class ImapSmtpProvider(MailProvider):
if messages:
yield messages
async def message_count(self, mailbox: str, *, since: date | None = None) -> int:
"""Number of messages to import for the progress denominator. Uses the
cheap ``EXISTS`` from SELECT for the whole mailbox, or a dated SEARCH."""
info = await self._select(mailbox)
if since is None:
return int(info.get("exists", 0))
imap = await self._connect()
search = await imap.uid_search(_date_criteria(since, None))
return len(parse_search_uids(search.lines))
async def newest_uid(self, mailbox: str) -> int:
"""Current high-water UID (``UIDNEXT - 1``); 0 for an empty mailbox. Used
by the ``new`` sync window to skip history and only pull future mail."""
info = await self._select(mailbox)
return max(0, int(info.get("uidnext", 1)) - 1)
async def list_uids(self, mailbox: str) -> set[int]:
"""Full set of UIDs currently present in the mailbox (expunge reconciliation).