"""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 ComposeAttachment, Draft from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft from app.queue import get_arq_pool 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] @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.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") await db.delete(draft) await db.commit() 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() pool = await get_arq_pool() await pool.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"}