2026-07-17 23:20:34 +02:00
|
|
|
"""Draft persistence and the send/undo flow (SPEC §6.5)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
2026-10-05 22:08:42 +02:00
|
|
|
from sqlalchemy import ColumnElement, exists, select
|
2026-07-17 23:20:34 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
2026-07-20 23:12:25 +02:00
|
|
|
from app.models import Account, Draft, Mailbox, Message, Outbox
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.outbox.schemas import DraftIn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DraftError(Exception):
|
|
|
|
|
"""Draft could not be created/updated (e.g. account not owned)."""
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
async def delete_mirrored_draft_row(
|
|
|
|
|
session: AsyncSession, *, account_id: uuid.UUID, imap_uid: int
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Delete the DB row for a draft that was mirrored into the IMAP Drafts folder.
|
|
|
|
|
|
|
|
|
|
The server copy is expunged separately (send_job / remove_imap_draft_job).
|
|
|
|
|
Without also dropping this row the draft resurfaces in ``list_remote_drafts``
|
|
|
|
|
as an "other client" draft once its local ``drafts`` row is gone — until the
|
|
|
|
|
next Drafts poll would catch the expunge. Used on both send and discard.
|
|
|
|
|
"""
|
|
|
|
|
mirror = await session.scalar(
|
|
|
|
|
select(Message)
|
|
|
|
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
|
|
|
|
.where(
|
|
|
|
|
Message.account_id == account_id,
|
|
|
|
|
Message.uid == imap_uid,
|
|
|
|
|
Mailbox.special_use == "drafts",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if mirror is not None:
|
|
|
|
|
await session.delete(mirror)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-05 22:08:42 +02:00
|
|
|
# A draft with one of these sends is in flight — or waiting in Scheduled for a
|
|
|
|
|
# retry — so it is not listed (or counted) in Drafts.
|
|
|
|
|
OUTBOX_ACTIVE = ("undo_window", "sending", "retrying", "failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def not_being_sent() -> ColumnElement[bool]:
|
|
|
|
|
"""SQL condition: this Draft row has no send in flight or waiting."""
|
|
|
|
|
return ~exists().where(Outbox.draft_id == Draft.id, Outbox.state.in_(OUTBOX_ACTIVE))
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|