Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.schemas import SearchOut, SearchThreadHit
|
||||
from app.api.threads import _summary
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Thread
|
||||
from app.search.factory import get_search_backend
|
||||
from app.search.queryparser import to_search_query
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchOut)
|
||||
async def search(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
q: str = Query(""),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> SearchOut:
|
||||
if not q.strip():
|
||||
return SearchOut(threads=[], cursor=None)
|
||||
|
||||
backend = get_search_backend()
|
||||
query = to_search_query(q, user.id)
|
||||
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
|
||||
|
||||
# Group hits by thread, preserving order and keeping the first highlight.
|
||||
order: list[uuid.UUID] = []
|
||||
highlights: dict[uuid.UUID, dict[str, str]] = {}
|
||||
for hit in hits:
|
||||
if hit.thread_id not in highlights:
|
||||
order.append(hit.thread_id)
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
elif hit.highlights and not highlights[hit.thread_id]:
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
|
||||
if not order:
|
||||
return SearchOut(threads=[], cursor=next_cursor)
|
||||
|
||||
threads = {
|
||||
t.id: t
|
||||
for t in await db.scalars(
|
||||
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
||||
)
|
||||
}
|
||||
results = [
|
||||
SearchThreadHit(thread=_summary(threads[tid]), highlights=highlights.get(tid, {}))
|
||||
for tid in order
|
||||
if tid in threads
|
||||
]
|
||||
return SearchOut(threads=results, cursor=next_cursor)
|
||||
Reference in New Issue
Block a user