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>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""Hosted mailboxes (SPEC Appendix D): every user's own mailbox on the Stalwart
|
||||
mail server beside the app.
|
||||
|
||||
* :mod:`app.hosted.admin` — the mail server's management API (JMAP);
|
||||
* :mod:`app.hosted.provision` — idempotent setup: its database and role, the
|
||||
directory over the ``mail_directory`` view, the worker's sign-in;
|
||||
* :mod:`app.hosted.native` — the permanent account in the webmail, and how the
|
||||
worker signs in to it without any stored password;
|
||||
* :mod:`app.hosted.rawcache` — raw messages kept once, on the mail server: the
|
||||
webmail keeps only a bounded cache of them (§D.6 item 2).
|
||||
|
||||
All of it is inert while ``HOSTED_MAILBOXES`` is off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# The webmail worker's sign-in on the mail server: ``user@domain%WORKER`` acts
|
||||
# for that user (Stalwart's master-user login, given the impersonate role only).
|
||||
WORKER_LOCALPART = "webmail-worker"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return get_settings().hosted_mailboxes
|
||||
|
||||
|
||||
def worker_address() -> str:
|
||||
return f"{WORKER_LOCALPART}@{get_settings().app_domain}"
|
||||
|
||||
|
||||
def mail_hostname() -> str:
|
||||
settings = get_settings()
|
||||
return settings.mail_hostname or f"mail.{settings.app_domain}"
|
||||
@@ -0,0 +1,177 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""The user's own mailbox as a webmail account (SPEC App. D §D.4).
|
||||
|
||||
Every user has one ``kind = 'native'`` account: their handle's mailbox on the
|
||||
hosted mail server. It is created with the user (and, for users from before,
|
||||
by :func:`ensure_all`, run by provisioning), cannot be removed, and its server
|
||||
settings are not the user's to change.
|
||||
|
||||
No password of the user's is stored for it: the worker signs in as itself,
|
||||
acting for the user (``user@domain%webmail-worker``, §D.3), over the Docker
|
||||
network (or loopback in dev) — TLS, with the certificate left unchecked since
|
||||
the traffic never leaves the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.hosted import enabled, worker_address
|
||||
from app.models import Account, User
|
||||
from app.providers.imap_smtp import ProviderConfig
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KIND = "native"
|
||||
|
||||
|
||||
def is_native(account: Account) -> bool:
|
||||
return account.kind == KIND
|
||||
|
||||
|
||||
def _server(host: str, port: int) -> dict[str, object]:
|
||||
return {"host": host, "port": port, "tls": "ssl", "auth": "password"}
|
||||
|
||||
|
||||
def internal_tls() -> ssl.SSLContext:
|
||||
"""TLS to the mail server on the internal network: its certificate is for
|
||||
its public name (or self-signed), not the container's."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def native_provider_config(email: str) -> ProviderConfig:
|
||||
"""IMAP/SMTP to the user's mailbox, signed in as the worker acting for them.
|
||||
|
||||
Read from the settings, not the account row: the mail server's address is
|
||||
the deployment's, and may change."""
|
||||
settings = get_settings()
|
||||
return ProviderConfig(
|
||||
imap_host=settings.stalwart_imap_host,
|
||||
imap_port=settings.stalwart_imap_port,
|
||||
imap_tls="ssl",
|
||||
smtp_host=settings.stalwart_smtp_host,
|
||||
smtp_port=settings.stalwart_smtp_port,
|
||||
smtp_tls="ssl",
|
||||
username=f"{email}%{worker_address()}",
|
||||
password=settings.stalwart_master_secret,
|
||||
tls_context=internal_tls(),
|
||||
internal=True,
|
||||
)
|
||||
|
||||
|
||||
async def native_account(db: AsyncSession, user_id: uuid.UUID) -> Account | None:
|
||||
account: Account | None = await db.scalar(
|
||||
select(Account).where(Account.user_id == user_id, Account.kind == KIND)
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
async def ensure(db: AsyncSession, user: User) -> Account | None:
|
||||
"""The user's native account, created (and its first sync queued by the
|
||||
caller, see :func:`ensure_and_sync`) if missing. None when hosted
|
||||
mailboxes are off. Flushes, does not commit."""
|
||||
if not enabled():
|
||||
return None
|
||||
existing = await native_account(db, user.id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# An account for the same address added by hand: the same address at
|
||||
# another provider (the mail before it moved here — e.g. while the domain
|
||||
# still points elsewhere). It stays as it is; the mailbox account is added
|
||||
# once it is removed (app/api/accounts.py).
|
||||
same = await db.scalar(
|
||||
select(Account.id).where(Account.user_id == user.id, Account.email == user.handle)
|
||||
)
|
||||
if same is not None:
|
||||
logger.info("hosted: %s already has an account for its address; not added", user.handle)
|
||||
return None
|
||||
settings = get_settings()
|
||||
imap = _server(settings.stalwart_imap_host, settings.stalwart_imap_port)
|
||||
smtp = _server(settings.stalwart_smtp_host, settings.stalwart_smtp_port)
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email=user.handle,
|
||||
kind=KIND,
|
||||
imap_config=imap,
|
||||
smtp_config=smtp,
|
||||
secret_ct=encrypt_secret(b""), # nothing: the worker signs in as itself
|
||||
discovery_src="hosted",
|
||||
state="backfilling",
|
||||
sync_window="all",
|
||||
# The mail server's own spam filter files into Junk: show it.
|
||||
sync_spam=True,
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
return account
|
||||
|
||||
|
||||
async def ensure_and_sync(db: AsyncSession, user: User) -> Account | None:
|
||||
""":func:`ensure`, committed, and the account's first sync queued if new."""
|
||||
before = await native_account(db, user.id)
|
||||
account = await ensure(db, user)
|
||||
await db.commit()
|
||||
if account is not None and before is None:
|
||||
from app.queue import enqueue_job
|
||||
|
||||
await enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
return account
|
||||
|
||||
|
||||
async def ensure_all() -> int:
|
||||
"""Give every user without one their native account (users from before
|
||||
hosted mailboxes). Returns how many were created."""
|
||||
from app.db import session_scope
|
||||
|
||||
created = 0
|
||||
async with session_scope() as db:
|
||||
users = list(
|
||||
await db.scalars(
|
||||
select(User).where(
|
||||
~select(Account.id)
|
||||
.where(Account.user_id == User.id, Account.kind == KIND)
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
)
|
||||
for user in users:
|
||||
async with session_scope() as db:
|
||||
fresh = await db.get(User, user.id)
|
||||
if fresh is None:
|
||||
continue
|
||||
await ensure_and_sync(db, fresh)
|
||||
created += 1
|
||||
logger.info("hosted: mailbox account for %s", fresh.handle)
|
||||
return created
|
||||
@@ -0,0 +1,323 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user