189 lines
6.3 KiB
Python
189 lines
6.3 KiB
Python
"""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"
|