Files
webmail-client/backend/app/api/search.py
T

82 lines
2.8 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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, thread_senders
2026-07-17 23:20:34 +02:00
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),
filter: str | None = Query(None, pattern="^(unread|pinned)$"),
2026-07-17 23:20:34 +02:00
) -> SearchOut:
"""One page of matching messages, grouped into conversations. ``filter``
keeps conversations with unread mail / pinned ones (the list's Unread and
Pinned tabs). The first page also says how many conversations match in
all, where the search engine can count them."""
2026-07-17 23:20:34 +02:00
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)
total: int | None = None
count = getattr(backend, "count_threads", None)
if cursor is None and count is not None:
total = await count(query, thread_filter=filter)
2026-07-17 23:20:34 +02:00
# 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, total=total)
2026-07-17 23:20:34 +02:00
threads = {
t.id: t
for t in await db.scalars(
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
)
}
senders = await thread_senders(db, user.id, list(threads))
2026-07-17 23:20:34 +02:00
results = [
SearchThreadHit(
thread=_summary(threads[tid], senders=senders.get(tid)),
highlights=highlights.get(tid, {}),
)
2026-07-17 23:20:34 +02:00
for tid in order
if tid in threads
and (
filter is None
or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned)
)
2026-07-17 23:20:34 +02:00
]
return SearchOut(threads=results, cursor=next_cursor, total=total)