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:
+39
-10
@@ -18,10 +18,11 @@ from app.config import get_settings
|
||||
from app.events import get_event_bus
|
||||
from app.indexer.indexer import PersistResult, persist_message
|
||||
from app.models import Account, Draft, Mailbox, Message
|
||||
from app.providers.base import MailboxDelta, ProviderNotSupported
|
||||
from app.providers.base import MailboxDelta, ProviderAuthError, ProviderNotSupported
|
||||
from app.providers.imap_smtp import ImapSmtpProvider
|
||||
from app.search.factory import get_search_backend
|
||||
from app.sync.folder_names import infer_special_use
|
||||
from app.sync.health import AccountHealth, is_temporary
|
||||
from app.sync.provider_factory import build_provider
|
||||
from app.threading_.threader import denormalize_thread
|
||||
|
||||
@@ -58,6 +59,12 @@ async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> Non
|
||||
await session.execute(
|
||||
update(Account).where(Account.id == account_id).values(state=state, state_detail=detail)
|
||||
)
|
||||
if state == "live": # (re)connected and synced: no sync problem any more
|
||||
await session.execute(
|
||||
update(Account)
|
||||
.where(Account.id == account_id)
|
||||
.values(sync_error=None, sync_error_since=None)
|
||||
)
|
||||
# Keep the sync:live registry in step with the account's state so the sync
|
||||
# coordinator claims it once live and drops it otherwise (refactor §2).
|
||||
from app.redis_client import get_redis
|
||||
@@ -504,7 +511,10 @@ async def _reconcile_drafts(
|
||||
|
||||
|
||||
async def live_loop(
|
||||
account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None
|
||||
account_id: uuid.UUID,
|
||||
mailbox_path: str = "INBOX",
|
||||
stop: asyncio.Event | None = None,
|
||||
health: AccountHealth | None = None,
|
||||
) -> None:
|
||||
"""Maintain a live IDLE/poll loop for one mailbox (SPEC §6.3)."""
|
||||
from app.db import session_scope
|
||||
@@ -518,6 +528,9 @@ async def live_loop(
|
||||
|
||||
provider = build_provider(account_obj)
|
||||
try:
|
||||
await provider.verify() # signed in: the account is reachable
|
||||
if health is not None:
|
||||
await health.ok()
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
@@ -544,7 +557,9 @@ async def live_loop(
|
||||
await provider.close()
|
||||
|
||||
|
||||
async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> None:
|
||||
async def poll_folders_once(
|
||||
account_id: uuid.UUID, *, include_inbox: bool, health: AccountHealth | None = None
|
||||
) -> None:
|
||||
"""One CONDSTORE poll pass over an account's special-use folders.
|
||||
|
||||
``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic
|
||||
@@ -586,14 +601,22 @@ async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> No
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# Server gone, or the sign-in refused: the other folders would only
|
||||
# fail the same way (20 s each). End the pass; the caller decides.
|
||||
if is_temporary(exc) or isinstance(exc, ProviderAuthError):
|
||||
raise
|
||||
logger.exception("poll failed: account=%s mailbox=%s", account_id, mailbox_obj.path)
|
||||
if health is not None:
|
||||
await health.ok()
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
|
||||
async def _poll_secondary_once(account_id: uuid.UUID) -> None:
|
||||
await poll_folders_once(account_id, include_inbox=False)
|
||||
async def _poll_secondary_once(
|
||||
account_id: uuid.UUID, health: AccountHealth | None = None
|
||||
) -> None:
|
||||
await poll_folders_once(account_id, include_inbox=False, health=health)
|
||||
|
||||
|
||||
async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
|
||||
@@ -602,7 +625,10 @@ async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
|
||||
|
||||
|
||||
async def secondary_poll_loop(
|
||||
account_id: uuid.UUID, stop: asyncio.Event | None = None, interval: float | None = None
|
||||
account_id: uuid.UUID,
|
||||
stop: asyncio.Event | None = None,
|
||||
interval: float | None = None,
|
||||
health: AccountHealth | None = None,
|
||||
) -> None:
|
||||
"""Periodically CONDSTORE-poll non-INBOX folders (Sent/Drafts/Archive/Trash/Junk).
|
||||
|
||||
@@ -624,11 +650,14 @@ async def secondary_poll_loop(
|
||||
await asyncio.wait_for(stop.wait(), timeout=wait)
|
||||
if stop.is_set():
|
||||
break
|
||||
await _poll_secondary_once(account_id)
|
||||
await _poll_secondary_once(account_id, health)
|
||||
|
||||
|
||||
async def poll_all_loop(
|
||||
account_id: uuid.UUID, interval: float, stop: asyncio.Event | None = None
|
||||
account_id: uuid.UUID,
|
||||
interval: float,
|
||||
stop: asyncio.Event | None = None,
|
||||
health: AccountHealth | None = None,
|
||||
) -> None:
|
||||
"""Transient CONDSTORE poll of INBOX + special folders every ``interval`` seconds.
|
||||
|
||||
@@ -646,4 +675,4 @@ async def poll_all_loop(
|
||||
await asyncio.wait_for(stop.wait(), timeout=wait)
|
||||
if stop.is_set():
|
||||
break
|
||||
await poll_folders_once(account_id, include_inbox=True)
|
||||
await poll_folders_once(account_id, include_inbox=True, health=health)
|
||||
|
||||
Reference in New Issue
Block a user