Files
webmail-client/backend/app/outbox/service.py
T
lukasandClaude Opus 4.8 d650f16feb mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse
Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
  of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
  restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
  _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
  mirrored DB row; adopting a remote draft carries its real attachments into the
  compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
  hidden outside their folder, Trash/Spam show only their own messages, and the
  mail-list message count matches the view.

Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
  single expand), hide Reply/Forward on trashed messages, close the Actions menu
  on message-body (iframe) clicks, scroll to the inline composer, and prompt
  Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
  mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
  Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-07-20 23:12:25 +02:00

100 lines
3.4 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, Mailbox, Message, Outbox
from app.outbox.schemas import DraftIn
class DraftError(Exception):
"""Draft could not be created/updated (e.g. account not owned)."""
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)
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