Files
webmail-client/backend/tests/outbox/test_draft_sync.py
T

187 lines
6.3 KiB
Python
Raw Normal View History

"""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": "<p>hi</p>", "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"] = "<d1@x.com>"
m["In-Reply-To"] = "<orig@x.com>" # 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"