178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""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)
|