Files
webmail-client/backend/app/api/accounts.py
T

177 lines
5.8 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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,
)