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>
This commit is contained in:
@@ -26,7 +26,9 @@ from app.providers.base import (
|
||||
MailProvider,
|
||||
ProviderAuthError,
|
||||
ProviderConnectError,
|
||||
ProviderError,
|
||||
ProviderNotSupported,
|
||||
ProviderRejectedError,
|
||||
RemoteMailbox,
|
||||
RemoteMessage,
|
||||
)
|
||||
@@ -43,7 +45,10 @@ _FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])"
|
||||
# Gmail (X-GM-EXT-1): also pull each message's labels so custom labels can map to
|
||||
# chips without visiting every label folder (SPEC: Gmail sync / no-duplication).
|
||||
_FETCH_ITEMS_GM = "(UID FLAGS INTERNALDATE RFC822.SIZE X-GM-LABELS BODY.PEEK[])"
|
||||
_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min
|
||||
# SPEC §5.1 asks for IDLE to be re-issued at least every 25 min; 10 also means a
|
||||
# connection that died silently (no FIN, e.g. the network dropped) is noticed
|
||||
# within ~11 min — the re-issue finds it — instead of ~29.
|
||||
_IDLE_TIMEOUT = 10 * 60
|
||||
_CONNECT_TIMEOUT = 20
|
||||
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
|
||||
_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)")
|
||||
@@ -51,6 +56,52 @@ _COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)")
|
||||
_IMAP_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
|
||||
class _KeepConnectTask:
|
||||
"""aioimaplib starts the TCP connection as a task it never awaits: a refused
|
||||
or unreachable server then surfaces only as a 20 s greeting timeout, plus a
|
||||
"Task exception was never retrieved" traceback in the log. Keep the task, so
|
||||
``_connect`` can await it — failing at once, with the real reason."""
|
||||
|
||||
connect_task: asyncio.Task[object]
|
||||
|
||||
def create_client( # type: ignore[no-untyped-def]
|
||||
self, host, port, loop, conn_lost_cb=None, ssl_context=None
|
||||
) -> None:
|
||||
local_loop = loop if loop is not None else asyncio.get_running_loop()
|
||||
self.protocol = aioimaplib.IMAP4ClientProtocol(local_loop, conn_lost_cb)
|
||||
self.connect_task = local_loop.create_task(
|
||||
local_loop.create_connection(lambda: self.protocol, host, port, ssl=ssl_context)
|
||||
)
|
||||
|
||||
|
||||
class _IMAP4(_KeepConnectTask, aioimaplib.IMAP4): # type: ignore[misc]
|
||||
pass
|
||||
|
||||
|
||||
class _IMAP4_SSL(_KeepConnectTask, aioimaplib.IMAP4_SSL): # type: ignore[misc] # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
def _smtp_error(exc: aiosmtplib.SMTPException) -> ProviderError:
|
||||
"""An SMTP failure as the provider error that says whether to try again.
|
||||
|
||||
A 5xx reply is the server's final word (recipient unknown, message refused):
|
||||
``ProviderRejectedError``, or ``ProviderAuthError`` for a refused login. Any
|
||||
4xx reply, a timeout or a dropped connection is temporary:
|
||||
``ProviderConnectError``, and the send is retried later.
|
||||
"""
|
||||
if isinstance(exc, aiosmtplib.SMTPRecipientsRefused):
|
||||
codes = [r.code for r in exc.recipients]
|
||||
if codes and all(c >= 500 for c in codes):
|
||||
return ProviderRejectedError(f"recipients refused: {exc}")
|
||||
return ProviderConnectError(f"SMTP send failed: {exc}")
|
||||
if isinstance(exc, aiosmtplib.SMTPResponseException) and exc.code >= 500:
|
||||
if isinstance(exc, aiosmtplib.SMTPAuthenticationError):
|
||||
return ProviderAuthError(f"SMTP auth failed: {exc}")
|
||||
return ProviderRejectedError(f"SMTP refused: {exc}")
|
||||
return ProviderConnectError(f"SMTP send failed: {exc}")
|
||||
|
||||
|
||||
def _imap_date(d: date) -> str:
|
||||
"""RFC 3501 search date, e.g. ``5-Jul-2026`` (locale-independent)."""
|
||||
return f"{d.day}-{_IMAP_MONTHS[d.month - 1]}-{d.year}"
|
||||
@@ -133,19 +184,22 @@ class ImapSmtpProvider(MailProvider):
|
||||
await self._acquire_conn_slots()
|
||||
try:
|
||||
try:
|
||||
imap: aioimaplib.IMAP4
|
||||
if cfg.imap_tls == "ssl":
|
||||
imap = aioimaplib.IMAP4_SSL(
|
||||
imap = _IMAP4_SSL(
|
||||
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
|
||||
)
|
||||
else:
|
||||
imap = aioimaplib.IMAP4(
|
||||
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
|
||||
)
|
||||
imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT)
|
||||
# Refused / unknown host: fails here at once. No answer at all:
|
||||
# gives up after the timeout (the connect attempt is cancelled).
|
||||
await asyncio.wait_for(imap.connect_task, _CONNECT_TIMEOUT)
|
||||
await imap.wait_hello_from_server()
|
||||
if cfg.imap_tls == "starttls":
|
||||
await imap.starttls()
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {exc}") from exc
|
||||
why = str(exc) or f"no answer within {_CONNECT_TIMEOUT} s"
|
||||
raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {why}") from exc
|
||||
|
||||
resp = await imap.login(cfg.username, cfg.password)
|
||||
if resp.result != "OK":
|
||||
@@ -391,7 +445,10 @@ class ImapSmtpProvider(MailProvider):
|
||||
await self._select(mailbox)
|
||||
idle_task = await imap.idle_start(timeout=_IDLE_TIMEOUT)
|
||||
try:
|
||||
await imap.wait_server_push()
|
||||
# No word from the server even after IDLE's own end: just return, and
|
||||
# the caller's poll shows whether the connection is still there.
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await imap.wait_server_push(timeout=_IDLE_TIMEOUT + 60)
|
||||
finally:
|
||||
imap.idle_done()
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -473,7 +530,5 @@ class ImapSmtpProvider(MailProvider):
|
||||
start_tls=start_tls,
|
||||
timeout=_CONNECT_TIMEOUT,
|
||||
)
|
||||
except aiosmtplib.SMTPAuthenticationError as exc:
|
||||
raise ProviderAuthError(f"SMTP auth failed: {exc}") from exc
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise ProviderConnectError(f"SMTP send failed: {exc}") from exc
|
||||
raise _smtp_error(exc) from exc
|
||||
|
||||
Reference in New Issue
Block a user