Files
webmail-client/backend/app/api/search.py
T
lukasandClaude Opus 5.5 08d428de8a Mail list: name everyone who wrote in the thread, and call the user "me"
The sender column showed participants[0], and participants are rebuilt
newest message first — so replying to a thread put the user in that slot
and the list named them instead of the person who wrote in.

Thread summaries (list and search) now carry `senders`: the distinct From
addresses of the thread's visible messages, oldest first, with the same
Trash/Spam visibility as the message count. The list joins them and shows
the user's own account addresses as "me" ("me, Alice" for a thread the
user started), and the avatar prefers the other side of the conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:17:37 +02:00

69 lines
2.1 KiB
Python

"""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
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)
)
}
senders = await thread_senders(db, user.id, list(threads))
results = [
SearchThreadHit(
thread=_summary(threads[tid], senders=senders.get(tid)),
highlights=highlights.get(tid, {}),
)
for tid in order
if tid in threads
]
return SearchOut(threads=results, cursor=next_cursor)