Files
webmail-client/backend/app/hosted/provision.py
T

324 lines
12 KiB
Python
Raw Normal View History

"""Set up the mail server for hosted mailboxes (SPEC App. D §D.2/§D.3). Idempotent.
Run after the migrations (the ``migrate`` service in production; in dev
``python -m app.hosted.provision``). Two halves:
1. **Postgres** (as the app's own, privileged database user): the mail
server's role ``stalwart`` (password ``STALWART_DB_PASSWORD``), which owns its
database and may read the ``mail_directory`` view of the app's database and
nothing else there; and the worker's sign-in in ``mail_service_accounts``.
The mail server cannot start before this has run once.
2. **The mail server** (its management API, as the recovery admin): the mail
domain and host name, mail kept on disk (not in Postgres), the SQL directory
over ``mail_directory`` as the only source of accounts, the worker's
impersonate-only role, the submission port for mail apps.
"""
from __future__ import annotations
import asyncio
import logging
import re
from typing import Any
from urllib.parse import urlparse
import asyncpg # type: ignore[import-untyped]
from app.config import get_settings
from app.hosted import admin, mail_hostname, worker_address
from app.identity.passwords import hash_password, verify_password
logger = logging.getLogger(__name__)
DB_ROLE = "stalwart"
DIRECTORY_NAME = "Ultimo webmail users"
WORKER_ROLE_NAME = "Ultimo webmail worker"
BLOB_PATH = "/var/lib/stalwart/blobs"
# The directory's queries ($1: the address signed in with / mail is for). A
# blocked user cannot sign in, but still receives mail.
QUERY_LOGIN = (
"SELECT name, secret, type, description FROM mail_directory WHERE name = $1 AND active"
)
QUERY_RECIPIENT = (
"SELECT name, NULL::text AS secret, type, description FROM mail_directory WHERE name = $1"
)
def _pg_dsn() -> str:
return get_settings().database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
def _app_db_name() -> str:
return urlparse(_pg_dsn()).path.lstrip("/")
def _quote_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
async def provision_database() -> None:
settings = get_settings()
if not settings.stalwart_db_password or not settings.stalwart_master_secret:
raise RuntimeError("STALWART_DB_PASSWORD and STALWART_MASTER_SECRET must be set")
conn = await asyncpg.connect(_pg_dsn())
try:
password = _quote_literal(settings.stalwart_db_password)
if await conn.fetchval("SELECT 1 FROM pg_roles WHERE rolname = $1", DB_ROLE):
await conn.execute(f"ALTER ROLE {DB_ROLE} WITH LOGIN PASSWORD {password}")
else:
await conn.execute(f"CREATE ROLE {DB_ROLE} WITH LOGIN PASSWORD {password}")
db = settings.stalwart_db_name
if not await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", db):
await conn.execute(f'CREATE DATABASE "{db}" OWNER {DB_ROLE}')
logger.info("hosted: created database %s", db)
await conn.execute(f"GRANT SELECT ON mail_directory TO {DB_ROLE}")
# The worker's sign-in: re-hashed only when the secret changed.
name = worker_address()
current = await conn.fetchval(
"SELECT secret FROM mail_service_accounts WHERE name = $1", name
)
if current is None or not verify_password(current, settings.stalwart_master_secret):
await conn.execute(
"""
INSERT INTO mail_service_accounts (name, secret, description)
VALUES ($1, $2, 'Webmail worker')
ON CONFLICT (name) DO UPDATE SET secret = EXCLUDED.secret
""",
name,
hash_password(settings.stalwart_master_secret),
)
finally:
await conn.close()
# ---- the mail server ------------------------------------------------------------------
async def _wait_for_server(timeout_s: float = 120.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout_s
while True:
try:
await admin.get_one("SystemSettings")
return
except admin.MailServerError:
if asyncio.get_running_loop().time() > deadline:
raise
await asyncio.sleep(2)
def _by(items: list[dict[str, Any]], key: str, value: str) -> dict[str, Any] | None:
return next((i for i in items if i.get(key) == value), None)
async def _domain() -> str:
name = get_settings().app_domain.lower()
found = _by(await admin.get_all("Domain"), "name", name)
if found is not None:
return str(found["id"])
domain_id = await admin.create("Domain", {"name": name})
logger.info("hosted: created mail domain %s", name)
return domain_id
async def _directory() -> str:
settings = get_settings()
dsn = urlparse(_pg_dsn())
value: dict[str, Any] = {
"@type": "Sql",
"description": DIRECTORY_NAME,
"columnEmail": "name",
"columnSecret": "secret",
"columnClass": "type",
"columnDescription": "description",
"queryLogin": QUERY_LOGIN,
"queryRecipient": QUERY_RECIPIENT,
"queryMemberOf": None,
"queryEmailAliases": None,
"store": {
"@type": "PostgreSql",
"host": settings.stalwart_pg_host,
"port": 5432 if settings.stalwart_pg_host == "postgres" else (dsn.port or 5432),
"database": _app_db_name(),
"authUsername": DB_ROLE,
"authSecret": {"@type": "EnvironmentVariable", "variableName": "STALWART_DB_PASSWORD"},
"useTls": False,
"allowInvalidCerts": False,
"poolMaxConnections": 5,
"poolRecyclingMethod": "fast",
"readReplicas": {},
"timeout": 15000,
"options": None,
},
}
found = _by(await admin.get_all("Directory"), "description", DIRECTORY_NAME)
if found is None:
directory_id = await admin.create("Directory", value)
logger.info("hosted: created the SQL directory")
else:
directory_id = str(found["id"])
await admin.update("Directory", directory_id, value)
await admin.update("Authentication", "singleton", {"directoryId": directory_id})
return directory_id
async def _worker_role() -> str:
perms = {"impersonate": True, "authenticate": True}
found = _by(await admin.get_all("Role"), "description", WORKER_ROLE_NAME)
if found is not None:
role_id = str(found["id"])
await admin.update("Role", role_id, {"enabledPermissions": perms})
return role_id
return await admin.create(
"Role", {"description": WORKER_ROLE_NAME, "enabledPermissions": perms}
)
async def _worker_account(role_id: str) -> None:
"""The worker may act for any mailbox and do nothing as itself."""
# Signing in brings it from the directory into the server's registry. With
# its role already set that sign-in fails (it may not open a session as
# itself) — after the account exists, which is all this needs.
worker = worker_address()
acc = await admin.account_id(worker)
if acc is None:
import httpx
async with httpx.AsyncClient(timeout=15) as client:
await client.get(
f"{get_settings().stalwart_url}/jmap/session",
auth=(worker, get_settings().stalwart_master_secret),
)
acc = await admin.account_id(worker)
if acc is None:
raise admin.MailServerError("the worker account did not appear on the mail server")
await admin.update("Account", acc, {"roles": {"@type": "Custom", "roleIds": {role_id: True}}})
async def _reload() -> None:
"""Apply the settings now. Stalwart 0.16.19 ships a few spam-list rules its
own validator rejects (``bit_and`` in SURBL tags); they never load, but
they make every reload fail — so they are removed, which changes nothing."""
for _ in range(10):
try:
await admin.create("Action", {"@type": "ReloadSettings"})
return
except admin.MailServerError as exc:
match = re.search(r"'object': '(SpamDnsblServer)', 'id': '(\w+)'", str(exc))
if match is None:
raise
logger.info("hosted: removing the invalid built-in rule %s", match.group(2))
await admin.destroy(match.group(1), [match.group(2)])
raise admin.MailServerError("settings reload kept failing")
async def _logging() -> None:
"""Log to the console (``docker logs``), not to files inside the container."""
for tracer in await admin.get_all("Tracer"):
if tracer.get("@type") != "Stdout":
await admin.update(
"Tracer",
str(tracer["id"]),
{
"@type": "Stdout",
"ansi": False,
"buffered": False,
"enable": True,
"level": "info",
"events": {},
"eventsPolicy": "exclude",
},
)
async def _blob_store() -> bool:
"""Mail on disk (``DATA_ROOT/stalwart``), not inside Postgres: database
backups stay small. Takes a restart; True when one is needed."""
current = await admin.get_one("BlobStore")
if current.get("@type") == "FileSystem" and current.get("path") == BLOB_PATH:
return False
await admin.update(
"BlobStore", "singleton", {"@type": "FileSystem", "path": BLOB_PATH, "depth": 2}
)
logger.info("hosted: mail is now kept on disk (takes effect after a restart)")
return True
async def _listeners() -> None:
"""Add submission with STARTTLS (587) beside the defaults: smtp 25, submissions
465, imaps 993 (and http 8080 for this API)."""
listeners = await admin.get_all("NetworkListener")
if _by(listeners, "name", "submission") is None:
await admin.create(
"NetworkListener",
{"name": "submission", "bind": {"[::]:587": True}, "protocol": "smtp", "useTls": True},
)
# Folder names for new mailboxes: the plain ones mail apps expect, not
# Stalwart's Outlook-style defaults ("Deleted Items", "Junk Mail", "Sent
# Items"). Archive is made too: the webmail archives into it.
FOLDERS = {
"trash": "Trash",
"junk": "Junk",
"drafts": "Drafts",
"sent": "Sent",
"archive": "Archive",
}
async def _folders() -> None:
folders = {
use: {"name": name, "create": True, "subscribe": True, "aliases": {}}
for use, name in FOLDERS.items()
}
await admin.update("Email", "singleton", {"defaultFolders": folders})
async def provision_server() -> bool:
"""Set the mail server up; True when it must be restarted to finish."""
await _wait_for_server()
await _logging()
restart = await _blob_store()
domain_id = await _domain()
await admin.update(
"SystemSettings",
"singleton",
{"defaultDomainId": domain_id, "defaultHostname": mail_hostname()},
)
await _listeners()
await _folders()
await _directory()
role_id = await _worker_role()
await _reload()
await _worker_account(role_id)
return restart
async def provision() -> bool:
from app.hosted import native
await provision_database()
restart = await provision_server()
created = await native.ensure_all()
if created:
logger.info("hosted: added the mailbox account of %d existing user(s)", created)
return restart
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
if not get_settings().hosted_mailboxes:
logger.info("hosted mailboxes are off (HOSTED_MAILBOXES): nothing to set up")
return
if asyncio.run(provision()):
logger.warning("hosted mailboxes: RESTART the mail server (stalwart) to finish")
else:
logger.info("hosted mailboxes: mail server ready")
if __name__ == "__main__":
main()