"""Mirroring local drafts to the IMAP Drafts folder (continue on any client).""" from __future__ import annotations import uuid from collections.abc import AsyncIterator from dataclasses import dataclass import pytest_asyncio from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.db import session_scope from app.identity.passwords import hash_password from app.models import Account, Draft, Mailbox, User from app.outbox import draft_sync @dataclass class Ctx: session: AsyncSession user_id: uuid.UUID account_id: uuid.UUID @pytest_asyncio.fixture async def ctx() -> AsyncIterator[Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"drf{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) session.add(user) await session.flush() account = Account( user_id=user.id, email="me@example.com", display_name="Me", imap_config={}, smtp_config={}, secret_ct=b"", discovery_src="manual", ) session.add(account) await session.flush() session.add( Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts") ) await session.commit() try: yield Ctx(session=session, user_id=user.id, account_id=account.id) finally: await session.execute(delete(User).where(User.id == user.id)) await session.commit() await session.close() await engine.dispose() class FakeProvider: def __init__(self) -> None: self.appended: list[tuple[str, frozenset[str]]] = [] self.deleted: list[tuple[str, list[int]]] = [] self._uid = 41 async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int: self.appended.append((mailbox, flags)) self._uid += 1 return self._uid async def delete_messages(self, mailbox: str, uids: list[int]) -> None: self.deleted.append((mailbox, list(uids))) async def close(self) -> None: return None async def _make_draft(ctx: Ctx) -> uuid.UUID: draft = Draft( user_id=ctx.user_id, account_id=ctx.account_id, kind="new", payload={"to": ["bob@example.com"], "subject": "Hi", "html": "

hi

", "text": ""}, ) ctx.session.add(draft) await ctx.session.commit() return draft.id async def test_push_draft_appends_with_draft_flag_and_replaces(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] did = await _make_draft(ctx) fake = FakeProvider() monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) await draft_sync.push_draft_to_imap_job({}, str(did)) # appended once to DRAFTS with the \Draft flag; UID recorded on the draft assert len(fake.appended) == 1 assert fake.appended[0][0] == "DRAFTS" assert "\\Draft" in fake.appended[0][1] assert fake.deleted == [] # nothing to replace yet async with session_scope() as s: assert (await s.get(Draft, did)).imap_uid == 42 # type: ignore[union-attr] # A second push (edit) appends a new copy and expunges the previous UID. await draft_sync.push_draft_to_imap_job({}, str(did)) assert len(fake.appended) == 2 assert fake.deleted == [("DRAFTS", [42])] async with session_scope() as s: assert (await s.get(Draft, did)).imap_uid == 43 # type: ignore[union-attr] async def test_draft_messages_are_not_threaded(ctx: Ctx) -> None: from datetime import UTC, datetime from email.message import EmailMessage from app.indexer.indexer import persist_message from app.providers.base import RemoteMessage m = EmailMessage() m["Subject"] = "A draft reply" m["From"] = "me@example.com" m["To"] = "bob@example.com" m["Message-ID"] = "" m["In-Reply-To"] = "" # would normally thread it m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" m.set_content("body") drafts_mb = await ctx.session.scalar( select(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts") ) assert drafts_mb is not None remote = RemoteMessage( uid=71, raw=m.as_bytes(), flags=frozenset({"\\Draft"}), internal_date=datetime.now(UTC) ) result = await persist_message( ctx.session, user_id=ctx.user_id, account_id=ctx.account_id, mailbox_id=drafts_mb.id, mailbox_special_use="drafts", remote=remote, ) await ctx.session.commit() assert result is not None assert result.message.thread_id is None # drafts stay out of conversations async def test_remove_imap_draft_expunges(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] fake = FakeProvider() monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) await draft_sync.remove_imap_draft_job({}, str(ctx.account_id), 7) assert fake.deleted == [("DRAFTS", [7])] async def test_no_drafts_mailbox_creates_one(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] # Drop the drafts mailbox so the job has to create it. await ctx.session.execute( delete(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts") ) await ctx.session.commit() did = await _make_draft(ctx) created: list[str] = [] class CreatingProvider(FakeProvider): async def create_mailbox(self, path: str) -> None: created.append(path) async def list_mailboxes(self): # type: ignore[no-untyped-def] from app.providers.base import RemoteMailbox return [RemoteMailbox(path="Drafts", special_use="drafts", uidvalidity=1)] fake = CreatingProvider() monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) await draft_sync.push_draft_to_imap_job({}, str(did)) assert created == ["Drafts"] async with session_scope() as s: mb = await s.scalar( select(Mailbox).where( Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts" ) ) assert mb is not None and mb.path == "Drafts"