Files
webmail-client/backend/app/api/saved_searches.py
T
lukasandClaude Opus 4.8 49738740c0 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
2026-07-23 23:00:08 +02:00

83 lines
2.5 KiB
Python

"""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"}