Files
webmail-client/backend/app/api/schemas.py
T
lukasandClaude Opus 5.5 609e7c58f9 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>
2026-10-05 22:08:42 +02:00

115 lines
2.6 KiB
Python

"""Shared API response models (SPEC §9.1)."""
from __future__ import annotations
import datetime as dt
import uuid
from pydantic import BaseModel
class Address(BaseModel):
name: str = ""
email: str = ""
class ThreadSummary(BaseModel):
id: uuid.UUID
subject: str
snippet: str
participants: list[Address]
# Distinct From addresses of the thread's visible messages, oldest first —
# what the list's sender column shows (recipients excluded).
senders: list[Address] = []
account_ids: list[uuid.UUID]
message_count: int
unread_count: int
has_attachments: bool
pinned: bool
last_message_at: dt.datetime
class ThreadListOut(BaseModel):
threads: list[ThreadSummary]
cursor: str | None = None
total: int = 0
class AttachmentOut(BaseModel):
id: uuid.UUID
filename: str
mime_type: str
size_bytes: int
class MessageEnvelope(BaseModel):
id: uuid.UUID
account_id: uuid.UUID
mailbox_id: uuid.UUID
thread_id: uuid.UUID | None
subject: str
from_addr: list[Address]
to_addr: list[Address]
cc_addr: list[Address]
bcc_addr: list[Address]
date: dt.datetime
flags: list[str]
is_unread: bool
is_starred: bool
has_attachments: bool
has_remote_images: bool
# The sender is on the user's trusted list, so remote images load without a prompt.
images_allowed: bool = False
size_bytes: int
snippet: str
# Other accounts this same delivery also landed in (collapsed duplicates).
also_in_account_ids: list[uuid.UUID] = []
class ThreadDetail(BaseModel):
id: uuid.UUID
subject: str
order: str # "asc" | "desc"
pinned: bool
messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
class FlagChangeRequest(BaseModel):
add: list[str] = []
remove: list[str] = []
class MarkReadRequest(BaseModel):
view: str = "unified" # unified | folder:<special> | account:<uuid>
class MoveRequest(BaseModel):
mailbox_id: uuid.UUID
class AccountOut(BaseModel):
id: uuid.UUID
email: str
display_name: str
color: str
state: str
signature: str = ""
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):
thread: ThreadSummary
highlights: dict[str, str]
class SearchOut(BaseModel):
threads: list[SearchThreadHit]
cursor: str | None = None