"""Secondary (non-INBOX) folder polling — multi-folder CONDSTORE sync (SPEC §6.3).""" from __future__ import annotations import uuid from datetime import UTC, datetime from email.message import EmailMessage from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import 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, Message, User from app.providers.base import MailboxDelta, RemoteMessage from app.sync import engine as sync_engine def _raw(subject: str, mid: str) -> bytes: m = EmailMessage() m["Subject"] = subject m["From"] = "me@example.com" m["To"] = "bob@example.com" m["Message-ID"] = mid m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" m.set_content("body") return m.as_bytes() async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> None: # type: ignore[no-untyped-def] engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"sec{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", imap_config={}, smtp_config={}, secret_ct=b"", discovery_src="manual", ) session.add(account) await session.flush() inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent") custom = Mailbox(account_id=account.id, user_id=user.id, path="Notes", special_use=None) session.add_all([inbox, sent, custom]) await session.commit() polled: list[str] = [] class FakeProvider: async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def] polled.append(mailbox) if mailbox == "SENT": return MailboxDelta( [ RemoteMessage( uid=5, raw=_raw("Sent one", ""), flags=frozenset({"\\Seen"}), internal_date=datetime.now(UTC), ) ], [], [], None, None, ) return MailboxDelta([], [], [], None, None) async def close(self) -> None: return None monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider()) try: await sync_engine._poll_secondary_once(account.id) # Only the non-INBOX special-use folder is polled (SENT); INBOX and the # custom folder (no special_use) are skipped. assert polled == ["SENT"] # The delta was applied: the sent message is now in the DB under SENT. session.expunge_all() n = await session.scalar( select(func.count()).select_from(Message).where(Message.mailbox_id == sent.id) ) assert n == 1 finally: async with session_scope() as cleanup: await cleanup.execute(delete(User).where(User.id == user.id)) await session.close() await engine.dispose() async def test_drafts_poll_reconciles_expunged_rows(monkeypatch) -> None: # type: ignore[no-untyped-def] """A Drafts poll drops DB rows whose server copy was expunged (phantoms) and un-links a local draft whose mirrored copy vanished.""" engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"rec{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", imap_config={}, smtp_config={}, secret_ct=b"", discovery_src="manual", ) session.add(account) await session.flush() drafts = Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts") session.add(drafts) await session.flush() # uid 2 survives on the server; uids 1 and 3 were expunged (phantom DB rows). for uid in (1, 2, 3): session.add( Message( user_id=user.id, account_id=account.id, mailbox_id=drafts.id, uid=uid, internal_date=datetime.now(UTC), blob_sha256="", ) ) # A local draft still linking the vanished uid 3 must be un-linked (imap_uid -> NULL). linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=3) kept = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=2) session.add_all([linked, kept]) await session.commit() class FakeProvider: async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def] return MailboxDelta([], [], [], None, None) async def list_uids(self, mailbox: str) -> set[int]: return {2} # only uid 2 remains on the server async def close(self) -> None: return None monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider()) try: await sync_engine._poll_secondary_once(account.id) session.expunge_all() remaining = set( await session.scalars(select(Message.uid).where(Message.mailbox_id == drafts.id)) ) assert remaining == {2} # phantoms 1 and 3 removed assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked assert (await session.get(Draft, kept.id)).imap_uid == 2 # untouched finally: async with session_scope() as cleanup: await cleanup.execute(delete(User).where(User.id == user.id)) await session.close() await engine.dispose()