Files
webmail-client/backend/tests/sync/test_backfill_window.py
T
lukasandClaude Opus 5.5 609e7c58f9 Unreachable mail servers: send retries in Scheduled, real action retries, account health
Sending: a send the server could not take (unreachable, timeout, 4xx) is no
longer failed at once. It waits in Scheduled ("retrying") and is tried again
after 1, 2, 5, 10, 30 min, then hourly, for 24 hours — on the server, so it
works with the browser closed. A permanent refusal (5xx, wrong password) is
"failed" at once, with the server's reason. GET /outbox lists both;
POST /outbox/{id}/retry (Retry now) and /cancel (Edit, Delete) act on them,
and they are left out of Drafts. Mail delivered but not filed in Sent now
counts as sent, and a conditional claim keeps two jobs from sending one
message twice. The app says when a send failed and when it finally went.

Actions: arq retries a job only on Retry, never on a plain exception, so the
"retry x5, then revert" for flags and moves never happened. Flag, move,
archive, restore, delete and empty-folder jobs now raise Retry while the
server is unreachable (~24 hours of growing gaps), skip a change a later one
replaced, stop at the first connection failure instead of 20 s per message,
and delete/empty keep the rows until the server has the messages gone.

Account health: 3 failed connections in a row mark an account unreachable
(amber in the sidebar and Settings, cleared on the next success); 3 refused
sign-ins stop its syncing as auth_failed until the password is entered
again, so the provider does not lock the account. Sync loops back off 30 s
doubling to 10 min and log one line per expected failure; a refused
connection fails at once with its reason (the connect task is kept), and a
silently dead IDLE connection is noticed within ~11 min instead of ~29.

Also: the empty reading pane is clipped to its width when dragged very
narrow; tests no longer leave live accounts and outbox rows in the database.

Migration 0021: outbox attempts / next_attempt_at / retry_until, accounts
sync_error / sync_error_since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:08:42 +02:00

177 lines
6.9 KiB
Python

"""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 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.identity.passwords import hash_password
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMailbox, RemoteMessage
from app.redis_client import get_redis
from app.sync import engine as sync_engine
from app.sync import leases
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 _cleanup(sm: async_sessionmaker, user_id: uuid.UUID, account_id: uuid.UUID) -> None: # type: ignore[type-arg]
"""Remove the seeded user — backfill sets its account live, and left behind a
running dev worker would keep trying to sync it."""
await leases.mark_not_live(get_redis(), account_id)
session = sm()
await session.execute(delete(User).where(User.id == user_id))
await session.commit()
await session.close()
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 _cleanup(sm, _uid, account_id)
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 _cleanup(sm, _uid, account_id)
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
finally:
await _cleanup(sm, _uid, account_id)
await engine.dispose()