"""Thread listing and detail endpoints (SPEC §9.1 Mail).""" from __future__ import annotations import uuid from collections.abc import Collection from typing import Annotated from fastapi import APIRouter, Depends, Query from sqlalchemy import Text, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement from app.api.errors import raise_api_error from app.api.schemas import ( Address, MessageEnvelope, ThreadDetail, ThreadListOut, ThreadSummary, ) from app.api.settings import allowed_image_senders 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, message_count: int | None = None) -> 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), # Count only the messages this view actually shows (deleted/spam excluded # elsewhere); falls back to the denormalized total. message_count=message_count if message_count is not None else 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, allowed_senders: Collection[str] = ()) -> MessageEnvelope: sender = msg.from_addr[0].get("email", "").lower() if msg.from_addr else "" 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, images_allowed=bool(sender) and sender in allowed_senders, size_bytes=msg.size_bytes, snippet=msg.snippet, ) @router.get("", response_model=ThreadListOut) async def list_threads( user: CurrentUser, db: DbSession, view: str = Query("unified"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), filter: str = Query("all"), # all | unread | pinned order: str = Query("desc"), # desc (newest first) | asc (oldest first) ) -> ThreadListOut: # Collect WHERE conditions once so the page query and the total-count query # stay in lockstep (offset pagination needs an accurate grand total). conditions: list[ColumnElement[bool]] = [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", ) ) conditions.append(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) ) conditions.append(Thread.id.in_(in_folder)) elif view != "unified": raise_api_error(400, "bad_view", "unsupported view") if filter == "unread": conditions.append(Thread.unread_count > 0) elif filter == "pinned": conditions.append(Thread.pinned.is_(True)) total = ( await db.scalar( select(func.count()).select_from(select(Thread.id).where(*conditions).subquery()) ) ) or 0 order_by = ( (Thread.last_message_at.asc(), Thread.id.asc()) if order == "asc" else (Thread.last_message_at.desc(), Thread.id.desc()) ) stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit) threads = list(await db.scalars(stmt)) # Per-thread visible message count matching what get_thread shows for this view: # Trash/Spam count only their own messages; every other view excludes them. This # keeps the list count in step with a single deleted message. counts: dict[uuid.UUID, int] = {} if threads: # count DISTINCT deliveries so collapsed cross-account copies count once, # matching what get_thread renders. delivery = func.coalesce(Message.delivery_id, func.cast(Message.id, Text)) count_stmt = ( select(Message.thread_id, func.count(func.distinct(delivery))) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads])) .group_by(Message.thread_id) ) current_special = view.split(":", 1)[1] if view.startswith("folder:") else None if current_special in _REMOVAL_FOLDERS: count_stmt = count_stmt.where(Mailbox.special_use == current_special) else: count_stmt = count_stmt.where( or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS)) ) counts = { r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None } return ThreadListOut( threads=[_summary(t, counts.get(t.id)) for t in threads], total=total ) 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 # Trash/Spam are message-scoped folders: an individually deleted message lives # there on its own. Viewing a thread from Trash/Spam shows ONLY the messages filed # there; every other view shows the conversation with trashed/spam messages hidden. _REMOVAL_FOLDERS = frozenset({"trash", "junk"}) @router.get("/{thread_id}", response_model=ThreadDetail) async def get_thread( thread_id: uuid.UUID, user: CurrentUser, db: DbSession, sort: str = Query("asc"), view: str = Query("unified"), ) -> ThreadDetail: thread = await _resolve_thread(db, user, thread_id) current_special = view.split(":", 1)[1] if view.startswith("folder:") else None # Messages are always returned ASC (oldest first); the client reverses for # display per the requested order (invariant I-5 / §9.4). stmt = ( select(Message) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where(Message.thread_id == thread_id, Message.user_id == user.id) .order_by(Message.internal_date.asc(), Message.id.asc()) ) if current_special in _REMOVAL_FOLDERS: # Show only the messages actually in this Trash/Spam folder. stmt = stmt.where(Mailbox.special_use == current_special) else: # Conversation view: hide messages that were individually trashed or spammed # (keep custom folders, special_use IS NULL, and every non-removal folder). stmt = stmt.where( or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS)) ) messages = list(await db.scalars(stmt)) # Collapse duplicate deliveries: the same email (same delivery_id) that landed # in more than one of the user's accounts shows once, noting the other accounts. # Messages without a real Message-ID (delivery_id is NULL) never collapse. reps: dict[str, Message] = {} also: dict[str, list[uuid.UUID]] = {} ordered: list[Message] = [] for m in messages: key = m.delivery_id or f"id:{m.id}" rep = reps.get(key) if rep is None: reps[key] = m also[key] = [] ordered.append(m) elif m.account_id != rep.account_id and m.account_id not in also[key]: also[key].append(m.account_id) allowed_senders = allowed_image_senders(user) envelopes = [] for m in ordered: env = _envelope(m, allowed_senders) env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"] envelopes.append(env) 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=envelopes, )