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,76 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user