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

233 lines
8.8 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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 Text, func, literal, or_, select, tuple_
2026-07-17 23:20:34 +02:00
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, message_count: int | None = None) -> ThreadSummary:
2026-07-17 23:20:34 +02:00
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,
2026-07-17 23:20:34 +02:00
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),
2026-07-17 23:20:34 +02:00
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,
2026-07-19 18:58:01 +02:00
size_bytes=msg.size_bytes,
2026-07-17 23:20:34 +02:00
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
)
# 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], cursor=next_cursor
)
2026-07-17 23:20:34 +02:00
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"})
2026-07-17 23:20:34 +02:00
@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"),
2026-07-17 23:20:34 +02:00
) -> ThreadDetail:
thread = await _resolve_thread(db, user, thread_id)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
2026-07-17 23:20:34 +02:00
# 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())
2026-07-17 23:20:34 +02:00
)
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)
envelopes = []
for m in ordered:
env = _envelope(m)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env)
2026-07-17 23:20:34 +02:00
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,
2026-07-17 23:20:34 +02:00
)