Files
webmail-client/backend/app/outbox/service.py
T
lukasandClaude Opus 4.8 a5ec0d21cb 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>
2026-07-17 23:20:34 +02:00

77 lines
2.6 KiB
Python

"""Draft persistence and the send/undo flow (SPEC §6.5)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Account, Draft, Outbox
from app.outbox.schemas import DraftIn
class DraftError(Exception):
"""Draft could not be created/updated (e.g. account not owned)."""
async def upsert_draft(
session: AsyncSession, *, user_id: uuid.UUID, body: DraftIn, draft_id: uuid.UUID | None
) -> Draft:
"""Create or update a draft owned by ``user_id``."""
account = await session.scalar(
select(Account).where(Account.id == body.account_id, Account.user_id == user_id)
)
if account is None:
raise DraftError("account not found")
payload = body.payload.model_dump(mode="json")
if draft_id is None:
draft = Draft(
user_id=user_id,
account_id=body.account_id,
thread_id=body.thread_id,
reply_to_message_id=body.reply_to_message_id,
kind=body.kind,
payload=payload,
)
session.add(draft)
else:
fetched = await session.scalar(
select(Draft).where(Draft.id == draft_id, Draft.user_id == user_id)
)
if fetched is None:
raise DraftError("draft not found")
draft = fetched
draft.account_id = body.account_id
draft.thread_id = body.thread_id
draft.reply_to_message_id = body.reply_to_message_id
draft.kind = body.kind
draft.payload = payload
draft.updated_at = datetime.now(UTC)
await session.flush()
return draft
async def create_outbox(session: AsyncSession, *, user_id: uuid.UUID, draft: Draft) -> Outbox:
"""Create an outbox row in the undo window for a draft."""
send_after = datetime.now(UTC) + timedelta(seconds=get_settings().undo_seconds)
outbox = Outbox(user_id=user_id, draft_id=draft.id, state="undo_window", send_after=send_after)
session.add(outbox)
await session.flush()
return outbox
async def cancel_outbox(session: AsyncSession, *, user_id: uuid.UUID, outbox_id: uuid.UUID) -> bool:
"""Cancel a send if still in the undo window. Returns False if too late/unknown."""
outbox = await session.scalar(
select(Outbox).where(Outbox.id == outbox_id, Outbox.user_id == user_id)
)
if outbox is None or outbox.state != "undo_window":
return False
outbox.state = "cancelled"
await session.flush()
return True