"""Sync-window backfill strategies at account setup (SPEC §12 M2): all (recent first, then remainder), 3months (windowed), new (no history, watermark only).""" from __future__ import annotations import uuid from collections.abc import AsyncIterator from datetime import UTC, date, datetime, timedelta from email.message import EmailMessage from sqlalchemy import 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.identity.passwords import hash_password from app.models import Account, Mailbox, Message, User from app.providers.base import RemoteMailbox, RemoteMessage from app.sync import engine as sync_engine def _raw(mid: str) -> bytes: m = EmailMessage() m["Subject"] = "hi" 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() class _FakeProvider: gmail_labels_supported = False def __init__(self) -> None: self.backfill_calls: list[tuple[str, date | None, date | None]] = [] self.count_calls: list[tuple[str, date | None]] = [] self.newest_calls: list[str] = [] async def list_mailboxes(self) -> list[RemoteMailbox]: return [RemoteMailbox(path="INBOX", special_use="inbox", uidvalidity=1)] async def message_count(self, mailbox: str, *, since: date | None = None) -> int: self.count_calls.append((mailbox, since)) return 3 async def newest_uid(self, mailbox: str) -> int: self.newest_calls.append(mailbox) return 42 async def backfill( # type: ignore[no-untyped-def] self, mailbox, *, newest_first=True, batch_size=200, since=None, before=None ) -> AsyncIterator[list[RemoteMessage]]: self.backfill_calls.append((mailbox, since, before)) # Distinct UID per pass so recent + remainder both persist a row. uid = 100 if since is not None else 50 yield [ RemoteMessage( uid=uid, raw=_raw(f"<{mailbox}-{uid}@x.com>"), flags=frozenset(), internal_date=datetime.now(UTC), ) ] async def close(self) -> None: return None async def _seed(sm: async_sessionmaker, window: str) -> tuple[uuid.UUID, uuid.UUID]: # type: ignore[type-arg] session = sm() user = User(handle=f"bf{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", sync_window=window, ) session.add(account) await session.commit() aid = account.id await session.close() return user.id, aid async def test_window_new_sets_watermark_and_skips_backfill(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) _uid, account_id = await _seed(sm, "new") fake = _FakeProvider() monkeypatch.setattr(sync_engine, "build_provider", lambda _a: fake) try: count = await sync_engine.bootstrap_and_backfill(account_id) assert count == 0 assert fake.backfill_calls == [] # no history fetched assert fake.newest_calls == ["INBOX"] session = sm() acct = await session.get(Account, account_id) assert acct is not None and acct.state == "live" and acct.backfill_total == 0 mb = await session.scalar(select(Mailbox).where(Mailbox.account_id == account_id)) assert mb is not None and mb.last_seen_uid == 42 # moved to the tip msgs = await session.scalar( select(func.count()).select_from(Message).where(Message.mailbox_id == mb.id) ) assert msgs == 0 # nothing imported await session.close() finally: await engine.dispose() async def test_window_all_imports_recent_then_remainder(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) _uid, account_id = await _seed(sm, "all") fake = _FakeProvider() monkeypatch.setattr(sync_engine, "build_provider", lambda _a: fake) try: await sync_engine.bootstrap_and_backfill(account_id) # Two passes per mailbox: recent window (since set), then the older # remainder (before set) — the "usable fast, then fill in" strategy. cutoff = date.today() - timedelta(days=sync_engine._RECENT_DAYS) assert fake.backfill_calls == [("INBOX", cutoff, None), ("INBOX", None, cutoff)] assert fake.count_calls == [("INBOX", None)] # denominator = whole mailbox session = sm() acct = await session.get(Account, account_id) assert acct is not None and acct.state == "live" assert acct.backfill_total == 3 and acct.backfill_done == 2 mb = await session.scalar(select(Mailbox).where(Mailbox.account_id == account_id)) assert mb is not None msgs = await session.scalar( select(func.count()).select_from(Message).where(Message.mailbox_id == mb.id) ) assert msgs == 2 # one from each pass await session.close() finally: await engine.dispose() async def test_window_3months_single_dated_pass(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) _uid, account_id = await _seed(sm, "3months") fake = _FakeProvider() monkeypatch.setattr(sync_engine, "build_provider", lambda _a: fake) try: await sync_engine.bootstrap_and_backfill(account_id) since = date.today() - timedelta(days=sync_engine._WINDOW_DAYS["3months"]) assert fake.backfill_calls == [("INBOX", since, None)] # single windowed pass assert fake.count_calls == [("INBOX", since)] # denominator counts the window await engine.dispose() finally: pass