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,
|
2026-08-04 19:59:52 +02:00
|
|
|
HelpBlockOut,
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|
2026-08-04 19:59:52 +02:00
|
|
|
from app.config import get_settings
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.db import get_session
|
|
|
|
|
from app.identity.deps import CurrentUser, require_csrf
|
|
|
|
|
from app.models import Account
|
2026-07-18 19:53:31 +02:00
|
|
|
from app.queue import enqueue_job
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.redis_client import get_redis
|
2026-07-18 15:27:09 +02:00
|
|
|
from app.security.crypto import encrypt_secret
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-08-04 19:59:52 +02:00
|
|
|
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,
|
2026-08-05 18:04:49 +02:00
|
|
|
setup_link=c.help.setup_link,
|
|
|
|
|
button_after_step=c.help.button_after_step,
|
2026-08-04 19:59:52 +02:00
|
|
|
)
|
|
|
|
|
if c.help
|
|
|
|
|
else None
|
|
|
|
|
),
|
2026-07-17 23:20:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-07-18 15:27:09 +02:00
|
|
|
id=a.id,
|
|
|
|
|
email=a.email,
|
|
|
|
|
display_name=a.display_name,
|
|
|
|
|
color=a.color,
|
|
|
|
|
state=a.state,
|
|
|
|
|
signature=a.signature,
|
2026-07-23 23:00:08 +02:00
|
|
|
sync_window=a.sync_window,
|
|
|
|
|
backfill_total=a.backfill_total,
|
|
|
|
|
backfill_done=a.backfill_done,
|
2026-10-05 22:08:42 +02:00
|
|
|
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,
|
2026-07-17 23:20:34 +02:00
|
|
|
)
|
|
|
|
|
for a in accounts
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
@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:
|
2026-07-18 19:53:31 +02:00
|
|
|
await enqueue_job("refresh_account_job", str(account_id))
|
2026-07-18 15:27:09 +02:00
|
|
|
return {"status": "queued"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
@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
|
|
|
|
|
],
|
2026-08-04 19:59:52 +02:00
|
|
|
google_oauth_enabled=get_settings().google_oauth_enabled,
|
2026-07-17 23:20:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()
|
|
|
|
|
|
2026-07-18 19:53:31 +02:00
|
|
|
await enqueue_job("sync_bootstrap_account", str(account.id))
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
return _detail(account)
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
@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")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
@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
|
2026-07-18 15:27:09 +02:00
|
|
|
if body.signature is not None:
|
|
|
|
|
account.signature = body.signature
|
|
|
|
|
|
2026-07-23 23:00:08 +02:00
|
|
|
# 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"
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
# 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"
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
await db.commit()
|
2026-07-18 15:27:09 +02:00
|
|
|
|
2026-07-23 23:00:08 +02:00
|
|
|
if conn_change or broaden:
|
2026-07-18 19:53:31 +02:00
|
|
|
await enqueue_job("sync_bootstrap_account", str(account.id))
|
2026-07-18 15:27:09 +02:00
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
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,
|
2026-07-18 15:27:09 +02:00
|
|
|
signature=account.signature,
|
2026-07-23 23:00:08 +02:00
|
|
|
sync_window=account.sync_window,
|
|
|
|
|
backfill_total=account.backfill_total,
|
|
|
|
|
backfill_done=account.backfill_done,
|
2026-07-17 23:20:34 +02:00
|
|
|
imap_config=account.imap_config,
|
|
|
|
|
smtp_config=account.smtp_config,
|
|
|
|
|
)
|