"""An account's connection health. Telling "the mail server is not there right now" from everything else. ``is_temporary`` decides whether a failure is worth another try later: the server could not be reached, timed out, dropped the connection, or answered "not now" (SMTP 4xx). Sends, user actions and the sync loops all retry on it, and only on it — a refused password or a permanent 5xx is not retried. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from aioimaplib import aioimaplib from sqlalchemy import update from app.providers.base import ProviderAuthError, ProviderConnectError logger = logging.getLogger(__name__) def is_temporary(exc: BaseException) -> bool: if isinstance(exc, (ProviderConnectError, TimeoutError, OSError)): return True # aioimaplib's own: a command that timed out, or a reply cut off. if isinstance(exc, (aioimaplib.CommandTimeout, aioimaplib.IncompleteRead)): return True # After the connection drops, aioimaplib refuses every further command as # "illegal in state LOGOUT" — the connection is gone, not the command wrong. return isinstance(exc, aioimaplib.Abort) and "illegal in state" in str(exc) # Failed connections in a row before an account shows as unreachable, and # refused sign-ins in a row before its syncing stops ("auth_failed": retrying a # wrong password only gets the account locked by the provider). Changing the # password in Settings re-runs the initial sync, which sets it live again. UNREACHABLE_AFTER = 3 AUTH_GIVE_UP_AFTER = 3 class AccountHealth: """One account's run of failures, shared by its sync loops.""" def __init__(self, account_id: uuid.UUID) -> None: self.account_id = account_id self.failures = 0 self.auth_failures = 0 # Whether the account is marked unreachable; None = not known yet (a # mark can survive a worker restart), so the first success checks. self.marked: bool | None = None # Set once the sign-in was refused too often: the account is no longer # synced (state auth_failed), so its loops should stop now. self.gave_up = False async def ok(self) -> None: """The server answered: reset the counts, and clear an unreachable mark.""" self.failures = 0 self.auth_failures = 0 if self.marked is not False: await _set_sync_error(self.account_id, None) self.marked = False async def failed(self, exc: BaseException) -> None: if isinstance(exc, ProviderAuthError): self.auth_failures += 1 if self.auth_failures >= AUTH_GIVE_UP_AFTER: from app.sync.engine import _set_state logger.warning( "account %s: sign-in refused %d times; stopping sync", self.account_id, self.auth_failures, ) await _set_state(self.account_id, "auth_failed", exc.detail[:500]) self.gave_up = True return if not is_temporary(exc): return self.failures += 1 if self.failures >= UNREACHABLE_AFTER and self.marked is not True: await _set_sync_error(self.account_id, "unreachable") self.marked = True async def _set_sync_error(account_id: uuid.UUID, error: str | None) -> None: """Mark (or clear) an account's sync problem; tell the user's open apps.""" from app.db import session_scope from app.events import get_event_bus from app.models import Account now = datetime.now(UTC) async with session_scope() as session: cond = Account.sync_error.isnot(None) if error is None else Account.sync_error.is_(None) user_id = await session.scalar( update(Account) .where(Account.id == account_id, cond) .values(sync_error=error, sync_error_since=None if error is None else now) .returning(Account.user_id) ) if user_id is None: return # nothing changed logger.warning("account %s: %s", account_id, error or "reachable again") await get_event_bus().publish( "account.state_changed", {"user_id": str(user_id), "account_id": str(account_id), "sync_error": error}, )