"""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, 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.db import get_session from app.identity.deps import CurrentUser, require_csrf from app.models import Account from app.queue import get_arq_pool from app.redis_client import get_redis 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, ) 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 ) for a in accounts ] @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 ], ) @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() pool = await get_arq_pool() await pool.enqueue_job("sync_bootstrap_account", str(account.id)) return _detail(account) @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 await db.commit() 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, imap_config=account.imap_config, smtp_config=account.smtp_config, )