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>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""Accounts API (SPEC §9.1): discover, test, create, list, update, delete."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.accounts.discovery import discover
|
|
from app.accounts.schemas import (
|
|
AccountDetailOut,
|
|
CandidateOut,
|
|
CreateAccountRequest,
|
|
DiscoverRequest,
|
|
DiscoverResponse,
|
|
DiscoveryStepOut,
|
|
HelpBlockOut,
|
|
ServerConfigIn,
|
|
TestRequest,
|
|
TestResponse,
|
|
UpdateAccountRequest,
|
|
)
|
|
from app.accounts.service import AccountExistsError, create_account, delete_account
|
|
from app.accounts.testing import AccountTestError, test_account
|
|
from app.accounts.types import CandidateConfig, ServerConfig
|
|
from app.api.errors import raise_api_error
|
|
from app.api.schemas import AccountOut
|
|
from app.config import get_settings
|
|
from app.db import get_session
|
|
from app.identity.deps import CurrentUser, require_csrf
|
|
from app.models import Account
|
|
from app.queue import enqueue_job
|
|
from app.redis_client import get_redis
|
|
from app.security.crypto import encrypt_secret
|
|
|
|
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
_DISCOVER_LIMIT = 10 # per minute per user (SPEC §10.5)
|
|
_DISCOVER_WINDOW = 60
|
|
|
|
|
|
async def _rate_limit_discover(user_id: uuid.UUID) -> None:
|
|
redis = get_redis()
|
|
key = f"rl:discover:{user_id}"
|
|
count = await redis.incr(key)
|
|
if count == 1:
|
|
await redis.expire(key, _DISCOVER_WINDOW)
|
|
if count > _DISCOVER_LIMIT:
|
|
raise_api_error(429, "rate_limited", "too many discovery requests; slow down")
|
|
|
|
|
|
def _server_in(cfg: ServerConfig) -> ServerConfigIn:
|
|
return ServerConfigIn(host=cfg.host, port=cfg.port, tls=cfg.tls, auth=cfg.auth)
|
|
|
|
|
|
def _candidate_out(c: CandidateConfig) -> CandidateOut:
|
|
return CandidateOut(
|
|
imap=_server_in(c.imap),
|
|
smtp=_server_in(c.smtp),
|
|
source=c.source,
|
|
oauth_provider=c.oauth_provider,
|
|
auth_strategy=list(c.auth_strategy),
|
|
help=(
|
|
HelpBlockOut(
|
|
help_id=c.help.help_id,
|
|
deep_link=c.help.deep_link,
|
|
app_name_hint=c.help.app_name_hint,
|
|
steps=list(c.help.steps),
|
|
docs_url=c.help.docs_url,
|
|
setup_link=c.help.setup_link,
|
|
button_after_step=c.help.button_after_step,
|
|
)
|
|
if c.help
|
|
else None
|
|
),
|
|
)
|
|
|
|
|
|
def _to_server_config(cfg: ServerConfigIn) -> ServerConfig:
|
|
return ServerConfig(cfg.host, cfg.port, cfg.tls, cfg.auth)
|
|
|
|
|
|
@router.get("", response_model=list[AccountOut])
|
|
async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
|
accounts = await db.scalars(
|
|
select(Account).where(Account.user_id == user.id).order_by(Account.created_at)
|
|
)
|
|
return [
|
|
AccountOut(
|
|
id=a.id,
|
|
email=a.email,
|
|
display_name=a.display_name,
|
|
color=a.color,
|
|
state=a.state,
|
|
signature=a.signature,
|
|
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
|
|
]
|
|
|
|
|
|
@router.post("/sync", dependencies=[Depends(require_csrf)])
|
|
async def sync_all_accounts(user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
"""Poll every folder of each of the user's accounts (manual Refresh)."""
|
|
account_ids = list(await db.scalars(select(Account.id).where(Account.user_id == user.id)))
|
|
for account_id in account_ids:
|
|
await enqueue_job("refresh_account_job", str(account_id))
|
|
return {"status": "queued"}
|
|
|
|
|
|
@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)])
|
|
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
|
|
await _rate_limit_discover(user.id)
|
|
result = await discover(str(body.email))
|
|
return DiscoverResponse(
|
|
candidates=[_candidate_out(c) for c in result.candidates],
|
|
steps=[
|
|
DiscoveryStepOut(method=s.method, status=s.status, detail=s.detail)
|
|
for s in result.steps
|
|
],
|
|
google_oauth_enabled=get_settings().google_oauth_enabled,
|
|
)
|
|
|
|
|
|
@router.post("/test", response_model=TestResponse, dependencies=[Depends(require_csrf)])
|
|
async def test_account_credentials(body: TestRequest, _user: CurrentUser) -> TestResponse:
|
|
try:
|
|
await test_account(
|
|
imap=_to_server_config(body.imap),
|
|
smtp=_to_server_config(body.smtp),
|
|
username=body.username or str(body.email),
|
|
password=body.password,
|
|
)
|
|
except AccountTestError as exc:
|
|
raise_api_error(400, exc.code, exc.detail)
|
|
return TestResponse(ok=True)
|
|
|
|
|
|
@router.post("", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)])
|
|
async def create_new_account(
|
|
body: CreateAccountRequest, user: CurrentUser, db: DbSession
|
|
) -> AccountDetailOut:
|
|
# Validate credentials before persisting (also the §10.2 decryption sibling).
|
|
try:
|
|
await test_account(
|
|
imap=_to_server_config(body.imap),
|
|
smtp=_to_server_config(body.smtp),
|
|
username=body.username or str(body.email),
|
|
password=body.password,
|
|
)
|
|
except AccountTestError as exc:
|
|
raise_api_error(400, exc.code, exc.detail)
|
|
|
|
try:
|
|
account = await create_account(db, user_id=user.id, req=body)
|
|
except AccountExistsError:
|
|
raise_api_error(409, "account_exists", "an account for this email already exists")
|
|
await db.commit()
|
|
|
|
await enqueue_job("sync_bootstrap_account", str(account.id))
|
|
|
|
return _detail(account)
|
|
|
|
|
|
@router.get("/{account_id}", response_model=AccountDetailOut)
|
|
async def get_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> AccountDetailOut:
|
|
account = await db.scalar(
|
|
select(Account).where(Account.id == account_id, Account.user_id == user.id)
|
|
)
|
|
if account is None:
|
|
raise_api_error(404, "not_found", "account not found")
|
|
return _detail(account)
|
|
|
|
|
|
def _server_from_config(cfg: dict[str, object]) -> ServerConfig:
|
|
return ServerConfig(
|
|
str(cfg["host"]),
|
|
int(str(cfg["port"])),
|
|
str(cfg.get("tls", "ssl")),
|
|
str(cfg.get("auth", "password")),
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
|
|
)
|
|
async def update_account(
|
|
account_id: uuid.UUID, body: UpdateAccountRequest, user: CurrentUser, db: DbSession
|
|
) -> AccountDetailOut:
|
|
account = await db.scalar(
|
|
select(Account).where(Account.id == account_id, Account.user_id == user.id)
|
|
)
|
|
if account is None:
|
|
raise_api_error(404, "not_found", "account not found")
|
|
if body.display_name is not None:
|
|
account.display_name = body.display_name
|
|
if body.color is not None:
|
|
account.color = body.color
|
|
if body.signature is not None:
|
|
account.signature = body.signature
|
|
|
|
# Broadening the sync window (e.g. "new" -> "all", "Download all mail") means
|
|
# importing history not fetched before, so re-run the backfill (SPEC §12 M2).
|
|
# persist_message is idempotent on (mailbox_id, uid), so already-synced mail
|
|
# is skipped. Narrowing keeps existing mail — nothing to fetch.
|
|
_COVERAGE = {"new": 0, "3months": 1, "all": 2}
|
|
broaden = False
|
|
if body.sync_window is not None and body.sync_window != account.sync_window:
|
|
broaden = _COVERAGE[body.sync_window] > _COVERAGE.get(account.sync_window, 0)
|
|
account.sync_window = body.sync_window
|
|
if broaden:
|
|
account.state = "backfilling"
|
|
|
|
# Connection settings and/or password: re-validate against the servers, then
|
|
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
|
|
# stored secret is only decrypted in the worker, never here).
|
|
conn_change = body.imap is not None or body.smtp is not None or body.password is not None
|
|
if conn_change:
|
|
if not body.password:
|
|
raise_api_error(
|
|
400,
|
|
"password_required",
|
|
"the mailbox password is required to change server settings",
|
|
)
|
|
imap = (
|
|
_to_server_config(body.imap) if body.imap else _server_from_config(account.imap_config)
|
|
)
|
|
smtp = (
|
|
_to_server_config(body.smtp) if body.smtp else _server_from_config(account.smtp_config)
|
|
)
|
|
try:
|
|
await test_account(imap=imap, smtp=smtp, username=account.email, password=body.password)
|
|
except AccountTestError as exc:
|
|
raise_api_error(400, exc.code, exc.detail)
|
|
account.imap_config = {
|
|
"host": imap.host,
|
|
"port": imap.port,
|
|
"tls": imap.tls,
|
|
"auth": imap.auth,
|
|
}
|
|
account.smtp_config = {
|
|
"host": smtp.host,
|
|
"port": smtp.port,
|
|
"tls": smtp.tls,
|
|
"auth": smtp.auth,
|
|
}
|
|
account.secret_ct = encrypt_secret(body.password.encode("utf-8"))
|
|
account.state = "backfilling"
|
|
|
|
await db.commit()
|
|
|
|
if conn_change or broaden:
|
|
await enqueue_job("sync_bootstrap_account", str(account.id))
|
|
|
|
return _detail(account)
|
|
|
|
|
|
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
|
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
ok = await delete_account(db, user_id=user.id, account_id=account_id)
|
|
if not ok:
|
|
raise_api_error(404, "not_found", "account not found")
|
|
await db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
def _detail(account: Account) -> AccountDetailOut:
|
|
return AccountDetailOut(
|
|
id=account.id,
|
|
email=account.email,
|
|
display_name=account.display_name,
|
|
color=account.color,
|
|
state=account.state,
|
|
state_detail=account.state_detail,
|
|
signature=account.signature,
|
|
sync_window=account.sync_window,
|
|
backfill_total=account.backfill_total,
|
|
backfill_done=account.backfill_done,
|
|
imap_config=account.imap_config,
|
|
smtp_config=account.smtp_config,
|
|
)
|