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:
2026-10-12 19:25:10 +02:00
co-authored by Claude Opus 5.5
parent d45fbf2c44
commit b88855931e
62 changed files with 2427 additions and 188 deletions
+34
View File
@@ -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}"
+177
View File
@@ -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)
+154
View File
@@ -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
+323
View File
@@ -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()