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
83 lines
2.5 KiB
Python
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"}
|