"""Thread listing and detail endpoints (SPEC §9.1 Mail).""" from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query from sqlalchemy import literal, select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.api.cursors import decode_cursor, encode_cursor from app.api.errors import raise_api_error from app.api.schemas import ( Address, MessageEnvelope, ThreadDetail, ThreadListOut, ThreadSummary, ) from app.db import get_session from app.identity.deps import CurrentUser from app.models import Mailbox, Message, Thread, User router = APIRouter(prefix="/threads", tags=["mail"]) DbSession = Annotated[AsyncSession, Depends(get_session)] def _addresses(raw: list[dict[str, str]]) -> list[Address]: return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw] def _summary(thread: Thread) -> ThreadSummary: return ThreadSummary( id=thread.id, subject=thread.subject_norm or "(no subject)", snippet=thread.snippet, participants=_addresses(thread.participants), account_ids=list(thread.account_ids), message_count=thread.message_count, unread_count=thread.unread_count, has_attachments=thread.has_attachments, pinned=thread.pinned, last_message_at=thread.last_message_at, ) def _envelope(msg: Message) -> MessageEnvelope: return MessageEnvelope( id=msg.id, account_id=msg.account_id, mailbox_id=msg.mailbox_id, thread_id=msg.thread_id, subject=msg.subject, from_addr=_addresses(msg.from_addr), to_addr=_addresses(msg.to_addr), cc_addr=_addresses(msg.cc_addr), bcc_addr=_addresses(msg.bcc_addr), date=msg.date_hdr or msg.internal_date, flags=list(msg.flags), is_unread="\\Seen" not in msg.flags, is_starred="\\Flagged" in msg.flags, has_attachments=msg.has_attachments, has_remote_images=msg.has_remote_images, snippet=msg.snippet, ) @router.get("", response_model=ThreadListOut) async def list_threads( user: CurrentUser, db: DbSession, view: str = Query("unified"), cursor: str | None = None, limit: int = Query(50, ge=1, le=200), ) -> ThreadListOut: stmt = select(Thread).where(Thread.user_id == user.id) if view.startswith("account:"): # A single account's inbox: threads with a message in THIS account's # inbox mailbox. Keeps the per-account view consistent with the unified # Inbox (archived/trashed/sent threads don't leak in). try: account_id = uuid.UUID(view.split(":", 1)[1]) except ValueError: raise_api_error(400, "bad_view", "invalid account id in view") in_account_inbox = ( select(Message.thread_id) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where( Message.user_id == user.id, Message.account_id == account_id, Mailbox.special_use == "inbox", ) ) stmt = stmt.where(Thread.id.in_(in_account_inbox)) elif view.startswith("folder:"): # Threads with at least one message in a mailbox of this special_use, # across all of the user's accounts (unified per-folder view). special = view.split(":", 1)[1] in_folder = ( select(Message.thread_id) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where(Message.user_id == user.id, Mailbox.special_use == special) ) stmt = stmt.where(Thread.id.in_(in_folder)) elif view != "unified": raise_api_error(400, "bad_view", "unsupported view") decoded = decode_cursor(cursor) if decoded is not None: cur_date, cur_id = decoded stmt = stmt.where( tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id)) ) stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1) threads = list(await db.scalars(stmt)) has_more = len(threads) > limit threads = threads[:limit] next_cursor = ( encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None ) return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor) async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread: thread = await db.scalar( select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id) ) if thread is None: raise_api_error(404, "not_found", "thread not found") return thread @router.get("/{thread_id}", response_model=ThreadDetail) async def get_thread( thread_id: uuid.UUID, user: CurrentUser, db: DbSession, sort: str = Query("asc"), ) -> ThreadDetail: thread = await _resolve_thread(db, user, thread_id) # Messages are always returned ASC (oldest first); the client reverses for # display per the requested order (invariant I-5 / §9.4). messages = list( await db.scalars( select(Message) .where(Message.thread_id == thread_id, Message.user_id == user.id) .order_by(Message.internal_date.asc(), Message.id.asc()) ) ) order = "desc" if sort == "desc" else "asc" return ThreadDetail( id=thread.id, subject=thread.subject_norm or "(no subject)", order=order, pinned=thread.pinned, messages=[_envelope(m) for m in messages], )