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
+2
View File
@@ -17,6 +17,7 @@ def register_routers(app: FastAPI) -> None:
from app.api.mailboxes import router as mailboxes_router
from app.api.messages import router as messages_router
from app.api.recipients import router as recipients_router
from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.threads import router as threads_router
@@ -34,6 +35,7 @@ def register_routers(app: FastAPI) -> None:
recipients_router,
settings_router,
mailboxes_router,
saved_searches_router,
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
+19 -1
View File
@@ -82,6 +82,9 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
color=a.color,
state=a.state,
signature=a.signature,
sync_window=a.sync_window,
backfill_total=a.backfill_total,
backfill_done=a.backfill_done,
)
for a in accounts
]
@@ -186,6 +189,18 @@ async def update_account(
if body.signature is not None:
account.signature = body.signature
# Broadening the sync window (e.g. "new" -> "all", "Download all mail") means
# importing history not fetched before, so re-run the backfill (SPEC §12 M2).
# persist_message is idempotent on (mailbox_id, uid), so already-synced mail
# is skipped. Narrowing keeps existing mail — nothing to fetch.
_COVERAGE = {"new": 0, "3months": 1, "all": 2}
broaden = False
if body.sync_window is not None and body.sync_window != account.sync_window:
broaden = _COVERAGE[body.sync_window] > _COVERAGE.get(account.sync_window, 0)
account.sync_window = body.sync_window
if broaden:
account.state = "backfilling"
# Connection settings and/or password: re-validate against the servers, then
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
# stored secret is only decrypted in the worker, never here).
@@ -224,7 +239,7 @@ async def update_account(
await db.commit()
if conn_change:
if conn_change or broaden:
await enqueue_job("sync_bootstrap_account", str(account.id))
return _detail(account)
@@ -248,6 +263,9 @@ def _detail(account: Account) -> AccountDetailOut:
state=account.state,
state_detail=account.state_detail,
signature=account.signature,
sync_window=account.sync_window,
backfill_total=account.backfill_total,
backfill_done=account.backfill_done,
imap_config=account.imap_config,
smtp_config=account.smtp_config,
)
+10 -3
View File
@@ -65,14 +65,21 @@ def _sender_email(message: Message) -> str:
@router.get("/{message_id}/html", response_class=HTMLResponse)
async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse:
async def message_html(
message_id: uuid.UUID,
user: CurrentUser,
db: DbSession,
images: bool = Query(False),
) -> HTMLResponse:
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
Re-sanitizes from the raw blob when the sender is image-approved, or when the
stored copy predates the current sanitizer version (SPEC §10.4).
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1``
is a one-time "Load images" — render remote images for this request only,
without persisting the sender as trusted.
"""
message = await _owned_message(db, user.id, message_id)
allow_remote = _sender_email(message) in allowed_image_senders(user)
allow_remote = images or _sender_email(message) in allowed_image_senders(user)
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
+82
View File
@@ -0,0 +1,82 @@
"""Saved searches — stored queries shown as virtual mailboxes (SPEC §12 M2)."""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import SavedSearch
router = APIRouter(prefix="/saved-searches", tags=["search"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class SavedSearchOut(BaseModel):
id: uuid.UUID
name: str
query: str
pinned: bool
class SavedSearchIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
query: str = Field(min_length=1, max_length=500)
pinned: bool = True
def _out(s: SavedSearch) -> SavedSearchOut:
return SavedSearchOut(id=s.id, name=s.name, query=s.query, pinned=s.pinned)
@router.get("", response_model=list[SavedSearchOut])
async def list_saved_searches(user: CurrentUser, db: DbSession) -> list[SavedSearchOut]:
rows = await db.scalars(
select(SavedSearch).where(SavedSearch.user_id == user.id).order_by(SavedSearch.name)
)
return [_out(s) for s in rows]
@router.post("", response_model=SavedSearchOut)
async def create_saved_search(
body: SavedSearchIn, user: CurrentUser, db: DbSession
) -> SavedSearchOut:
name = body.name.strip()
query = body.query.strip()
if not name or not query:
raise_api_error(400, "invalid", "name and query are required")
# Upsert by (user, name): re-saving under an existing name updates its query.
existing = await db.scalar(
select(SavedSearch).where(SavedSearch.user_id == user.id, SavedSearch.name == name)
)
if existing is not None:
existing.query = query
existing.pinned = body.pinned
saved = existing
else:
saved = SavedSearch(user_id=user.id, name=name, query=query, pinned=body.pinned)
db.add(saved)
await db.commit()
return _out(saved)
@router.delete("/{search_id}")
async def delete_saved_search(
search_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> dict[str, str]:
saved = await db.scalar(
select(SavedSearch).where(SavedSearch.id == search_id, SavedSearch.user_id == user.id)
)
if saved is None:
raise_api_error(404, "not_found", "saved search not found")
await db.delete(saved)
await db.commit()
return {"status": "deleted"}
+6
View File
@@ -29,6 +29,7 @@ class ThreadSummary(BaseModel):
class ThreadListOut(BaseModel):
threads: list[ThreadSummary]
cursor: str | None = None
total: int = 0
class AttachmentOut(BaseModel):
@@ -54,6 +55,8 @@ class MessageEnvelope(BaseModel):
is_starred: bool
has_attachments: bool
has_remote_images: bool
# The sender is on the user's trusted list, so remote images load without a prompt.
images_allowed: bool = False
size_bytes: int
snippet: str
# Other accounts this same delivery also landed in (collapsed duplicates).
@@ -84,6 +87,9 @@ class AccountOut(BaseModel):
color: str
state: str
signature: str = ""
sync_window: str = "all"
backfill_total: int = 0
backfill_done: int = 0
class SearchThreadHit(BaseModel):
+34 -21
View File
@@ -3,13 +3,14 @@
from __future__ import annotations
import uuid
from collections.abc import Collection
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, func, literal, or_, select, tuple_
from sqlalchemy import Text, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement
from app.api.cursors import decode_cursor, encode_cursor
from app.api.errors import raise_api_error
from app.api.schemas import (
Address,
@@ -18,6 +19,7 @@ from app.api.schemas import (
ThreadListOut,
ThreadSummary,
)
from app.api.settings import allowed_image_senders
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import Mailbox, Message, Thread, User
@@ -48,7 +50,8 @@ def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
)
def _envelope(msg: Message) -> MessageEnvelope:
def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnvelope:
sender = msg.from_addr[0].get("email", "").lower() if msg.from_addr else ""
return MessageEnvelope(
id=msg.id,
account_id=msg.account_id,
@@ -65,6 +68,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
is_starred="\\Flagged" in msg.flags,
has_attachments=msg.has_attachments,
has_remote_images=msg.has_remote_images,
images_allowed=bool(sender) and sender in allowed_senders,
size_bytes=msg.size_bytes,
snippet=msg.snippet,
)
@@ -75,10 +79,14 @@ async def list_threads(
user: CurrentUser,
db: DbSession,
view: str = Query("unified"),
cursor: str | None = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
filter: str = Query("all"), # all | unread | pinned
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
) -> ThreadListOut:
stmt = select(Thread).where(Thread.user_id == user.id)
# Collect WHERE conditions once so the page query and the total-count query
# stay in lockstep (offset pagination needs an accurate grand total).
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
if view.startswith("account:"):
# A single account's inbox: threads with a message in THIS account's
@@ -97,7 +105,7 @@ async def list_threads(
Mailbox.special_use == "inbox",
)
)
stmt = stmt.where(Thread.id.in_(in_account_inbox))
conditions.append(Thread.id.in_(in_account_inbox))
elif view.startswith("folder:"):
# Threads with at least one message in a mailbox of this special_use,
# across all of the user's accounts (unified per-folder view).
@@ -107,24 +115,28 @@ async def list_threads(
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Mailbox.special_use == special)
)
stmt = stmt.where(Thread.id.in_(in_folder))
conditions.append(Thread.id.in_(in_folder))
elif view != "unified":
raise_api_error(400, "bad_view", "unsupported view")
decoded = decode_cursor(cursor)
if decoded is not None:
cur_date, cur_id = decoded
stmt = stmt.where(
tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id))
)
stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1)
if filter == "unread":
conditions.append(Thread.unread_count > 0)
elif filter == "pinned":
conditions.append(Thread.pinned.is_(True))
threads = list(await db.scalars(stmt))
has_more = len(threads) > limit
threads = threads[:limit]
next_cursor = (
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
total = (
await db.scalar(
select(func.count()).select_from(select(Thread.id).where(*conditions).subquery())
)
) or 0
order_by = (
(Thread.last_message_at.asc(), Thread.id.asc())
if order == "asc"
else (Thread.last_message_at.desc(), Thread.id.desc())
)
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
threads = list(await db.scalars(stmt))
# Per-thread visible message count matching what get_thread shows for this view:
# Trash/Spam count only their own messages; every other view excludes them. This
@@ -152,7 +164,7 @@ async def list_threads(
}
return ThreadListOut(
threads=[_summary(t, counts.get(t.id)) for t in threads], cursor=next_cursor
threads=[_summary(t, counts.get(t.id)) for t in threads], total=total
)
@@ -216,9 +228,10 @@ async def get_thread(
elif m.account_id != rep.account_id and m.account_id not in also[key]:
also[key].append(m.account_id)
allowed_senders = allowed_image_senders(user)
envelopes = []
for m in ordered:
env = _envelope(m)
env = _envelope(m, allowed_senders)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env)