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:
@@ -0,0 +1,160 @@
|
||||
"""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),
|
||||
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],
|
||||
)
|
||||
Reference in New Issue
Block a user