"""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, ) 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, )