Files
webmail-client/backend/app/api/accounts.py
T
lukasandClaude Opus 5.5 b88855931e M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:25:10 +02:00

306 lines
11 KiB
Python

"""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,
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,
kind=a.kind,
)
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 and account.kind == "native":
raise_api_error(400, "native_account", "the settings of your own mailbox cannot be changed")
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]:
kind = await db.scalar(
select(Account.kind).where(Account.id == account_id, Account.user_id == user.id)
)
if kind == "native":
raise_api_error(409, "native_account", "your own mailbox cannot be removed")
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()
# Removing an account for the user's own address (the mail at its old
# provider) makes room for their hosted mailbox's (App. D §D.4).
from app.hosted import native
await native.ensure_and_sync(db, user)
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,
kind=account.kind,
)