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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+64
View File
@@ -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)