"""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() async def test_apply_delta_expunges_vanished_uids() -> None: # type: ignore[no-untyped-def] """A QRESYNC delta carrying ``expunged_uids`` removes those rows, drops them from the search index (tsvector cleared), and un-links any local draft that mirrored a vanished UID — the VANISHED path (SPEC §6.3).""" from app.models import Message as MessageModel engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"van{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", last_seen_uid=10, ) session.add(inbox) await session.flush() for uid in (7, 8, 9): session.add( MessageModel( user_id=user.id, account_id=account.id, mailbox_id=inbox.id, uid=uid, internal_date=datetime.now(UTC), blob_sha256="", ) ) linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=9) session.add(linked) await session.commit() class FakeProvider: qresync_enabled = True async def close(self) -> None: return None # uids 7 and 9 vanished on the server; 8 survives. delta = MailboxDelta([], [], [7, 9], None, 4242) await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type] try: session.expunge_all() remaining = set( await session.scalars( select(MessageModel.uid).where(MessageModel.mailbox_id == inbox.id) ) ) assert remaining == {8} # 7 and 9 expunged assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked refreshed = await session.get(Mailbox, inbox.id) assert refreshed.highestmodseq == 4242 # modseq baseline advanced 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_server_side_flag_change_updates_the_thread_counters() -> None: """A flag set elsewhere (phone, webmail, server rule) must re-denormalize. `threads.unread_count` is what the message list renders, while the reading pane renders the message's own flags. When a delta updated flags without recomputing the thread, the two disagreed forever: the list kept showing a message as unread that the pane showed as read, and only a manual "mark as read" — which goes through a different path — reconciled them. """ from app.models import Thread from app.threading_.threader import denormalize_thread engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"fc{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", last_seen_uid=5 ) thread = Thread(user_id=user.id, subject_norm="t", last_message_at=datetime.now(UTC)) session.add_all([inbox, thread]) await session.flush() message = Message( user_id=user.id, account_id=account.id, mailbox_id=inbox.id, thread_id=thread.id, uid=5, internal_date=datetime.now(UTC), blob_sha256="0" * 64, flags=[], ) session.add(message) await session.flush() await denormalize_thread(session, thread.id) await session.commit() assert (await session.get(Thread, thread.id)).unread_count == 1 class FakeProvider: qresync_enabled = True async def close(self) -> None: return None class _FlagChange: uid = 5 flags = ["\\Seen"] delta = MailboxDelta([], [_FlagChange()], [], None, None) await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type] try: session.expunge_all() refreshed = await session.get(Thread, thread.id) assert refreshed.unread_count == 0, ( "the server said the message was read; the thread counter did not follow" ) 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_new_inbox_mail_while_live_asks_for_a_push(monkeypatch) -> None: # type: ignore[no-untyped-def] """New mail found by the live sync in an Inbox enqueues the new-mail push (app/push); new mail in any other folder does not.""" from app.push import service as push_service asked: list[tuple[uuid.UUID, list[uuid.UUID]]] = [] async def record(user_id: uuid.UUID, ids: list[uuid.UUID]) -> None: asked.append((user_id, ids)) monkeypatch.setattr(push_service, "enqueue_new_mail", record) engine = create_async_engine(get_settings().database_url, poolclass=NullPool) session = async_sessionmaker(engine, expire_on_commit=False)() user = User(handle=f"psh{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") session.add_all([inbox, sent]) await session.commit() class FakeProvider: async def close(self) -> None: return None def delta(uid: int, mid: str) -> MailboxDelta: msg = RemoteMessage( uid=uid, raw=_raw("Hi", mid), flags=frozenset(), internal_date=datetime.now(UTC) ) return MailboxDelta([msg], [], [], None, None) try: await sync_engine.apply_delta(FakeProvider(), account, sent, delta(3, "")) # type: ignore[arg-type] assert asked == [] await sync_engine.apply_delta(FakeProvider(), account, inbox, delta(4, "")) # type: ignore[arg-type] new_id = await session.scalar(select(Message.id).where(Message.mailbox_id == inbox.id)) assert asked == [(user.id, [new_id])] 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_live_loop_catches_up_before_it_idles(monkeypatch) -> None: # type: ignore[no-untyped-def] """Mail that reached INBOX while the loop was not running (a new account's first minutes, a reconnect) is fetched at once, not after the next new message or the 10-minute IDLE timeout.""" import asyncio engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"idl{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") session.add(inbox) await session.commit() calls: list[str] = [] stop = asyncio.Event() class FakeProvider: async def verify(self) -> None: return None async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def] calls.append("poll") waiting = RemoteMessage( uid=1, raw=_raw("Arrived while away", ""), flags=frozenset(), internal_date=datetime.now(UTC), ) return MailboxDelta([waiting] if len(calls) == 1 else [], [], [], None, None) async def idle(self, mailbox: str) -> None: calls.append("idle") stop.set() async def close(self) -> None: return None monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider()) try: await asyncio.wait_for(sync_engine.live_loop(account.id, "INBOX", stop), 10) assert calls == ["poll", "idle"] n = await session.scalar( select(func.count()).select_from(Message).where(Message.mailbox_id == inbox.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()