"""The mail server's management API (Stalwart 0.16, JMAP ``x:`` methods). Signed in to as the recovery admin (``STALWART_RECOVERY_ADMIN`` in the mail server's environment, ``STALWART_ADMIN_SECRET`` here). Objects are Stalwart's registry: ``x:Account``, ``x:Domain``, ``x:Directory``, ``x:Role``, ``x:AppPassword``… with the usual JMAP ``/get``, ``/query``, ``/set``. """ from __future__ import annotations import base64 from typing import Any import httpx from app.config import get_settings from app.hosted import worker_address ADMIN_USER = "admin" _USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"] _TIMEOUT = 15.0 class MailServerError(Exception): """The mail server refused a call, or could not be reached.""" def _basic(user: str, secret: str) -> str: return "Basic " + base64.b64encode(f"{user}:{secret}".encode()).decode() def _admin_auth() -> str: return _basic(ADMIN_USER, get_settings().stalwart_admin_secret) async def call( *method_calls: tuple[str, dict[str, Any]], auth: str | None = None ) -> list[dict[str, Any]]: """Run JMAP method calls in one request; return each call's arguments. Raises :class:`MailServerError` on a method error or a refused ``set``.""" body = { "using": _USING, "methodCalls": [[name, args, str(i)] for i, (name, args) in enumerate(method_calls)], } try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: resp = await client.post( f"{get_settings().stalwart_url}/jmap", json=body, headers={"Authorization": auth or _admin_auth()}, ) except httpx.HTTPError as exc: raise MailServerError(f"mail server unreachable: {exc}") from exc if resp.status_code != 200: raise MailServerError(f"mail server answered {resp.status_code}: {resp.text[:300]}") out: list[dict[str, Any]] = [] for name, args, _ in resp.json()["methodResponses"]: if name == "error": raise MailServerError(f"{args.get('type')}: {args.get('description', '')}") for kind in ("notCreated", "notUpdated", "notDestroyed"): if args.get(kind): raise MailServerError(f"{name} {kind}: {args[kind]}") out.append(args) return out async def get_all(obj: str, **args: Any) -> list[dict[str, Any]]: """Every object of a type (registry objects are few).""" (res,) = await call((f"x:{obj}/get", {"ids": None, **args})) return list(res["list"]) async def get_one(obj: str, object_id: str = "singleton") -> dict[str, Any]: (res,) = await call((f"x:{obj}/get", {"ids": [object_id]})) if not res["list"]: raise MailServerError(f"{obj} {object_id} not found") return dict(res["list"][0]) async def create(obj: str, value: dict[str, Any], **args: Any) -> str: (res,) = await call((f"x:{obj}/set", {"create": {"n": value}, **args})) return str(res["created"]["n"]["id"]) async def update(obj: str, object_id: str, patch: dict[str, Any], **args: Any) -> None: await call((f"x:{obj}/set", {"update": {object_id: patch}, **args})) async def destroy(obj: str, ids: list[str], **args: Any) -> None: if ids: await call((f"x:{obj}/set", {"destroy": ids, **args})) # ---- accounts ---------------------------------------------------------------------- async def account_id(email: str) -> str | None: """The mail server's id of a mailbox, if it knows it yet. Accounts from the directory reach the server's registry the first time they are used (a sign-in, a delivery): see :func:`ensure_account`.""" local = email.split("@", 1)[0].lower() (res,) = await call(("x:Account/query", {"filter": {"name": local}})) ids = list(res.get("ids") or []) if not ids: return None for acc in (await call(("x:Account/get", {"ids": ids, "properties": ["emailAddress"]})))[0][ "list" ]: if str(acc.get("emailAddress", "")).lower() == email.lower(): return str(acc["id"]) return None async def ensure_account(email: str) -> str: """The mailbox's id, bringing it into the server's registry if new: a sign-in as the worker acting for it does that (§D.3).""" found = await account_id(email) if found is not None: return found auth = _basic(f"{email}%{worker_address()}", get_settings().stalwart_master_secret) try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: resp = await client.get( f"{get_settings().stalwart_url}/jmap/session", headers={"Authorization": auth} ) except httpx.HTTPError as exc: raise MailServerError(f"mail server unreachable: {exc}") from exc if resp.status_code != 200: raise MailServerError(f"cannot open the mailbox {email}: {resp.status_code}") found = await account_id(email) if found is None: raise MailServerError(f"mailbox {email} not found after sign-in") return found async def set_blocked(email: str, blocked: bool) -> None: """A blocked user's mailbox cannot be signed in to at all — app passwords and the worker included. Mail to it is still accepted.""" acc = await account_id(email) if acc is None: return # never used: the directory alone keeps it shut permissions: dict[str, Any] = ( {"@type": "Merge", "enabledPermissions": {}, "disabledPermissions": {"authenticate": True}} if blocked else {"@type": "Inherit"} ) await update("Account", acc, {"permissions": permissions}) async def delete_account(email: str) -> None: """Remove the mailbox and all its mail from the mail server.""" acc = await account_id(email) if acc is not None: await destroy("Account", [acc]) # ---- app passwords (§D.5) ------------------------------------------------------------- async def create_app_password(email: str, description: str) -> tuple[str, str]: """A new app password for the mailbox: (server id, the password itself).""" acc = await ensure_account(email) (res,) = await call( ("x:AppPassword/set", {"accountId": acc, "create": {"n": {"description": description}}}) ) created = res["created"]["n"] return str(created["id"]), str(created["secret"]) async def delete_app_passwords(email: str, server_ids: list[str]) -> None: acc = await account_id(email) if acc is not None and server_ids: (res,) = await call(("x:AppPassword/get", {"accountId": acc, "ids": server_ids})) present = [str(p["id"]) for p in res["list"]] await destroy("AppPassword", present, accountId=acc)