Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user