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:
@@ -101,6 +101,9 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
sync_window=a.sync_window,
|
||||
backfill_total=a.backfill_total,
|
||||
backfill_done=a.backfill_done,
|
||||
sync_error=a.sync_error,
|
||||
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
|
||||
state_detail=a.state_detail,
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
@@ -16,10 +16,12 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message
|
||||
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
|
||||
from app.outbox.jobs import RETRY_WINDOW
|
||||
from app.outbox.schemas import (
|
||||
DraftIn,
|
||||
DraftOut,
|
||||
OutboxItemOut,
|
||||
RemoteDraftOut,
|
||||
SendRequest,
|
||||
SendResponse,
|
||||
@@ -29,6 +31,7 @@ from app.outbox.service import (
|
||||
cancel_outbox,
|
||||
create_outbox,
|
||||
delete_mirrored_draft_row,
|
||||
not_being_sent,
|
||||
upsert_draft,
|
||||
)
|
||||
from app.queue import enqueue_job
|
||||
@@ -109,7 +112,9 @@ def _draft_out(draft: Draft) -> DraftOut:
|
||||
@router.get("/drafts", response_model=list[DraftOut])
|
||||
async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]:
|
||||
drafts = await db.scalars(
|
||||
select(Draft).where(Draft.user_id == user.id).order_by(Draft.updated_at.desc())
|
||||
select(Draft)
|
||||
.where(Draft.user_id == user.id, not_being_sent())
|
||||
.order_by(Draft.updated_at.desc())
|
||||
)
|
||||
return [_draft_out(d) for d in drafts]
|
||||
|
||||
@@ -313,3 +318,72 @@ async def undo_send(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> d
|
||||
if not ok:
|
||||
raise_api_error(409, "undo_too_late", "the message has already left the undo window")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
# --- the Scheduled folder's server side: sends being retried, or failed ------------
|
||||
|
||||
|
||||
@router.get("/outbox", response_model=list[OutboxItemOut])
|
||||
async def list_outbox(user: CurrentUser, db: DbSession) -> list[OutboxItemOut]:
|
||||
"""Sends that did not go out: retrying (the server could not take them yet)
|
||||
or failed for good. They wait in Scheduled, not in Drafts."""
|
||||
rows = await db.execute(
|
||||
select(Outbox, Draft)
|
||||
.join(Draft, Draft.id == Outbox.draft_id)
|
||||
.where(Outbox.user_id == user.id, Outbox.state.in_(("retrying", "failed")))
|
||||
.order_by(Outbox.send_after.desc())
|
||||
)
|
||||
out: list[OutboxItemOut] = []
|
||||
for ob, draft in rows.tuples():
|
||||
payload = draft.payload or {}
|
||||
to = payload.get("to")
|
||||
out.append(
|
||||
OutboxItemOut(
|
||||
outbox_id=ob.id,
|
||||
draft_id=draft.id,
|
||||
account_id=draft.account_id,
|
||||
state=ob.state,
|
||||
attempts=ob.attempts,
|
||||
error=ob.error,
|
||||
next_attempt_at=ob.next_attempt_at.isoformat() if ob.next_attempt_at else None,
|
||||
retry_until=ob.retry_until.isoformat() if ob.retry_until else None,
|
||||
sent_at=ob.send_after.isoformat(),
|
||||
to=[str(t) for t in to] if isinstance(to, list) else [],
|
||||
subject=str(payload.get("subject") or ""),
|
||||
preview=str(payload.get("text") or "")[:200],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _waiting_send(db: AsyncSession, user_id: uuid.UUID, outbox_id: uuid.UUID) -> Outbox:
|
||||
ob = await db.scalar(select(Outbox).where(Outbox.id == outbox_id, Outbox.user_id == user_id))
|
||||
if ob is None or ob.state not in ("retrying", "failed"):
|
||||
raise_api_error(409, "not_waiting", "this message is not waiting to be sent")
|
||||
return ob
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/retry")
|
||||
async def retry_outbox(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
"""Retry now. A send that had failed for good gets a fresh 24-hour window."""
|
||||
ob = await _waiting_send(db, user.id, outbox_id)
|
||||
now = datetime.now(UTC)
|
||||
if ob.state == "failed" or ob.retry_until is None or ob.retry_until <= now:
|
||||
ob.retry_until = now + RETRY_WINDOW
|
||||
ob.state = "retrying"
|
||||
ob.next_attempt_at = now
|
||||
await db.commit()
|
||||
await enqueue_job("send_job", str(ob.id), _job_id=f"send:{ob.id}:now:{ob.attempts}")
|
||||
return {"status": "retrying"}
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/cancel")
|
||||
async def cancel_waiting_send(
|
||||
outbox_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
"""Stop trying: the message goes back to Drafts (Edit, or before Delete)."""
|
||||
ob = await _waiting_send(db, user.id, outbox_id)
|
||||
ob.state = "cancelled"
|
||||
ob.next_attempt_at = None
|
||||
await db.commit()
|
||||
return {"status": "cancelled", "draft_id": str(ob.draft_id)}
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Draft, Mailbox, Message
|
||||
from app.outbox.service import not_being_sent
|
||||
|
||||
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
||||
|
||||
@@ -58,7 +59,9 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
|
||||
|
||||
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
|
||||
# UIDs excluded), matching what list_drafts + list_remote_drafts return.
|
||||
local = await db.scalar(select(func.count()).select_from(Draft).where(Draft.user_id == user.id))
|
||||
local = await db.scalar(
|
||||
select(func.count()).select_from(Draft).where(Draft.user_id == user.id, not_being_sent())
|
||||
)
|
||||
owned = {
|
||||
(r.account_id, r.imap_uid)
|
||||
for r in await db.execute(
|
||||
|
||||
@@ -97,6 +97,11 @@ class AccountOut(BaseModel):
|
||||
sync_window: str = "all"
|
||||
backfill_total: int = 0
|
||||
backfill_done: int = 0
|
||||
# "unreachable" while the mail server has not answered for a while.
|
||||
sync_error: str | None = None
|
||||
sync_error_since: str | None = None
|
||||
# Why the state is what it is (e.g. the server's reply to a refused sign-in).
|
||||
state_detail: str = ""
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user