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

313 lines
11 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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
2026-07-17 23:20:34 +02:00
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)
2026-07-17 23:20:34 +02:00
@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"}
2026-07-17 23:20:34 +02:00
@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
2026-07-17 23:20:34 +02:00
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)
2026-07-17 23:20:34 +02:00
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)
2026-07-17 23:20:34 +02:00
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(
2026-07-17 23:20:34 +02:00
"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"}