Files
webmail-client/backend/app/api/compose.py
T
lukasandClaude Opus 4.8 d650f16feb mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse
Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
  of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
  restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
  _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
  mirrored DB row; adopting a remote draft carries its real attachments into the
  compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
  hidden outside their folder, Trash/Spam show only their own messages, and the
  mail-list message count matches the view.

Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
  single expand), hide Reply/Forward on trashed messages, close the Actions menu
  on message-body (iframe) clicks, scroll to the inline composer, and prompt
  Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
  mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
  Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-07-20 23:12:25 +02:00

313 lines
11 KiB
Python

"""Compose endpoints: drafts + send + undo (SPEC §9.1)."""
from __future__ import annotations
import uuid
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.api.schemas import AttachmentOut
from app.config import get_settings
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.indexer import blobstore
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message
from app.outbox.schemas import (
DraftIn,
DraftOut,
RemoteDraftOut,
SendRequest,
SendResponse,
)
from app.outbox.service import (
DraftError,
cancel_outbox,
create_outbox,
delete_mirrored_draft_row,
upsert_draft,
)
from app.queue import enqueue_job
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 # 25 MB per file (compose Attach limit)
def _att_out(att: ComposeAttachment) -> AttachmentOut:
return AttachmentOut(
id=att.id, filename=att.filename, mime_type=att.mime_type, size_bytes=att.size_bytes
)
@router.post("/attachments", response_model=AttachmentOut)
async def upload_attachment(
user: CurrentUser, db: DbSession, file: UploadFile = File(...)
) -> AttachmentOut:
"""Store a file for an outgoing message (≤ 25 MB) and return its metadata."""
data = await file.read()
if not data:
raise_api_error(400, "empty_file", "the file is empty")
if len(data) > MAX_ATTACHMENT_BYTES:
raise_api_error(413, "too_large", "attachment exceeds the 25 MB limit")
sha = blobstore.store(user.id, data)
att = ComposeAttachment(
user_id=user.id,
filename=file.filename or "attachment",
mime_type=file.content_type or "application/octet-stream",
size_bytes=len(data),
blob_sha256=sha,
)
db.add(att)
await db.commit()
return _att_out(att)
@router.get("/attachments", response_model=list[AttachmentOut])
async def list_compose_attachments(
user: CurrentUser, db: DbSession, ids: str = Query("")
) -> list[AttachmentOut]:
"""Resolve metadata for the given compose-attachment ids (reopening a draft)."""
id_list: list[uuid.UUID] = []
for value in ids.split(","):
value = value.strip()
if not value:
continue
try:
id_list.append(uuid.UUID(value))
except ValueError:
continue
if not id_list:
return []
rows = await db.scalars(
select(ComposeAttachment).where(
ComposeAttachment.user_id == user.id, ComposeAttachment.id.in_(id_list)
)
)
by_id = {r.id: r for r in rows}
return [_att_out(by_id[i]) for i in id_list if i in by_id]
def _draft_out(draft: Draft) -> DraftOut:
return DraftOut(
id=draft.id,
account_id=draft.account_id,
thread_id=draft.thread_id,
reply_to_message_id=draft.reply_to_message_id,
kind=draft.kind,
payload=draft.payload,
updated_at=draft.updated_at.isoformat(),
)
@router.get("/drafts", response_model=list[DraftOut])
async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]:
drafts = await db.scalars(
select(Draft).where(Draft.user_id == user.id).order_by(Draft.updated_at.desc())
)
return [_draft_out(d) for d in drafts]
def _fmt_addr(addr: dict[str, str]) -> str:
email = addr.get("email", "")
name = addr.get("name", "")
return f"{name} <{email}>" if name else email
async def _own_draft_uids(db: AsyncSession, user_id: uuid.UUID) -> set[tuple[uuid.UUID, int]]:
"""(account_id, imap_uid) of drafts we mirrored — excluded from the remote list."""
rows = await db.execute(
select(Draft.account_id, Draft.imap_uid).where(
Draft.user_id == user_id, Draft.imap_uid.isnot(None)
)
)
return {(r.account_id, r.imap_uid) for r in rows}
@router.get("/drafts/remote", response_model=list[RemoteDraftOut])
async def list_remote_drafts(user: CurrentUser, db: DbSession) -> list[RemoteDraftOut]:
"""Drafts sitting in the IMAP Drafts folder that we haven't already adopted."""
own = await _own_draft_uids(db, user.id)
messages = await db.scalars(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Mailbox.special_use == "drafts")
.order_by(Message.internal_date.desc())
)
out: list[RemoteDraftOut] = []
for m in messages:
if (m.account_id, m.uid) in own:
continue
out.append(
RemoteDraftOut(
message_id=m.id,
account_id=m.account_id,
imap_uid=m.uid,
subject=m.subject,
to=[_fmt_addr(a) for a in m.to_addr],
cc=[_fmt_addr(a) for a in m.cc_addr],
bcc=[_fmt_addr(a) for a in m.bcc_addr],
snippet=m.snippet,
updated_at=(m.date_hdr or m.internal_date).isoformat(),
)
)
return out
@router.post("/drafts/adopt/{message_id}", response_model=DraftOut)
async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> DraftOut:
"""Turn a Drafts-folder message into an editable local draft (linked by UID)."""
msg = await db.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.id == message_id,
Message.user_id == user.id,
Mailbox.special_use == "drafts",
)
)
if msg is None:
raise_api_error(404, "not_found", "draft message not found")
# Idempotent: if already adopted (a local draft links this UID), return it.
existing = await db.scalar(
select(Draft).where(
Draft.user_id == user.id,
Draft.account_id == msg.account_id,
Draft.imap_uid == msg.uid,
)
)
if existing is not None:
return _draft_out(existing)
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == msg.id)))
html = next((p.content for p in parts if p.kind == "html_sanitized"), "")
text = next((p.content for p in parts if p.kind == "text"), "")
# Carry the draft's real (non-inline) attachments over as compose attachments,
# so they show in the editor and are re-attached on save/send. The underlying
# blobs are shared by sha256 — we only add new ComposeAttachment references.
att_rows = list(
await db.scalars(
select(Attachment)
.where(Attachment.message_id == msg.id, Attachment.content_id.is_(None))
.order_by(Attachment.filename)
)
)
attachment_ids: list[str] = []
for a in att_rows:
ca = ComposeAttachment(
user_id=user.id,
filename=a.filename,
mime_type=a.mime_type,
size_bytes=a.size_bytes,
blob_sha256=a.blob_sha256,
)
db.add(ca)
await db.flush()
attachment_ids.append(str(ca.id))
payload = {
"to": [_fmt_addr(a) for a in msg.to_addr],
"cc": [_fmt_addr(a) for a in msg.cc_addr],
"bcc": [_fmt_addr(a) for a in msg.bcc_addr],
"subject": msg.subject,
"html": html,
"text": text,
"attachment_ids": attachment_ids,
}
draft = Draft(
user_id=user.id,
account_id=msg.account_id,
kind="new",
payload=payload,
imap_uid=msg.uid,
)
db.add(draft)
await db.commit()
return _draft_out(draft)
@router.post("/drafts", response_model=DraftOut)
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
try:
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=None)
except DraftError as exc:
raise_api_error(400, "draft_error", str(exc))
await db.commit()
return _draft_out(draft)
@router.put("/drafts/{draft_id}", response_model=DraftOut)
async def update_draft(
draft_id: uuid.UUID, body: DraftIn, user: CurrentUser, db: DbSession
) -> DraftOut:
try:
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=draft_id)
except DraftError as exc:
code = 404 if "not found" in str(exc) else 400
raise_api_error(code, "draft_error", str(exc))
await db.commit()
return _draft_out(draft)
@router.post("/drafts/{draft_id}/publish")
async def publish_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
"""Mirror the draft to the account's IMAP Drafts folder (continue on any client)."""
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
if draft is None:
raise_api_error(404, "not_found", "draft not found")
await enqueue_job("push_draft_to_imap_job", str(draft_id))
return {"status": "queued"}
@router.delete("/drafts/{draft_id}")
async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
if draft is None:
raise_api_error(404, "not_found", "draft not found")
account_id, imap_uid = draft.account_id, draft.imap_uid
await db.delete(draft)
# Drop the mirrored Drafts-folder message row now so the draft doesn't
# resurface as an "other client" remote draft after the local row is gone.
if imap_uid:
await delete_mirrored_draft_row(db, account_id=account_id, imap_uid=imap_uid)
await db.commit()
# Remove the mirrored copy from the server Drafts folder too.
if imap_uid:
await enqueue_job("remove_imap_draft_job", str(account_id), imap_uid)
return {"status": "deleted"}
@router.post("/send", response_model=SendResponse)
async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendResponse:
draft = await db.scalar(
select(Draft).where(Draft.id == body.draft_id, Draft.user_id == user.id)
)
if draft is None:
raise_api_error(404, "not_found", "draft not found")
outbox = await create_outbox(db, user_id=user.id, draft=draft)
await db.commit()
await enqueue_job(
"send_job",
str(outbox.id),
_defer_by=timedelta(seconds=get_settings().undo_seconds),
)
return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat())
@router.post("/send/{outbox_id}/undo")
async def undo_send(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
ok = await cancel_outbox(db, user_id=user.id, outbox_id=outbox_id)
await db.commit()
if not ok:
raise_api_error(409, "undo_too_late", "the message has already left the undo window")
return {"status": "cancelled"}