"""An account whose mail server is unreachable, or refuses the password.""" from __future__ import annotations import contextlib 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 async def test_ssl_connections_really_use_tls(monkeypatch) -> None: # type: ignore[no-untyped-def] """Regression (v1.3.26): our IMAP4_SSL subclass skipped aioimaplib's TLS setup, so port 993 got plain TCP and every account timed out.""" import asyncio import ssl from app.providers.imap_smtp.provider import _IMAP4, _IMAP4_SSL seen: list[object] = [] loop = asyncio.get_running_loop() async def fake_create_connection(factory, host, port, ssl=None): # type: ignore[no-untyped-def] seen.append(ssl) raise ConnectionRefusedError monkeypatch.setattr(loop, "create_connection", fake_create_connection) for cls in (_IMAP4_SSL, _IMAP4): client = cls(host="imap.example.com", port=993, timeout=1) with contextlib.suppress(ConnectionRefusedError): await client.connect_task assert isinstance(seen[0], ssl.SSLContext) # SSL: a TLS context assert seen[1] is None # plain / STARTTLS: none up front