Files
webmail-client/backend/tests/sync/test_account_health.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

128 lines
5.0 KiB
Python

"""An account whose mail server is unreachable, or refuses the password."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import aiosmtplib
import pytest_asyncio
from aioimaplib import aioimaplib
from sqlalchemy import delete
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, User
from app.providers.base import (
ProviderAuthError,
ProviderConnectError,
ProviderRejectedError,
)
from app.providers.imap_smtp.provider import _smtp_error
from app.sync.health import AUTH_GIVE_UP_AFTER, UNREACHABLE_AFTER, AccountHealth, is_temporary
@pytest_asyncio.fixture
async def account_id() -> AsyncIterator[uuid.UUID]:
async with session_scope() as session:
user = User(
handle=f"hl{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={"host": "imap.example.com", "port": 993, "tls": "ssl"},
smtp_config={"host": "smtp.example.com", "port": 465, "tls": "ssl"},
secret_ct=b"",
discovery_src="manual",
state="live",
)
session.add(account)
await session.flush()
user_id, aid = user.id, account.id
try:
yield aid
finally:
async with session_scope() as session:
await session.execute(delete(User).where(User.id == user_id))
async def _account(aid: uuid.UUID) -> Account:
async with session_scope() as session:
account = await session.get(Account, aid)
assert account is not None
return account
def test_what_counts_as_temporary() -> None:
assert is_temporary(ProviderConnectError("cannot connect"))
assert is_temporary(TimeoutError())
assert is_temporary(ConnectionResetError())
assert is_temporary(aioimaplib.CommandTimeout("uid move"))
# After the connection drops, aioimaplib refuses commands this way.
assert is_temporary(aioimaplib.Abort("command UID illegal in state LOGOUT"))
assert not is_temporary(aioimaplib.Abort("server has not MOVE capability"))
assert not is_temporary(ProviderAuthError("bad password"))
assert not is_temporary(ProviderRejectedError("550 no such user"))
assert not is_temporary(ValueError("bug"))
def test_smtp_errors_say_whether_to_retry() -> None:
assert isinstance(_smtp_error(aiosmtplib.SMTPConnectError("refused")), ProviderConnectError)
assert isinstance(_smtp_error(aiosmtplib.SMTPServerDisconnected("gone")), ProviderConnectError)
assert isinstance(
_smtp_error(aiosmtplib.SMTPResponseException(451, "try later")), ProviderConnectError
)
assert isinstance(
_smtp_error(aiosmtplib.SMTPResponseException(554, "rejected")), ProviderRejectedError
)
assert isinstance(
_smtp_error(aiosmtplib.SMTPAuthenticationError(535, "bad credentials")), ProviderAuthError
)
refused = aiosmtplib.SMTPRecipientsRefused(
[aiosmtplib.SMTPRecipientRefused(550, "no such user", "x@example.com")]
)
assert isinstance(_smtp_error(refused), ProviderRejectedError)
greylisted = aiosmtplib.SMTPRecipientsRefused(
[aiosmtplib.SMTPRecipientRefused(450, "greylisted", "x@example.com")]
)
assert isinstance(_smtp_error(greylisted), ProviderConnectError)
async def test_unreachable_after_failures_in_a_row_then_clears(account_id: uuid.UUID) -> None:
health = AccountHealth(account_id)
for _ in range(UNREACHABLE_AFTER - 1):
await health.failed(ProviderConnectError("cannot connect"))
assert (await _account(account_id)).sync_error is None # a blip is not an outage
await health.failed(ProviderConnectError("cannot connect"))
account = await _account(account_id)
assert account.sync_error == "unreachable" and account.sync_error_since is not None
assert account.state == "live" # still synced: retried until it answers
await health.ok()
account = await _account(account_id)
assert account.sync_error is None and account.sync_error_since is None
async def test_mark_survives_a_restart_and_clears_on_success(account_id: uuid.UUID) -> None:
first = AccountHealth(account_id)
for _ in range(UNREACHABLE_AFTER):
await first.failed(ProviderConnectError("cannot connect"))
# A new worker knows nothing of the mark, yet its first success clears it.
await AccountHealth(account_id).ok()
assert (await _account(account_id)).sync_error is None
async def test_refused_sign_in_stops_syncing(account_id: uuid.UUID) -> None:
health = AccountHealth(account_id)
for _ in range(AUTH_GIVE_UP_AFTER - 1):
await health.failed(ProviderAuthError("AUTHENTICATIONFAILED"))
assert (await _account(account_id)).state == "live"
await health.failed(ProviderAuthError("AUTHENTICATIONFAILED"))
account = await _account(account_id)
assert account.state == "auth_failed" and "AUTHENTICATIONFAILED" in account.state_detail