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
+1
View File
@@ -102,3 +102,4 @@ class AccountDetailOut(BaseModel):
backfill_done: int backfill_done: int
imap_config: dict[str, object] imap_config: dict[str, object]
smtp_config: dict[str, object] smtp_config: dict[str, object]
kind: str = "external"
+14
View File
@@ -104,6 +104,7 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
sync_error=a.sync_error, sync_error=a.sync_error,
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None, sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
state_detail=a.state_detail, state_detail=a.state_detail,
kind=a.kind,
) )
for a in accounts for a in accounts
] ]
@@ -225,6 +226,8 @@ async def update_account(
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the # re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
# stored secret is only decrypted in the worker, never here). # stored secret is only decrypted in the worker, never here).
conn_change = body.imap is not None or body.smtp is not None or body.password is not None conn_change = body.imap is not None or body.smtp is not None or body.password is not None
if conn_change and account.kind == "native":
raise_api_error(400, "native_account", "the settings of your own mailbox cannot be changed")
if conn_change: if conn_change:
if not body.password: if not body.password:
raise_api_error( raise_api_error(
@@ -267,10 +270,20 @@ async def update_account(
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)]) @router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
kind = await db.scalar(
select(Account.kind).where(Account.id == account_id, Account.user_id == user.id)
)
if kind == "native":
raise_api_error(409, "native_account", "your own mailbox cannot be removed")
ok = await delete_account(db, user_id=user.id, account_id=account_id) ok = await delete_account(db, user_id=user.id, account_id=account_id)
if not ok: if not ok:
raise_api_error(404, "not_found", "account not found") raise_api_error(404, "not_found", "account not found")
await db.commit() await db.commit()
# Removing an account for the user's own address (the mail at its old
# provider) makes room for their hosted mailbox's (App. D §D.4).
from app.hosted import native
await native.ensure_and_sync(db, user)
return {"status": "deleted"} return {"status": "deleted"}
@@ -288,4 +301,5 @@ def _detail(account: Account) -> AccountDetailOut:
backfill_done=account.backfill_done, backfill_done=account.backfill_done,
imap_config=account.imap_config, imap_config=account.imap_config,
smtp_config=account.smtp_config, smtp_config=account.smtp_config,
kind=account.kind,
) )
+11 -2
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.db import get_session from app.db import get_session
from app.hosted.admin import MailServerError
from app.identity.deps import CurrentAdmin, require_csrf from app.identity.deps import CurrentAdmin, require_csrf
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
from app.identity.schemas import ( from app.identity.schemas import (
@@ -183,7 +184,12 @@ async def patch_user(
if body.quota_bytes is not None: if body.quota_bytes is not None:
user.quota_bytes = body.quota_bytes user.quota_bytes = body.quota_bytes
if body.blocked is not None: if body.blocked is not None:
await set_blocked(db, user, body.blocked) try:
await set_blocked(db, user, body.blocked)
except MailServerError as exc:
raise_api_error(
502, "mail_server_unavailable", f"the mail server did not answer: {exc}"
)
await db.commit() await db.commit()
used = await db.scalar( used = await db.scalar(
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id) select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
@@ -200,5 +206,8 @@ async def patch_user(
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]: async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
"""Delete a user and all of their data. Cannot be undone.""" """Delete a user and all of their data. Cannot be undone."""
user = await _target(db, admin, user_id) user = await _target(db, admin, user_id)
await delete_user(db, user) try:
await delete_user(db, user)
except MailServerError as exc:
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
return {"status": "deleted"} return {"status": "deleted"}
+5 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Annotated from typing import Annotated
@@ -17,7 +18,7 @@ from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store from app.indexer import attachments as att_store
from app.indexer import blobstore from app.indexer import blobstore, rawstore
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
from app.outbox.jobs import RETRY_WINDOW from app.outbox.jobs import RETRY_WINDOW
from app.outbox.schemas import ( from app.outbox.schemas import (
@@ -208,6 +209,9 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
) )
) )
attachment_ids: list[str] = [] attachment_ids: list[str] = []
if att_rows:
with contextlib.suppress(FileNotFoundError): # fetched if a hosted mailbox's
await rawstore.ensure(msg)
for a in att_rows: for a in att_rows:
# Kept only inside the message: give the compose attachment a blob of its own. # Kept only inside the message: give the compose attachment a blob of its own.
if not await asyncio.to_thread( if not await asyncio.to_thread(
+16 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import html as html_lib import html as html_lib
import io import io
import uuid import uuid
@@ -21,7 +22,7 @@ from app.api.settings import add_allowed_image_sender, allowed_image_senders
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store from app.indexer import attachments as att_store
from app.indexer import blobstore, thumbs from app.indexer import rawstore, thumbs
from app.indexer.mime import parse_message from app.indexer.mime import parse_message
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
from app.indexer.thumbs import thumb_type from app.indexer.thumbs import thumb_type
@@ -128,7 +129,7 @@ async def message_html(
# If the raw blob is unavailable, fall back to whatever is already stored. # If the raw blob is unavailable, fall back to whatever is already stored.
if allow_remote or stale: if allow_remote or stale:
try: try:
raw = blobstore.load(user.id, message.blob_sha256) raw = await rawstore.ensure_bytes(message)
except FileNotFoundError: except FileNotFoundError:
raw = None raw = None
if raw is not None: if raw is not None:
@@ -191,8 +192,16 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}" return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
async def _raw_ready(msg: Message) -> None:
"""Have the message's raw source here (a hosted mailbox's is fetched from
the mail server when not cached); a miss is left to the reader to report."""
with contextlib.suppress(FileNotFoundError):
await rawstore.ensure(msg)
async def _attachment_bytes(msg: Message, att: Attachment) -> bytes: async def _attachment_bytes(msg: Message, att: Attachment) -> bytes:
"""An attachment's bytes, taken out of its message (404 if they cannot be).""" """An attachment's bytes, taken out of its message (404 if they cannot be)."""
await _raw_ready(msg)
try: try:
return await asyncio.to_thread( return await asyncio.to_thread(
att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
@@ -236,6 +245,7 @@ async def forward_attachments(
of its own (shared, by hash, with any other use of the same file). of its own (shared, by hash, with any other use of the same file).
Returns them as the composer lists its attachments.""" Returns them as the composer lists its attachments."""
msg = await _owned_message(db, user.id, message_id) msg = await _owned_message(db, user.id, message_id)
await _raw_ready(msg)
rows = [ rows = [
ComposeAttachment( ComposeAttachment(
user_id=user.id, user_id=user.id,
@@ -315,6 +325,8 @@ async def attachment_thumbnail(
kind = thumb_type(att.mime_type, att.filename) if att is not None else None kind = thumb_type(att.mime_type, att.filename) if att is not None else None
if att is None or kind is None: if att is None or kind is None:
raise_api_error(404, "not_found", "no thumbnail") raise_api_error(404, "not_found", "no thumbnail")
if not thumbs.made(msg.user_id, att.blob_sha256):
await _raw_ready(msg)
data = await asyncio.to_thread( data = await asyncio.to_thread(
thumbs.thumbnail, thumbs.thumbnail,
msg.user_id, msg.user_id,
@@ -350,6 +362,8 @@ async def message_attachment(
) )
if att is None: if att is None:
raise_api_error(404, "not_found", "attachment not found") raise_api_error(404, "not_found", "attachment not found")
if not att_store.has_file(msg.user_id, att.blob_sha256):
await _raw_ready(msg)
try: try:
# Taken out of the message into a short-lived file (kept a day after # Taken out of the message into a short-lived file (kept a day after
# its last use), so it can be streamed with HTTP Range like before. # its last use), so it can be streamed with HTTP Range like before.
+2
View File
@@ -102,6 +102,8 @@ class AccountOut(BaseModel):
sync_error_since: str | None = None sync_error_since: str | None = None
# Why the state is what it is (e.g. the server's reply to a refused sign-in). # Why the state is what it is (e.g. the server's reply to a refused sign-in).
state_detail: str = "" state_detail: str = ""
# "native": the user's own hosted mailbox — cannot be removed (App. D §D.4).
kind: str = "external"
class SearchThreadHit(BaseModel): class SearchThreadHit(BaseModel):
+27
View File
@@ -74,6 +74,33 @@ class Settings(BaseSettings):
# Auto-purge Trash/Spam messages older than this many days (0 disables). # Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS") trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
# --- Hosted mailboxes (SPEC Appendix D, app/hosted/) -----------------------
# Off: no mail server, byte-identical to before (every account external).
hosted_mailboxes: bool = Field(default=False, alias="HOSTED_MAILBOXES")
# The mail server's management API (JMAP), signed in to as its recovery admin.
stalwart_url: str = Field(default="http://stalwart:8080", alias="STALWART_URL")
stalwart_admin_secret: str = Field(default="", alias="STALWART_ADMIN_SECRET")
# The webmail worker's own sign-in, which may act for any mailbox
# (``user%worker``): no user's password is ever stored (§D.3).
stalwart_master_secret: str = Field(default="", alias="STALWART_MASTER_SECRET")
# The mail server's role on Postgres (its database, and the directory view).
stalwart_db_password: str = Field(default="", alias="STALWART_DB_PASSWORD")
stalwart_db_name: str = Field(default="stalwart", alias="STALWART_DB_NAME")
# Where the mail server reaches Postgres (its own network view of it).
stalwart_pg_host: str = Field(default="postgres", alias="STALWART_PG_HOST")
# IMAP/SMTP as the webmail reaches them (TLS, certificate not checked: the
# traffic stays on the Docker network or loopback).
stalwart_imap_host: str = Field(default="stalwart", alias="STALWART_IMAP_HOST")
stalwart_imap_port: int = Field(default=993, alias="STALWART_IMAP_PORT")
stalwart_smtp_host: str = Field(default="stalwart", alias="STALWART_SMTP_HOST")
stalwart_smtp_port: int = Field(default=465, alias="STALWART_SMTP_PORT")
# The mail server's public name (SMTP greeting, reverse DNS, what mail apps
# are told to connect to). Default: mail.APP_DOMAIN.
mail_hostname: str = Field(default="", alias="MAIL_HOSTNAME")
# Raw messages of hosted mailboxes are kept only in a cache (§D.6 item 2);
# beyond this per user, the least recently used go first.
raw_cache_mb: int = Field(default=256, alias="RAW_CACHE_MB")
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
+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()
+47 -11
View File
@@ -1,14 +1,18 @@
"""App passwords: one generated password per mail app (SPEC App. D §D.5). """App passwords: one generated password per mail app (SPEC App. D §D.5).
A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its over IMAP/SMTP, and a calendar or contacts app to the DAV server — never with
own — 16 random letters, shown once — that can be revoked without touching the the account password (no 2FA code can be asked for there), always with one of
main password or the other apps. The webmail itself never accepts one. these. Each gets a password of its own, shown once, that can be
revoked without touching the main password or the other apps. The webmail
itself never accepts one.
Stored as argon2id hashes (identity/passwords.py), the form the mail server's With hosted mailboxes on, the mail server makes the password (``app_`` and 36
SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail more letters and digits: its format names the credential) and checks it for
server compares exactly what an app sends, so the password is copied and IMAP/SMTP itself; its argon2id hash is kept here too, for the DAV server, with
entered without the spaces it is shown with. the server's id to revoke it there. Without, it is 16 random letters, for DAV
only. Either way the hash is of the bare password: apps send exactly that, so
it is copied and entered without the spaces it is shown with.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,10 +24,12 @@ import uuid
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
from app.identity.passwords import hash_password, verify_password from app.identity.passwords import hash_password, verify_password
from app.models import UserAppPassword from app.models import User, UserAppPassword
LENGTH = 16 # 26^16 ≈ 2^75 LENGTH = 16 # 26^16 ≈ 2^75
SERVER_LENGTH = 40 # the mail server's: "app_" + base32 of a 4-byte id and 18 random bytes
MAX_PER_USER = 25 MAX_PER_USER = 25
NAME_MAX = 60 NAME_MAX = 60
_ALPHABET = string.ascii_lowercase _ALPHABET = string.ascii_lowercase
@@ -66,16 +72,45 @@ async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserA
) )
if (count or 0) >= MAX_PER_USER: if (count or 0) >= MAX_PER_USER:
raise TooMany raise TooMany
password = generate() name = name.strip()[:NAME_MAX]
server_id: str | None = None
if hosted.enabled():
from app.hosted import admin as mail_admin
handle = await db.scalar(select(User.handle).where(User.id == user_id))
server_id, password = await mail_admin.create_app_password(str(handle), name)
else:
password = generate()
row = UserAppPassword( row = UserAppPassword(
user_id=user_id, name=name.strip()[:NAME_MAX], password_hash=hash_password(password) user_id=user_id, name=name, password_hash=hash_password(password), server_id=server_id
) )
db.add(row) db.add(row)
await db.commit() await db.commit()
return row, password return row, password
async def _revoke_on_server(
db: AsyncSession, user_id: uuid.UUID, rows: list[UserAppPassword]
) -> None:
"""Revoke on the mail server too (first: if it cannot be reached, the
password keeps working there, so nothing is revoked here either)."""
server_ids = [r.server_id for r in rows if r.server_id]
if server_ids and hosted.enabled():
from app.hosted import admin as mail_admin
handle = await db.scalar(select(User.handle).where(User.id == user_id))
await mail_admin.delete_app_passwords(str(handle), server_ids)
async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool: async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
rows = list(
await db.scalars(
select(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
)
)
)
await _revoke_on_server(db, user_id, rows)
result = await db.execute( result = await db.execute(
delete(UserAppPassword).where( delete(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
@@ -88,6 +123,7 @@ async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUI
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None: async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
"""Every app signs in again (the main password was reset).""" """Every app signs in again (the main password was reset)."""
await _revoke_on_server(db, user_id, await list_for(db, user_id))
await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id)) await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
await db.commit() await db.commit()
await _changed(user_id) await _changed(user_id)
@@ -96,7 +132,7 @@ async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None: async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
"""The app password ``password`` matches, if any — for the mail server's """The app password ``password`` matches, if any — for the mail server's
sign-in and for tests; never for the webmail's own sign-in.""" sign-in and for tests; never for the webmail's own sign-in."""
if len(password) != LENGTH: if len(password) not in (LENGTH, SERVER_LENGTH):
return None return None
for row in await list_for(db, user_id): for row in await list_for(db, user_id):
if verify_password(row.password_hash, password): if verify_password(row.password_hash, password):
+29 -3
View File
@@ -6,16 +6,18 @@ from __future__ import annotations
import logging import logging
import re import re
import uuid import uuid
from typing import Annotated from typing import Annotated, NoReturn
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.events import get_event_bus from app.events import get_event_bus
from app.hosted.admin import MailServerError
from app.identity import app_passwords, trusted_devices from app.identity import app_passwords, trusted_devices
from app.identity import totp as totp_mod from app.identity import totp as totp_mod
from app.identity.cookies import ( from app.identity.cookies import (
@@ -105,6 +107,7 @@ def _user_out(user: User) -> UserOut:
settings=user.settings, settings=user.settings,
totp_enabled=user.totp_secret is not None, totp_enabled=user.totp_secret is not None,
is_admin=user.is_admin, is_admin=user.is_admin,
mail_host=hosted.mail_hostname() if hosted.enabled() else None,
) )
@@ -132,6 +135,12 @@ async def register(
except RegistrationError as exc: except RegistrationError as exc:
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message) raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
await db.commit() await db.commit()
# The user's own mailbox (App. D §D.4): it exists on the mail server as soon
# as the user does (the directory reads the users table); this adds it to
# the webmail.
from app.hosted import native
await native.ensure_and_sync(db, user)
await _establish_session(request, response, user) await _establish_session(request, response, user)
return _user_out(user) return _user_out(user)
@@ -316,7 +325,12 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
await db.commit() await db.commit()
await destroy_all_sessions(user.id) # force re-login everywhere after reset await destroy_all_sessions(user.id) # force re-login everywhere after reset
# Mail apps sign in again too: the reset may be the owner taking the account back. # Mail apps sign in again too: the reset may be the owner taking the account back.
await app_passwords.revoke_all(db, user.id) try:
await app_passwords.revoke_all(db, user.id)
except MailServerError:
# The new password is set; the app passwords stay listed (and working)
# until the mail server answers, so the user can still revoke them.
logger.exception("password reset: could not revoke app passwords on the mail server")
# Whoever reset the password may be the owner taking the account back: no # Whoever reset the password may be the owner taking the account back: no
# device keeps skipping the code on the old trust. # device keeps skipping the code on the old trust.
await trusted_devices.revoke_all(user.id) await trusted_devices.revoke_all(user.id)
@@ -326,6 +340,12 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
# --- App passwords (SPEC App. D §D.5) ----------------------------------------- # --- App passwords (SPEC App. D §D.5) -----------------------------------------
def _mail_server_down() -> NoReturn:
raise_api_error(
502, "mail_server_unavailable", "the mail server is not answering; try again shortly"
)
def _app_password_out(row: object) -> AppPasswordOut: def _app_password_out(row: object) -> AppPasswordOut:
from app.models import UserAppPassword from app.models import UserAppPassword
@@ -361,6 +381,8 @@ async def create_app_password(
raise_api_error( raise_api_error(
409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords" 409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords"
) )
except MailServerError:
_mail_server_down()
return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password) return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password)
@@ -372,7 +394,11 @@ async def create_app_password(
async def revoke_app_password( async def revoke_app_password(
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> MessageOut: ) -> MessageOut:
if not await app_passwords.revoke(db, user.id, app_password_id): try:
revoked = await app_passwords.revoke(db, user.id, app_password_id)
except MailServerError:
_mail_server_down()
if not revoked:
raise_api_error(404, "not_found", "app password not found") raise_api_error(404, "not_found", "app password not found")
return MessageOut() return MessageOut()
+3
View File
@@ -93,6 +93,9 @@ class UserOut(BaseModel):
settings: dict[str, object] settings: dict[str, object]
totp_enabled: bool = False totp_enabled: bool = False
is_admin: bool = False is_admin: bool = False
# The hosted mail server's name for mail apps (IMAP 993, SMTP 465/587), or
# None when there are no hosted mailboxes (SPEC App. D).
mail_host: str | None = None
class RegistrationStatusOut(BaseModel): class RegistrationStatusOut(BaseModel):
+21 -2
View File
@@ -67,7 +67,16 @@ async def search_users(db: AsyncSession, q: str) -> list[tuple[User, int, list[s
async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None: async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None:
"""Block or unblock. Blocking also signs the user out everywhere.""" """Block or unblock. Blocking also signs the user out everywhere, and shuts
their hosted mailbox to every sign-in (mail apps included); it still
receives mail. The mail server goes first: if it cannot be told, nothing
changes."""
from app import hosted
if hosted.enabled():
from app.hosted import admin as mail_admin
await mail_admin.set_blocked(user.handle, blocked)
if blocked and user.blocked_at is None: if blocked and user.blocked_at is None:
user.blocked_at = dt.datetime.now(dt.UTC) user.blocked_at = dt.datetime.now(dt.UTC)
await db.flush() await db.flush()
@@ -97,8 +106,18 @@ async def _plugin_tables(db: AsyncSession) -> list[str]:
async def delete_user(db: AsyncSession, user: User) -> None: async def delete_user(db: AsyncSession, user: User) -> None:
"""Delete ``user`` and everything they own. Commits.""" """Delete ``user`` and everything they own. Commits.
Their hosted mailbox goes first, with all its mail: if the mail server
cannot be reached nothing is deleted — a mailbox left behind would be
handed, mail and all, to whoever registers the address next."""
from app import hosted
user_id = user.id user_id = user.id
if hosted.enabled():
from app.hosted import admin as mail_admin
await mail_admin.delete_account(user.handle)
# Mail accounts first, one by one: this is what stops their sync runners and # Mail accounts first, one by one: this is what stops their sync runners and
# tidies threads, exactly as when the user removes an account themselves. # tidies threads, exactly as when the user removes an account themselves.
+13 -6
View File
@@ -10,7 +10,9 @@ decoded on its own). It is taken out of the raw message when wanted:
* :func:`dedupe_existing` — the one-time pass removing the separate copies * :func:`dedupe_existing` — the one-time pass removing the separate copies
stored before, each only after checking it can be taken out of its message. stored before, each only after checking it can be taken out of its message.
Blocking (parsing, disk): call these in a thread. Blocking (parsing, disk): call these in a thread — after
:func:`app.indexer.rawstore.ensure` for the message, which fetches a hosted
mailbox's message from its mail server when it is not cached.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,7 +25,7 @@ import uuid
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from app.indexer import blobstore from app.indexer import blobstore, rawstore
from app.indexer.mime import load_message, part_payload from app.indexer.mime import load_message, part_payload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,8 +47,8 @@ def load(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None)
legacy = _legacy(user_id, sha) legacy = _legacy(user_id, sha)
if legacy is not None: if legacy is not None:
return legacy.read_bytes() return legacy.read_bytes()
raw = blobstore.blob_path(user_id, message_sha) raw = rawstore.locate(user_id, message_sha)
if part_index == NOT_IN_MESSAGE or not raw.exists(): if part_index == NOT_IN_MESSAGE or raw is None:
raise FileNotFoundError(sha) raise FileNotFoundError(sha)
payload = part_payload(load_message(path=raw), part_index, sha) payload = part_payload(load_message(path=raw), part_index, sha)
if payload is None: if payload is None:
@@ -82,6 +84,11 @@ def prune_cache(cache: Path) -> None:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
def has_file(user_id: uuid.UUID, sha: str) -> bool:
"""Whether :func:`file` can answer without reading the message."""
return _legacy(user_id, sha) is not None or (_cache_dir(user_id) / sha).exists()
def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path: def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path:
"""A file holding the attachment, to stream from. Raises FileNotFoundError.""" """A file holding the attachment, to stream from. Raises FileNotFoundError."""
legacy = _legacy(user_id, sha) legacy = _legacy(user_id, sha)
@@ -167,9 +174,9 @@ async def dedupe_existing(batch: int = 200, *, user_id: uuid.UUID | None = None)
located: dict[uuid.UUID, int] = {} located: dict[uuid.UUID, int] = {}
for (owner, msg_sha), atts in by_message.items(): for (owner, msg_sha), atts in by_message.items():
raw = blobstore.blob_path(owner, msg_sha) raw = rawstore.locate(owner, msg_sha)
try: try:
indexes = await asyncio.to_thread(_part_indexes, raw) if raw.exists() else {} indexes = await asyncio.to_thread(_part_indexes, raw) if raw is not None else {}
except Exception: # noqa: BLE001 - unreadable message: keep the copies except Exception: # noqa: BLE001 - unreadable message: keep the copies
logger.warning("attachments: cannot parse %s", raw, exc_info=True) logger.warning("attachments: cannot parse %s", raw, exc_info=True)
indexes = {} indexes = {}
+24 -5
View File
@@ -11,7 +11,12 @@ that for every user, whatever the way the rows went:
a sent message (its draft is gone) or a composer closed without saving; a sent message (its draft is gone) or a composer closed without saving;
* message/attachment files no row refers to; * message/attachment files no row refers to;
* attachment previews (thumbnails) of attachments that are gone; * attachment previews (thumbnails) of attachments that are gone;
* streaming copies past their keep time (app/indexer/attachments.py). * streaming copies past their keep time (app/indexer/attachments.py);
* cached raw messages of a hosted mailbox beyond the cache's room
(app/indexer/rawstore.py).
It also recounts threads whose unread/message counters disagree with their
messages, and drops threads left empty (app/threading_/threader.py).
A file younger than an hour is left alone: it may belong to work in progress A file younger than an hour is left alone: it may belong to work in progress
(a message being stored, a sent message waiting for its Sent copy). (a message being stored, a sent message waiting for its Sent copy).
@@ -31,6 +36,7 @@ from sqlalchemy import delete, or_, select
from app.config import get_settings from app.config import get_settings
from app.indexer import attachments as att_store from app.indexer import attachments as att_store
from app.indexer import rawstore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -94,9 +100,9 @@ def _old(path: Path, now: float) -> bool:
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]: async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
"""Clean one user's stored files; returns what was removed.""" """Clean one user's stored files; returns what was removed."""
from app.db import session_scope from app.db import session_scope
from app.models import Attachment, ComposeAttachment, Draft, Message from app.models import Account, Attachment, ComposeAttachment, Draft, Message
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0} stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
async with session_scope() as db: async with session_scope() as db:
# Uploads no draft refers to, once a week old. # Uploads no draft refers to, once a week old.
used = _draft_upload_ids( used = _draft_upload_ids(
@@ -116,8 +122,14 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
async with session_scope() as db: async with session_scope() as db:
# Every file still referred to. Attachments count only where their file # Every file still referred to. Attachments count only where their file
# is their only copy (not found in the message, or not yet looked for). # is their only copy (not found in the message, or not yet looked for).
# A hosted mailbox's messages are kept by its mail server: their raw
# file here (left by a Sent copy filed late) is not needed.
referenced = set( referenced = set(
await db.scalars(select(Message.blob_sha256).where(Message.user_id == user_id)) await db.scalars(
select(Message.blob_sha256)
.join(Account, Account.id == Message.account_id)
.where(Message.user_id == user_id, Account.kind != "native")
)
) )
referenced |= set( referenced |= set(
await db.scalars( await db.scalars(
@@ -140,6 +152,12 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id)) await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id))
) )
# Counters out of step with their messages (see repair_threads).
from app.threading_.threader import repair_threads
async with session_scope() as db:
stats["threads_repaired"] = await repair_threads(db, user_id)
root = Path(get_settings().blob_root) / str(user_id) root = Path(get_settings().blob_root) / str(user_id)
if not root.is_dir(): if not root.is_dir():
return stats return stats
@@ -168,6 +186,7 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
cache = root / "cache" cache = root / "cache"
if cache.is_dir(): if cache.is_dir():
att_store.prune_cache(cache) att_store.prune_cache(cache)
rawstore.prune(user_id)
return stats return stats
@@ -178,7 +197,7 @@ async def sweep(user_id: uuid.UUID | None = None) -> dict[str, int]:
async with session_scope() as db: async with session_scope() as db:
users = [user_id] if user_id is not None else list(await db.scalars(select(User.id))) users = [user_id] if user_id is not None else list(await db.scalars(select(User.id)))
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0} total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
for uid in users: for uid in users:
try: try:
stats = await sweep_user(uid) stats = await sweep_user(uid)
+16 -5
View File
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.concurrency import loop_semaphore from app.concurrency import loop_semaphore
from app.config import get_settings from app.config import get_settings
from app.indexer import blobstore from app.indexer import blobstore, rawstore
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
from app.indexer.sanitize import sanitize_html from app.indexer.sanitize import sanitize_html
from app.models import Attachment, BodyPart, KnownRecipient, Message from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage from app.providers.base import RemoteMessage
from app.search.base import SearchDoc from app.search.base import SearchDoc
from app.threading_.normalize import synthesize_message_id from app.threading_.normalize import synthesize_message_id
@@ -118,6 +118,13 @@ async def _resolve_message_id(
return parsed_id return parsed_id
async def _keeps_raw(session: AsyncSession, account_id: uuid.UUID) -> bool:
"""Whether the raw message is kept for good: not for a hosted mailbox, whose
mail server holds it (it is only cached, App. D §D.6 item 2)."""
kind = await session.scalar(select(Account.kind).where(Account.id == account_id))
return kind != "native"
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail: async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7). """Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
@@ -128,8 +135,9 @@ async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmai
settings = get_settings() settings = get_settings()
threshold = settings.stream_parse_mb * 1024 * 1024 threshold = settings.stream_parse_mb * 1024 * 1024
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency): async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
if len(raw) > threshold: path = rawstore.locate(user_id, sha) if len(raw) > threshold else None
return parse_message_file(blobstore.blob_path(user_id, sha)) if path is not None:
return parse_message_file(path)
return parse_message(raw) return parse_message(raw)
@@ -154,7 +162,10 @@ async def persist_message(
if existing is not None: if existing is not None:
return None return None
sha = blobstore.store(user_id, remote.raw) if await _keeps_raw(session, account_id):
sha = blobstore.store(user_id, remote.raw)
else:
sha = rawstore.put_cached(user_id, remote.raw)
parsed = await _parse_bounded(user_id, sha, remote.raw) parsed = await _parse_bounded(user_id, sha, remote.raw)
message_id_hdr = await _resolve_message_id( message_id_hdr = await _resolve_message_id(
+170
View File
@@ -0,0 +1,170 @@
"""Where a message's raw source is (SPEC App. D §D.6 item 2).
An external account's messages are kept whole in the blob store, as always.
A hosted mailbox's are not: the mail server beside the app holds them, so the
webmail keeps only what it reads them for (headers, the sanitized body, the
snippet, the search index) and a **cache** of raw messages — the newest ones
on arrival, the ones opened since — bounded per user (``RAW_CACHE_MB``,
least recently used out first). Anything else is fetched from the mail server
when needed: downloading an attachment, "show original", forwarding.
Layout: ``${BLOB_ROOT}/{user_id}/rawcache/{sha256}``, the same content hash
``messages.blob_sha256`` names.
* :func:`locate` — the file, if it is here (blob store or cache); cheap, sync;
* :func:`ensure` — the file, fetched from the mail server if need be (async):
call it before handing the work to a thread that reads the file.
"""
from __future__ import annotations
import contextlib
import logging
import time
import uuid
from pathlib import Path
from app.config import get_settings
from app.indexer import blobstore
logger = logging.getLogger(__name__)
CACHE_DIR = "rawcache"
KEEP_S = 30 * 24 * 3600 # unused this long: dropped whatever the room
PRUNE_EVERY = 50
_writes: dict[uuid.UUID, int] = {}
def cache_dir(user_id: uuid.UUID) -> Path:
return blobstore.blob_path(user_id, "00").parent.parent / CACHE_DIR
def cache_path(user_id: uuid.UUID, sha: str) -> Path:
return cache_dir(user_id) / sha
def locate(user_id: uuid.UUID, sha: str) -> Path | None:
"""The message's raw file, if it is kept or cached here."""
kept = blobstore.blob_path(user_id, sha)
if kept.exists():
return kept
cached = cache_path(user_id, sha)
if cached.exists():
with contextlib.suppress(OSError):
cached.touch() # used again: kept longer
return cached
return None
def load(user_id: uuid.UUID, sha: str) -> bytes:
"""The raw bytes if they are here. Raises FileNotFoundError."""
path = locate(user_id, sha)
if path is None:
raise FileNotFoundError(sha)
return path.read_bytes()
def put_cached(user_id: uuid.UUID, data: bytes, sha: str | None = None) -> str:
"""Cache a raw message (``sha`` names it; its content hash by default)."""
sha = sha or blobstore.sha256_hex(data)
if blobstore.exists(user_id, sha):
return sha # kept for good already (another account has the same message)
path = cache_path(user_id, sha)
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
tmp.write_bytes(data)
tmp.replace(path)
else:
path.touch()
# Pruning lists the whole cache: done every PRUNE_EVERY writes, not each
# (a backfill writes thousands); meanwhile it overshoots by that much at most.
writes = _writes.get(user_id, 0) + 1
_writes[user_id] = writes % PRUNE_EVERY
if writes >= PRUNE_EVERY:
with contextlib.suppress(OSError): # a file removed meanwhile by another request
prune(user_id)
return sha
def prune(user_id: uuid.UUID, *, max_bytes: int | None = None) -> int:
"""Drop cached messages unused for KEEP_S, and the least recently used
beyond the cap. Returns bytes freed."""
cache = cache_dir(user_id)
if not cache.is_dir():
return 0
cap = max_bytes if max_bytes is not None else get_settings().raw_cache_mb * 1024 * 1024
files = [(p, p.stat()) for p in cache.iterdir() if p.is_file() and not p.name.endswith(".tmp")]
now = time.time()
total = freed = 0
for path, st in sorted(files, key=lambda f: f[1].st_mtime, reverse=True):
total += st.st_size
if now - st.st_mtime > KEEP_S or total > cap:
path.unlink(missing_ok=True)
freed += st.st_size
return freed
def drop(user_id: uuid.UUID, sha: str) -> None:
cache_path(user_id, sha).unlink(missing_ok=True)
async def ensure(message: object) -> Path:
"""The raw file of ``message`` (a :class:`~app.models.Message`), fetched
from the hosted mail server into the cache when it is not here. Raises
FileNotFoundError when it cannot be had (an external account's message
missing from the blob store; a native one gone from the server)."""
from app.models import Message
assert isinstance(message, Message)
found = locate(message.user_id, message.blob_sha256)
if found is not None:
return found
raw = await _fetch_native(message)
if raw is None:
raise FileNotFoundError(message.blob_sha256)
got = blobstore.sha256_hex(raw)
if got != message.blob_sha256:
# The server's copy differs from what was indexed (it should not):
# cached under the indexed name, so the message can still be read.
logger.warning("raw of message %s changed on the mail server", message.id)
put_cached(message.user_id, raw, message.blob_sha256)
return cache_path(message.user_id, message.blob_sha256)
async def ensure_bytes(message: object) -> bytes:
path = await ensure(message)
import asyncio
return await asyncio.to_thread(path.read_bytes)
async def _fetch_native(message: object) -> bytes | None:
from sqlalchemy import select
from app.db import session_scope
from app.models import Account, Mailbox, Message
assert isinstance(message, Message)
async with session_scope() as db:
row = (
await db.execute(
select(Account.kind, Account.email, Mailbox.path)
.join(Mailbox, Mailbox.account_id == Account.id)
.where(Account.id == message.account_id, Mailbox.id == message.mailbox_id)
)
).first()
if row is None or row.kind != "native":
return None
from app.hosted.native import native_provider_config
from app.providers.base import ProviderError
from app.providers.imap_smtp import ImapSmtpProvider
provider = ImapSmtpProvider(native_provider_config(row.email))
try:
return await provider.fetch_raw(row.path, message.uid)
except ProviderError as exc:
logger.warning("cannot fetch message %s from the mail server: %s", message.id, exc)
return None
finally:
await provider.close()
+5
View File
@@ -87,6 +87,11 @@ def _render(data: bytes, mime_type: str) -> bytes | None:
return out.getvalue() return out.getvalue()
def made(user_id: uuid.UUID, sha: str) -> bool:
"""Whether the attachment's thumbnail (or its absence) is settled already."""
return _path(user_id, sha, ".webp").exists() or _path(user_id, sha, ".none").exists()
def thumbnail( def thumbnail(
user_id: uuid.UUID, user_id: uuid.UUID,
sha: str, sha: str,
+14 -5
View File
@@ -131,6 +131,10 @@ class Account(Base):
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False) secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
# external: any IMAP/SMTP server (the user's password in secret_ct);
# native: the user's own mailbox on the hosted mail server — permanent, no
# stored password (SPEC App. D §D.4; app/hosted/).
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'external'"))
discovery_src: Mapped[str] = mapped_column(Text, nullable=False) discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'")) state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
@@ -161,9 +165,12 @@ class Mailbox(Base):
) )
path: Mapped[str] = mapped_column(Text, nullable=False) path: Mapped[str] = mapped_column(Text, nullable=False)
special_use: Mapped[str | None] = mapped_column(Text) special_use: Mapped[str | None] = mapped_column(Text)
uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) # IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
highestmodseq: Mapped[int | None] = mapped_column(Integer) uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
last_seen_uid: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("0")
)
class Thread(Base): class Thread(Base):
@@ -214,7 +221,7 @@ class Message(Base):
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column( restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
) )
uid: Mapped[int] = mapped_column(Integer, nullable=False) uid: Mapped[int] = mapped_column(BigInteger, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column( thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id") UUID(as_uuid=True), ForeignKey("threads.id")
) )
@@ -321,7 +328,7 @@ class Draft(Base):
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
# UID of the mirrored copy in the account's IMAP Drafts folder (so the draft # UID of the mirrored copy in the account's IMAP Drafts folder (so the draft
# is visible/continuable on other mail clients). Replaced on each edit. # is visible/continuable on other mail clients). Replaced on each edit.
imap_uid: Mapped[int | None] = mapped_column(Integer) imap_uid: Mapped[int | None] = mapped_column(BigInteger)
updated_at: Mapped[dt.datetime] = _now() updated_at: Mapped[dt.datetime] = _now()
@@ -367,6 +374,8 @@ class UserAppPassword(Base):
created_at: Mapped[dt.datetime] = _now() created_at: Mapped[dt.datetime] = _now()
# Set by the mail server's sign-in, once there is one. # Set by the mail server's sign-in, once there is one.
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
# The hosted mail server's id of the same password (§D.5), when it has one.
server_id: Mapped[str | None] = mapped_column(Text)
class PushSubscription(Base): class PushSubscription(Base):
+5 -3
View File
@@ -22,6 +22,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.indexer import attachments as att_store from app.indexer import attachments as att_store
from app.indexer import rawstore
from app.indexer.sanitize import INTERNAL_CID from app.indexer.sanitize import INTERNAL_CID
from app.models import Attachment, Message from app.models import Attachment, Message
@@ -64,7 +65,7 @@ async def embed(
message_id, content_id = target message_id, content_id = target
found = ( found = (
await session.execute( await session.execute(
select(Attachment, Message.blob_sha256) select(Attachment, Message)
.join(Message, Message.id == Attachment.message_id) .join(Message, Message.id == Attachment.message_id)
.where( .where(
Attachment.message_id == message_id, Attachment.message_id == message_id,
@@ -76,10 +77,11 @@ async def embed(
).first() ).first()
part: InlinePart | None = None part: InlinePart | None = None
if found is not None: if found is not None:
att, message_sha = found att, message = found
try: try:
await rawstore.ensure(message) # a hosted mailbox's: fetched if need be
data = await asyncio.to_thread( data = await asyncio.to_thread(
att_store.load, user_id, message_sha, att.blob_sha256, att.part_index att_store.load, user_id, message.blob_sha256, att.blob_sha256, att.part_index
) )
cid = make_msgid(domain=domain)[1:-1] cid = make_msgid(domain=domain)[1:-1]
part = InlinePart(cid, att.mime_type, att.filename, data) part = InlinePart(cid, att.mime_type, att.filename, data)
+3 -5
View File
@@ -23,7 +23,7 @@ from sqlalchemy import select
from app.db import session_scope from app.db import session_scope
from app.events import get_event_bus from app.events import get_event_bus
from app.indexer import blobstore from app.indexer import rawstore
from app.models import Account, Mailbox, Message, Thread, User from app.models import Account, Mailbox, Message, Thread, User
@@ -39,9 +39,8 @@ class PluginMessages:
) )
if msg is None or not msg.blob_sha256: if msg is None or not msg.blob_sha256:
return None return None
sha = msg.blob_sha256
try: try:
raw = blobstore.load(user_id, sha) raw = await rawstore.ensure_bytes(msg)
except FileNotFoundError: except FileNotFoundError:
return None return None
return message_from_bytes(raw).get(name) return message_from_bytes(raw).get(name)
@@ -60,9 +59,8 @@ class PluginMessages:
) )
if msg is None or not msg.blob_sha256: if msg is None or not msg.blob_sha256:
return None return None
sha = msg.blob_sha256
try: try:
return blobstore.load(user_id, sha) return await rawstore.ensure_bytes(msg)
except FileNotFoundError: except FileNotFoundError:
return None return None
+31 -5
View File
@@ -132,6 +132,12 @@ class ProviderConfig:
smtp_tls: str # "ssl" | "starttls" | "plain" smtp_tls: str # "ssl" | "starttls" | "plain"
username: str username: str
password: str # plaintext; caller decrypts per §10.2 password: str # plaintext; caller decrypts per §10.2
# TLS settings other than the default (certificate checked): the hosted
# mail server on the internal network (app/hosted/native.py).
tls_context: ssl.SSLContext | None = None
# The hosted mail server: every user's mailbox is on that one host, so the
# per-host connection cap (which spares outside providers) does not apply.
internal: bool = False
class ImapSmtpProvider(MailProvider): class ImapSmtpProvider(MailProvider):
@@ -169,8 +175,11 @@ class ImapSmtpProvider(MailProvider):
account = loop_semaphore( account = loop_semaphore(
"imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP "imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP
) )
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
await account.acquire() # consistent order (account, then host) avoids deadlock await account.acquire() # consistent order (account, then host) avoids deadlock
if cfg.internal:
self._conn_slots = [account]
return
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
try: try:
await host.acquire() await host.acquire()
except BaseException: except BaseException:
@@ -193,7 +202,10 @@ class ImapSmtpProvider(MailProvider):
imap: aioimaplib.IMAP4 imap: aioimaplib.IMAP4
if cfg.imap_tls == "ssl": if cfg.imap_tls == "ssl":
imap = _IMAP4_SSL( imap = _IMAP4_SSL(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT host=cfg.imap_host,
port=cfg.imap_port,
timeout=_CONNECT_TIMEOUT,
ssl_context=cfg.tls_context,
) )
else: else:
imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT) imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT)
@@ -356,6 +368,15 @@ class ImapSmtpProvider(MailProvider):
resp = await imap.uid_search("ALL") resp = await imap.uid_search("ALL")
return set(parse_search_uids(resp.lines)) return set(parse_search_uids(resp.lines))
async def fetch_raw(self, mailbox: str, uid: int) -> bytes | None:
"""One message's raw bytes, or None if it is not (or no longer) there."""
await self._select(mailbox)
imap = await self._connect()
for message in await self._fetch_messages(imap, [uid]):
if message.uid == uid:
return message.raw
return None
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]: async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
if not uids: if not uids:
return [] return []
@@ -474,12 +495,12 @@ class ImapSmtpProvider(MailProvider):
"""Create a mailbox on the server (idempotent — ignores 'already exists').""" """Create a mailbox on the server (idempotent — ignores 'already exists')."""
imap = await self._connect() imap = await self._connect()
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await imap.create(path) await imap.create(self._wire(path))
async def move(self, uid: int, src: str, dst: str) -> int: async def move(self, uid: int, src: str, dst: str) -> int:
await self._select(src) await self._select(src)
imap = await self._connect() imap = await self._connect()
resp = await imap.uid("move", str(uid), dst) resp = await imap.uid("move", str(uid), self._wire(dst))
if resp.result != "OK": if resp.result != "OK":
raise ProviderConnectError(f"MOVE {uid} -> {dst} failed") raise ProviderConnectError(f"MOVE {uid} -> {dst} failed")
for line in resp.lines: for line in resp.lines:
@@ -521,7 +542,11 @@ class ImapSmtpProvider(MailProvider):
cfg = self._cfg cfg = self._cfg
use_tls = cfg.smtp_tls == "ssl" use_tls = cfg.smtp_tls == "ssl"
start_tls = cfg.smtp_tls == "starttls" start_tls = cfg.smtp_tls == "starttls"
host_slot = loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap) host_slot = (
contextlib.nullcontext()
if cfg.internal
else loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
)
try: try:
async with host_slot: # bound simultaneous sockets to the SMTP host (§4) async with host_slot: # bound simultaneous sockets to the SMTP host (§4)
await aiosmtplib.send( await aiosmtplib.send(
@@ -534,6 +559,7 @@ class ImapSmtpProvider(MailProvider):
password=cfg.password, password=cfg.password,
use_tls=use_tls, use_tls=use_tls,
start_tls=start_tls, start_tls=start_tls,
tls_context=cfg.tls_context,
timeout=_CONNECT_TIMEOUT, timeout=_CONNECT_TIMEOUT,
) )
except aiosmtplib.SMTPException as exc: except aiosmtplib.SMTPException as exc:
+3 -3
View File
@@ -77,7 +77,7 @@ async def apply_flags_to_messages(
if message.thread_id is not None: if message.thread_id is not None:
thread_ids.add(message.thread_id) thread_ids.add(message.thread_id)
await session.flush() await session.flush()
for tid in thread_ids: for tid in sorted(thread_ids): # one lock order across transactions
await denormalize_thread(session, tid) await denormalize_thread(session, tid)
return thread_ids return thread_ids
@@ -504,7 +504,7 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
await session.flush() await session.flush()
# Threads with no messages left get dropped (drafts un-linked first); the # Threads with no messages left get dropped (drafts un-linked first); the
# rest are re-denormalized so their counts/snippet reflect the removal. # rest are re-denormalized so their counts/snippet reflect the removal.
for tid in thread_ids: for tid in sorted(thread_ids):
remaining = await session.scalar( remaining = await session.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid) select(func.count()).select_from(Message).where(Message.thread_id == tid)
) )
@@ -584,7 +584,7 @@ async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
) )
await session.execute(delete(Message).where(Message.id.in_(msg_ids))) await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
await session.flush() await session.flush()
for tid in thread_ids: for tid in sorted(thread_ids):
remaining = await session.scalar( remaining = await session.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid) select(func.count()).select_from(Message).where(Message.thread_id == tid)
) )
+12 -1
View File
@@ -354,7 +354,18 @@ async def apply_delta(
if delta.new_uidvalidity is not None: if delta.new_uidvalidity is not None:
# UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3). # UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3).
async with session_scope() as session: async with session_scope() as session:
# Their threads are recounted: a message not brought back by the
# re-import (gone meanwhile) must not stay counted.
wiped_threads: set[uuid.UUID] = {
tid
for tid in await session.scalars(
select(Message.thread_id).where(Message.mailbox_id == mailbox.id)
)
if tid is not None
}
await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id)) await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id))
for tid in sorted(wiped_threads):
await denormalize_thread(session, tid)
await session.execute( await session.execute(
update(Mailbox) update(Mailbox)
.where(Mailbox.id == mailbox.id) .where(Mailbox.id == mailbox.id)
@@ -428,7 +439,7 @@ async def apply_delta(
) )
# After the deletes, so a thread left with no messages is removed rather # After the deletes, so a thread left with no messages is removed rather
# than recounted (denormalize_thread handles that case). # than recounted (denormalize_thread handles that case).
for tid in touched_threads: for tid in sorted(touched_threads): # one lock order (denormalize_thread)
await denormalize_thread(session, tid) await denormalize_thread(session, tid)
updates = {"last_seen_uid": max_uid} updates = {"last_seen_uid": max_uid}
+7
View File
@@ -3,6 +3,9 @@
This module runs only inside the worker/sync context; ``decrypt_secret`` (the This module runs only inside the worker/sync context; ``decrypt_secret`` (the
credential vault) is invoked here to obtain the plaintext mailbox password. It credential vault) is invoked here to obtain the plaintext mailbox password. It
must never be imported by API request code. must never be imported by API request code.
A native account (the user's hosted mailbox, App. D §D.4) has no stored
password: the worker signs in as itself, acting for the user.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,6 +17,10 @@ from app.security.crypto import decrypt_secret
def build_provider(account: Account) -> ImapSmtpProvider: def build_provider(account: Account) -> ImapSmtpProvider:
"""Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0).""" """Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0)."""
if account.kind == "native":
from app.hosted.native import native_provider_config
return ImapSmtpProvider(native_provider_config(account.email))
imap = account.imap_config imap = account.imap_config
smtp = account.smtp_config smtp = account.smtp_config
password = decrypt_secret(account.secret_ct).decode("utf-8") password = decrypt_secret(account.secret_ct).decode("utf-8")
+61 -6
View File
@@ -11,12 +11,12 @@ from __future__ import annotations
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from sqlalchemy import ColumnElement, delete, literal, select, update from sqlalchemy import ColumnElement, delete, exists, func, literal, select, update
from sqlalchemy import any_ as sa_any from sqlalchemy import any_ as sa_any
from sqlalchemy import or_ as sa_or from sqlalchemy import or_ as sa_or
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Thread from app.models import Draft, Message, Thread
from app.threading_.normalize import is_synthetic, normalize_subject from app.threading_.normalize import is_synthetic, normalize_subject
_SUBJECT_FALLBACK_WINDOW = timedelta(days=7) _SUBJECT_FALLBACK_WINDOW = timedelta(days=7)
@@ -172,18 +172,38 @@ async def _create_thread(session: AsyncSession, message: Message) -> uuid.UUID:
async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None: async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None:
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6).""" """Recompute a thread's denormalized fields from its messages (SPEC §7.2.6).
The thread row is locked *before* its messages are read: two transactions
recounting one thread at once (read in the webmail while a sync brings the
same flag from a phone) then take turns, and the second reads what the
first committed — instead of both reading first and the slower one writing
an old count (a message shown read, its thread still "unread"). The
messages are reloaded from the database, not taken from the session's
copies, which a bulk UPDATE may have left behind."""
thread = await session.scalar(
select(Thread)
.where(Thread.id == thread_id)
.with_for_update()
.execution_options(populate_existing=True)
)
if thread is None:
return
messages = list( messages = list(
await session.scalars( await session.scalars(
select(Message) select(Message)
.where(Message.thread_id == thread_id) .where(Message.thread_id == thread_id)
.order_by(Message.internal_date.desc()) .order_by(Message.internal_date.desc())
.execution_options(populate_existing=True)
) )
) )
thread = await session.get(Thread, thread_id)
if thread is None:
return
if not messages: if not messages:
# A reply still being written keeps its (now empty) thread: the draft
# points at it.
if await session.scalar(select(exists().where(Draft.thread_id == thread_id))):
thread.message_count = 0
thread.unread_count = 0
return
await session.execute(delete(Thread).where(Thread.id == thread_id)) await session.execute(delete(Thread).where(Thread.id == thread_id))
return return
@@ -241,3 +261,38 @@ async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
await denormalize_thread(session, thread_id) await denormalize_thread(session, thread_id)
await session.flush() await session.flush()
return thread_id return thread_id
async def repair_threads(session: AsyncSession, user_id: uuid.UUID) -> int:
"""Recount the user's threads whose stored counters disagree with their
messages (and drop threads left without any). A safety net run nightly
(app/indexer/gc.py): every write path recounts its threads, but a missed
one would otherwise show a read message as unread until it changed again.
Returns how many were repaired."""
seen = Message.flags.any("\\Seen") # type: ignore[arg-type]
actual = (
select(
Message.thread_id.label("tid"),
func.count().label("n"),
func.count().filter(~seen).label("unread"),
)
.where(Message.user_id == user_id, Message.thread_id.is_not(None))
.group_by(Message.thread_id)
.subquery()
)
stale = list(
await session.scalars(
select(Thread.id)
.outerjoin(actual, actual.c.tid == Thread.id)
.where(
Thread.user_id == user_id,
(actual.c.tid.is_(None) & (Thread.message_count != 0))
| (actual.c.tid.is_(None) & ~exists().where(Draft.thread_id == Thread.id))
| (actual.c.n != Thread.message_count)
| (actual.c.unread != Thread.unread_count),
)
)
)
for tid in sorted(stale):
await denormalize_thread(session, tid)
return len(stale)
@@ -0,0 +1,75 @@
"""hosted mailboxes: native accounts and the mail server's directory (SPEC App. D)
* ``accounts.kind`` — ``external`` (any IMAP/SMTP server, the user's own
password in the vault) or ``native`` (the user's mailbox on the hosted mail
server: permanent, signed in to without any stored password, §D.4).
* ``mail_directory`` — the view the mail server's SQL directory reads (§D.3):
one row per user, its address — and no password: the webmail password never
opens the mailbox in a mail app, only app passwords do (the mail server's
own, §D.5) — and ``active`` false for a blocked user. Plus the service accounts of
``mail_service_accounts``: the webmail's own worker, which may act for any
mailbox and nothing else.
* ``user_app_passwords.server_id`` — the mail server's id of the same app
password (§D.5), to revoke it there too.
The mail server reads the view as its own role, given SELECT on it alone by
``app.hosted.provision`` (the role is created there, with its password).
Revision ID: 0025
Revises: 0024
Create Date: 2026-10-11
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0025"
down_revision: str | None = "0024"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN kind text NOT NULL DEFAULT 'external'")
op.execute(
"ALTER TABLE accounts ADD CONSTRAINT ck_accounts_kind "
"CHECK (kind IN ('external', 'native'))"
)
# One mailbox per user.
op.execute("CREATE UNIQUE INDEX uq_accounts_native ON accounts (user_id) WHERE kind = 'native'")
op.execute("ALTER TABLE user_app_passwords ADD COLUMN server_id text")
op.execute(
"""
CREATE TABLE mail_service_accounts (
name citext PRIMARY KEY,
secret text NOT NULL,
description text NOT NULL DEFAULT ''
)
"""
)
op.execute(
"""
CREATE VIEW mail_directory AS
SELECT u.handle AS name,
NULL::text AS secret,
'individual'::text AS type,
u.handle::text AS description,
u.blocked_at IS NULL AS active
FROM users u
UNION ALL
SELECT s.name, s.secret, 'individual'::text, s.description, true
FROM mail_service_accounts s
"""
)
def downgrade() -> None:
op.execute("DROP VIEW IF EXISTS mail_directory")
op.execute("DROP TABLE IF EXISTS mail_service_accounts")
op.execute("ALTER TABLE user_app_passwords DROP COLUMN IF EXISTS server_id")
op.execute("DROP INDEX IF EXISTS uq_accounts_native")
op.execute("ALTER TABLE accounts DROP CONSTRAINT IF EXISTS ck_accounts_kind")
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS kind")
@@ -0,0 +1,40 @@
"""IMAP ids as 64-bit integers
UIDVALIDITY and UIDs are unsigned 32-bit numbers (RFC 9051), MODSEQ up to 63
bits (RFC 7162); Postgres ``integer`` is signed 32-bit. Servers that hand out
values above 2^31 — the hosted mail server's UIDVALIDITY, for one — could not
be stored ("value out of int32 range") and their folders never synced.
Revision ID: 0026
Revises: 0025
Create Date: 2026-10-11
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0026"
down_revision: str | None = "0025"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_COLUMNS = (
("mailboxes", "uidvalidity"),
("mailboxes", "highestmodseq"),
("mailboxes", "last_seen_uid"),
("messages", "uid"),
("drafts", "imap_uid"),
)
def upgrade() -> None:
for table, column in _COLUMNS:
op.execute(f"ALTER TABLE {table} ALTER COLUMN {column} TYPE bigint")
def downgrade() -> None:
for table, column in _COLUMNS:
op.execute(f"ALTER TABLE {table} ALTER COLUMN {column} TYPE integer")
+13
View File
@@ -18,6 +18,19 @@ os.environ.setdefault(
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15" os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode()) os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test") os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
# Hosted mailboxes off: tests that need the mail server turn them on themselves
# (tests/hosted/), against the dev stack's Stalwart container.
os.environ["HOSTED_MAILBOXES"] = "false"
for _key, _value in {
"STALWART_URL": "http://127.0.0.1:8180",
"STALWART_ADMIN_SECRET": "stalwart_admin_dev",
"STALWART_MASTER_SECRET": "stalwart_master_dev",
"STALWART_IMAP_HOST": "127.0.0.1",
"STALWART_IMAP_PORT": "1993",
"STALWART_SMTP_HOST": "127.0.0.1",
"STALWART_SMTP_PORT": "1465",
}.items():
os.environ.setdefault(_key, _value)
from collections.abc import AsyncIterator # noqa: E402 from collections.abc import AsyncIterator # noqa: E402
@@ -0,0 +1,269 @@
"""Hosted mailboxes (SPEC App. D) against the dev stack's Stalwart container.
Sign-in through the directory (password, 2FA, block), app passwords made by the
mail server, the permanent native account, and raw messages kept only by the
mail server (fetched back on demand). Skipped when the container is not up.
"""
from __future__ import annotations
import asyncio
import imaplib
import smtplib
import ssl
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from email.message import EmailMessage
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select
from app.config import get_settings
from app.db import session_scope
from app.hosted import admin, native
from app.identity import app_passwords
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.identity.user_admin import delete_user, set_blocked
from app.indexer import blobstore, rawstore
from app.main import create_app
from app.models import Account, Attachment, Mailbox, Message, User
PASSWORD = "Correct-Horse-7"
async def _server_domain() -> str | None:
try:
settings = await admin.get_one("SystemSettings")
domain = await admin.get_one("Domain", str(settings["defaultDomainId"]))
except admin.MailServerError:
return None
return str(domain["name"])
@dataclass
class Ctx:
user: User
web: httpx.AsyncClient
@pytest_asyncio.fixture
async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
domain = await _server_domain()
if domain is None:
pytest.skip("the dev mail server (docker compose service stalwart) is not running")
settings = get_settings()
monkeypatch.setattr(settings, "hosted_mailboxes", True)
monkeypatch.setattr(settings, "app_domain", domain)
handle = f"hosted{uuid.uuid4().hex[:8]}@{domain}"
async with session_scope() as db:
user = User(handle=handle, password_hash=hash_password(PASSWORD))
db.add(user)
token = await create_session(user.id, "pytest")
web = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
)
try:
yield Ctx(user, web)
finally:
await web.aclose()
await admin.delete_account(handle)
async with session_scope() as db:
await db.execute(delete(User).where(User.id == user.id))
def _imap_login(user: str, password: str) -> bool:
tls = ssl.create_default_context()
tls.check_hostname = False
tls.verify_mode = ssl.CERT_NONE
settings = get_settings()
try:
# A blocked account's sign-in is not answered: the server drops the
# connection after a minute. Not answering within seconds is a refusal.
conn = imaplib.IMAP4_SSL(
settings.stalwart_imap_host, settings.stalwart_imap_port, ssl_context=tls, timeout=5
)
except OSError:
return False
try:
conn.login(user, password)
conn.logout()
return True
except (imaplib.IMAP4.error, OSError):
return False
async def _can_sign_in(user: str, password: str) -> bool:
return await asyncio.to_thread(_imap_login, user, password)
def _deliver(to: str, subject: str, attachment: bytes) -> None:
msg = EmailMessage()
msg["From"] = "Anna Test <anna@example.org>"
msg["To"] = to
msg["Subject"] = subject
msg.set_content("Hello,\n\nthe report is attached.\n")
msg.add_attachment(attachment, maintype="application", subtype="octet-stream", filename="r.bin")
with smtplib.SMTP(get_settings().stalwart_imap_host, 1025, timeout=20) as smtp:
smtp.send_message(msg)
async def test_the_webmail_password_never_opens_the_mailbox(ctx: Ctx) -> None:
"""Mail apps sign in with app passwords only — 2FA or not."""
assert not await _can_sign_in(ctx.user.handle, PASSWORD)
assert not await _can_sign_in(ctx.user.handle, "wrong")
async def test_app_passwords_work_for_mail_apps_and_dav_and_are_revoked_everywhere(
ctx: Ctx,
) -> None:
handle = ctx.user.handle
async with session_scope() as db:
row, password = await app_passwords.create(db, ctx.user.id, "Thunderbird")
assert password.startswith("app_") and len(password) == app_passwords.SERVER_LENGTH
assert row.server_id
assert await _can_sign_in(handle, password)
async with session_scope() as db:
assert await app_passwords.verify(db, ctx.user.id, password) is not None # DAV
# 2FA turned on changes nothing for it.
async with session_scope() as db:
user = await db.get(User, ctx.user.id)
assert user is not None
user.totp_secret = b"x"
assert await _can_sign_in(handle, password)
# Blocked: nothing opens the mailbox; unblocked: it does again.
async with session_scope() as db:
user = await db.get(User, ctx.user.id)
assert user is not None
await set_blocked(db, user, True)
assert not await _can_sign_in(handle, password)
async with session_scope() as db:
user = await db.get(User, ctx.user.id)
assert user is not None
await set_blocked(db, user, False)
assert await _can_sign_in(handle, password)
async with session_scope() as db:
assert await app_passwords.revoke(db, ctx.user.id, row.id)
assert not await _can_sign_in(handle, password)
async def test_the_native_account_keeps_mail_once_and_cannot_be_removed(ctx: Ctx) -> None:
from app.sync.engine import bootstrap_and_backfill
async with session_scope() as db:
user = await db.get(User, ctx.user.id)
assert user is not None
account = await native.ensure(db, user)
assert account is not None
account_id = account.id
payload = uuid.uuid4().bytes * 5000
await asyncio.to_thread(_deliver, ctx.user.handle, "Report", payload)
# Synced like any IMAP account (delivery is asynchronous: retry briefly).
message = None
for _ in range(20):
await bootstrap_and_backfill(account_id)
async with session_scope() as db:
message = await db.scalar(
select(Message).where(Message.account_id == account_id, Message.subject == "Report")
)
if message is not None:
break
await asyncio.sleep(0.5)
assert message is not None
# A new mailbox has the plain folder names (not "Deleted Items", …).
async with session_scope() as db:
folders = dict(
(
await db.execute(
select(Mailbox.special_use, Mailbox.path).where(
Mailbox.account_id == account_id
)
)
).all()
)
assert folders["trash"] == "Trash" and folders["sent"] == "Sent"
assert folders["junk"] == "Junk" and folders["archive"] == "Archive"
# The raw message is not kept for good — only cached.
sha = message.blob_sha256
assert not blobstore.exists(ctx.user.id, sha)
assert rawstore.cache_path(ctx.user.id, sha).exists()
# Gone from the cache, the attachment is fetched back from the mail server.
rawstore.drop(ctx.user.id, sha)
async with session_scope() as db:
att = await db.scalar(select(Attachment).where(Attachment.message_id == message.id))
assert att is not None
got = await ctx.web.get(f"/api/messages/{message.id}/attachments/{att.id}?download=1")
assert got.status_code == 200 and got.content == payload
assert rawstore.cache_path(ctx.user.id, sha).exists()
# Permanent: not removable, its server settings not changeable.
listed = (await ctx.web.get("/api/accounts")).json()
assert [a["kind"] for a in listed] == ["native"]
assert (await ctx.web.delete(f"/api/accounts/{account_id}")).status_code == 409
patched = await ctx.web.patch(f"/api/accounts/{account_id}", json={"password": "x"})
assert patched.status_code == 400 and patched.json()["error"]["code"] == "native_account"
async def test_deleting_the_user_deletes_the_mailbox(ctx: Ctx) -> None:
await admin.ensure_account(ctx.user.handle) # brings it into the server
assert await admin.account_id(ctx.user.handle) is not None
async with session_scope() as db:
user = await db.get(User, ctx.user.id)
assert user is not None
await delete_user(db, user)
assert await admin.account_id(ctx.user.handle) is None
async with session_scope() as db:
assert await db.scalar(select(Account.id).where(Account.user_id == ctx.user.id)) is None
async def test_the_cache_keeps_the_most_recently_used(ctx: Ctx) -> None:
import os
import time
uid = ctx.user.id
old = rawstore.put_cached(uid, b"a" * 1000)
new = rawstore.put_cached(uid, b"b" * 1000)
past = time.time() - 60
os.utime(rawstore.cache_path(uid, old), (past, past))
rawstore.prune(uid, max_bytes=1500)
assert not rawstore.cache_path(uid, old).exists()
assert rawstore.cache_path(uid, new).exists()
stale = time.time() - rawstore.KEEP_S - 10
os.utime(rawstore.cache_path(uid, new), (stale, stale))
rawstore.prune(uid)
assert not rawstore.cache_path(uid, new).exists()
async def test_moves_reach_folders_with_spaces_in_their_names(ctx: Ctx) -> None:
"""Older mailboxes (and many providers) say "Deleted Items": the name goes
over the wire quoted, or the move is refused and Trash never fills."""
from app.providers.imap_smtp import ImapSmtpProvider
await admin.ensure_account(ctx.user.handle)
msg = EmailMessage()
msg["From"] = "a@example.com"
msg["To"] = ctx.user.handle
msg["Subject"] = "Swipe me"
msg.set_content("x")
provider = ImapSmtpProvider(native.native_provider_config(ctx.user.handle))
try:
await provider.create_mailbox("Deleted Items")
uid = await provider.append("INBOX", msg.as_bytes(), frozenset())
moved = await provider.move(uid, "INBOX", "Deleted Items")
assert moved > 0
finally:
await provider.close()
@@ -8,6 +8,7 @@ from __future__ import annotations
import uuid import uuid
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path
from app.indexer import indexer from app.indexer import indexer
from app.indexer.mime import parse_message, parse_message_file from app.indexer.mime import parse_message, parse_message_file
@@ -50,6 +51,8 @@ async def test_parse_bounded_streams_large_messages(monkeypatch) -> None: # typ
monkeypatch.setattr(indexer, "parse_message_file", fake_file) monkeypatch.setattr(indexer, "parse_message_file", fake_file)
monkeypatch.setattr(indexer, "parse_message", fake_bytes) monkeypatch.setattr(indexer, "parse_message", fake_bytes)
# The message's file, written before parsing (blob store or raw cache).
monkeypatch.setattr(indexer.rawstore, "locate", lambda user_id, sha: Path("/blob"))
user_id = uuid.uuid4() user_id = uuid.uuid4()
small = await indexer._parse_bounded(user_id, "sha", b"x" * 16) small = await indexer._parse_bounded(user_id, "sha", b"x" * 16)
+9 -1
View File
@@ -115,7 +115,15 @@ async def test_settings_defaults_then_merge(ctx: _Ctx) -> None:
s = (await ctx.client_a.get(f"{BASE}/settings")).json() s = (await ctx.client_a.get(f"{BASE}/settings")).json()
assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0) assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0)
assert s["network_checks"] is False # privacy default (§C.2) assert s["network_checks"] is False # privacy default (§C.2)
assert s["enabled_accounts"] == [] # empty = every account assert s["enabled_accounts"] is None # every account
# Every account off is stored as such (not read back as "all on"), and
# all_accounts goes back to every account.
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"enabled_accounts": []})).json()
assert s["enabled_accounts"] == []
assert (await ctx.client_a.get(f"{BASE}/settings")).json()["enabled_accounts"] == []
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"all_accounts": True})).json()
assert s["enabled_accounts"] is None
# PATCH merges: the field we leave out keeps its value. # PATCH merges: the field we leave out keeps its value.
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json() s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json()
@@ -32,7 +32,7 @@ from webmail_plugin_antispam import policy, rspamd
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
SETTINGS = policy.Settings( SETTINGS = policy.Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=12.0, move_threshold=12.0,
banner_threshold=6.0, banner_threshold=6.0,
network_checks=False, network_checks=False,
@@ -68,7 +68,7 @@ def wired(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
async def _settings(_u: object) -> policy.Settings: async def _settings(_u: object) -> policy.Settings:
return policy.Settings( return policy.Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=12.0, move_threshold=12.0,
banner_threshold=6.0, banner_threshold=6.0,
network_checks=False, network_checks=False,
@@ -14,7 +14,7 @@ import pytest
from webmail_plugin_antispam import hook, jobs, policy from webmail_plugin_antispam import hook, jobs, policy
SETTINGS = policy.Settings( SETTINGS = policy.Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=12.0, move_threshold=12.0,
banner_threshold=6.0, banner_threshold=6.0,
network_checks=False, network_checks=False,
@@ -169,7 +169,7 @@ async def test_bootstrap_respects_the_setting(monkeypatch: pytest.MonkeyPatch) -
async def _settings(_u: object) -> policy.Settings: async def _settings(_u: object) -> policy.Settings:
return policy.Settings( return policy.Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=12.0, move_threshold=12.0,
banner_threshold=6.0, banner_threshold=6.0,
network_checks=False, network_checks=False,
+11 -2
View File
@@ -6,7 +6,7 @@ import pytest
from webmail_plugin_antispam.policy import Settings, decide, matches from webmail_plugin_antispam.policy import Settings, decide, matches
SETTINGS = Settings( SETTINGS = Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=12.0, move_threshold=12.0,
banner_threshold=6.0, banner_threshold=6.0,
network_checks=False, network_checks=False,
@@ -79,7 +79,7 @@ def test_pattern_matching(patterns: set[str], email: str, hit: bool) -> None:
def test_per_account_enablement() -> None: def test_per_account_enablement() -> None:
"""Empty list means every account; a populated one is an allow-list.""" """None means every account; a list is an allow-list — empty, none."""
everywhere = SETTINGS everywhere = SETTINGS
assert everywhere.covers("any-account-id") assert everywhere.covers("any-account-id")
@@ -93,6 +93,15 @@ def test_per_account_enablement() -> None:
assert scoped.covers("acc-1") assert scoped.covers("acc-1")
assert not scoped.covers("acc-2") assert not scoped.covers("acc-2")
nowhere = Settings(
enabled_accounts=[],
move_threshold=12.0,
banner_threshold=6.0,
network_checks=False,
learn_from_provider_junk=True,
)
assert not nowhere.covers("acc-1")
# --- contacts are never filed as spam ---------------------------------------- # --- contacts are never filed as spam ----------------------------------------
+104
View File
@@ -0,0 +1,104 @@
"""Thread counters that drifted from their messages are repaired (nightly safety
net, app/threading_/threader.py repair_threads)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from email.message import EmailMessage
from sqlalchemy import delete, select, update
from app.db import session_scope
from app.identity.passwords import hash_password
from app.indexer.indexer import persist_message
from app.models import Account, Draft, Mailbox, Thread, User
from app.providers.base import RemoteMessage
from app.threading_.threader import repair_threads
def _raw(subject: str) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "billing@example.com"
m["To"] = "me@example.com"
m["Message-ID"] = f"<{uuid.uuid4().hex}@example.com>"
m.set_content("receipt")
return m.as_bytes()
async def test_repair_recounts_stale_threads_and_drops_empty_ones() -> None:
async with session_scope() as db:
user = User(
handle=f"rep{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
db.add(user)
await db.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
db.add(account)
await db.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
db.add(inbox)
await db.flush()
result = await persist_message(
db,
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=RemoteMessage(
uid=1,
raw=_raw("Your receipt"),
flags=frozenset({"\\Seen"}),
internal_date=datetime.now(UTC),
),
)
assert result is not None
tid = result.message.thread_id
assert tid is not None
# A read message whose thread still says "unread" (the bug), and a
# thread left behind with no messages at all.
await db.execute(update(Thread).where(Thread.id == tid).values(unread_count=1))
empty = Thread(
user_id=user.id,
subject_norm="gone",
message_count=1,
unread_count=1,
last_message_at=datetime.now(UTC),
)
db.add(empty)
# An emptied thread a reply draft still points at: kept, counts zeroed.
replied = Thread(
user_id=user.id,
subject_norm="replying",
message_count=1,
unread_count=0,
last_message_at=datetime.now(UTC),
)
db.add(replied)
await db.flush()
db.add(Draft(user_id=user.id, account_id=account.id, thread_id=replied.id, payload={}))
await db.flush()
empty_id = empty.id
replied_id = replied.id
try:
async with session_scope() as db:
assert await repair_threads(db, user.id) == 3
async with session_scope() as db:
thread = await db.get(Thread, tid)
assert thread is not None and thread.unread_count == 0 and thread.message_count == 1
assert await db.get(Thread, empty_id) is None
kept = await db.get(Thread, replied_id)
assert kept is not None and kept.message_count == 0
assert await repair_threads(db, user.id) == 0 # nothing left to repair
finally:
async with session_scope() as db:
await db.execute(delete(User).where(User.id == user.id)) # cascades
assert await db.scalar(select(User.id).where(User.id == user.id)) is None
+24
View File
@@ -117,6 +117,29 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# Hosted mailboxes (SPEC Appendix D). Everything but where its database is
# (config.json) lives in that database and is set up by
# `python -m app.hosted.provision`, which also creates the database and its
# role — so the first start waits (restarting) until that has run once.
# Dev publishes the ports on loopback, off the standard numbers.
stalwart:
image: stalwartlabs/stalwart:v0.16.19
environment:
STALWART_DB_PASSWORD: ${STALWART_DB_PASSWORD:-stalwart_dev}
STALWART_RECOVERY_ADMIN: admin:${STALWART_ADMIN_SECRET:-stalwart_admin_dev}
volumes:
- ./stalwart/config.json:/etc/stalwart/config.json:ro
- stalwartdata:/var/lib/stalwart
ports:
- "127.0.0.1:8180:8080" # management API (JMAP)
- "127.0.0.1:1025:25" # SMTP — mail from "outside"
- "127.0.0.1:1465:465" # submissions
- "127.0.0.1:1587:587" # submission (STARTTLS)
- "127.0.0.1:1993:993" # IMAPS
depends_on:
postgres: { condition: service_healthy }
restart: unless-stopped
# Test mail servers (integration tests + manual dev against a throwaway mailbox) # Test mail servers (integration tests + manual dev against a throwaway mailbox)
# Spelling and grammar checking for the compose window (POST /api/spellcheck # Spelling and grammar checking for the compose window (POST /api/spellcheck
# forwards to it; the browser never talks to it directly). No n-gram data: # forwards to it; the browser never talks to it directly). No n-gram data:
@@ -152,3 +175,4 @@ volumes:
blobs: blobs:
rspamddata: rspamddata:
rspamdredis: rspamdredis:
stalwartdata:
+15
View File
@@ -0,0 +1,15 @@
{
"@type": "PostgreSql",
"host": "postgres",
"port": 5432,
"database": "stalwart",
"authUsername": "stalwart",
"authSecret": { "@type": "EnvironmentVariable", "variableName": "STALWART_DB_PASSWORD" },
"useTls": false,
"allowInvalidCerts": false,
"poolMaxConnections": 10,
"poolRecyclingMethod": "fast",
"timeout": 15000,
"readReplicas": {},
"options": null
}
+1 -1
View File
@@ -10,7 +10,7 @@ Notes for switching `ultimo.email` mail from home.pl to Ultimo's own mail server
|---|---| |---|---|
| Hosting | **Everything on the QNAP** — receive and send directly (spec §D.1 option A). A relay (home.pl SMTP, SMTP2GO, Brevo) is optional, as a fallback only. | | Hosting | **Everything on the QNAP** — receive and send directly (spec §D.1 option A). A relay (home.pl SMTP, SMTP2GO, Brevo) is optional, as a fallback only. |
| Domain | Addresses `name@ultimo.email`; webmail stays at `https://ultimo.email`. | | Domain | Addresses `name@ultimo.email`; webmail stays at `https://ultimo.email`. |
| Mail apps | **Allowed**: Thunderbird and phone mail apps use the mailboxes over IMAP/SMTP, signing in with **app passwords** (required when 2FA is on). | | Mail apps | **Allowed**: Thunderbird and phone mail apps use the mailboxes over IMAP/SMTP, signing in with **app passwords** only (never the webmail password). |
| Mail host name | **`mail.ultimo.email`** (recommended; `ultimo.email` would also work, see below). | | Mail host name | **`mail.ultimo.email`** (recommended; `ultimo.email` would also work, see below). |
## Current setup ## Current setup
+22 -6
View File
@@ -3,7 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
import { fixInvisibleText } from "../../lib/readability"; import { fixInvisibleText } from "../../lib/readability";
import { useTheme } from "../../lib/theme";
import type { MessageEnvelope } from "../../lib/types"; import type { MessageEnvelope } from "../../lib/types";
import { Attachments } from "./Attachments"; import { Attachments } from "./Attachments";
@@ -30,6 +32,12 @@ export function MessageBody({
const onMailtoRef = useRef(onMailto); const onMailtoRef = useRef(onMailto);
onMailtoRef.current = onMailto; onMailtoRef.current = onMailto;
const [height, setHeight] = useState(80); const [height, setHeight] = useState(80);
// Dark message mode (lib/darkMessage.ts): a dark page in the dark theme,
// unless switched off in the subject bar.
const darkMessages = useDarkMessages();
const dark = useTheme().resolved === "dark" && darkMessages;
const darkRef = useRef(dark);
darkRef.current = dark;
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ["message-html", message.id, allowed], queryKey: ["message-html", message.id, allowed],
@@ -89,7 +97,8 @@ export function MessageBody({
btn.setAttribute( btn.setAttribute(
"style", "style",
"display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" + "display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" +
"background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;", (darkRef.current ? "background:#3f444a;color:#c9ccd1;" : "background:#e5e7eb;color:#4b5563;") +
"cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
); );
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
quote.style.display = quote.style.display === "none" ? "" : "none"; quote.style.display = quote.style.display === "none" ? "" : "none";
@@ -131,8 +140,13 @@ export function MessageBody({
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding // overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
// (horizontal stays scrollable for genuinely wide emails). // (horizontal stays scrollable for genuinely wide emails).
doc.documentElement.style.overflowY = "hidden"; doc.documentElement.style.overflowY = "hidden";
// Before the quote is folded away: hidden text counts as hidden on purpose. if (darkRef.current) {
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts) // The sender's colours that would not read on the dark page.
adaptForDarkMode(doc);
} else {
// Before the quote is folded away: hidden text counts as hidden on purpose.
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
}
injectQuoteToggle(doc); injectQuoteToggle(doc);
resize(); resize();
// Re-measure after the first paint settles (fonts/layout can shift the height // Re-measure after the first paint settles (fonts/layout can shift the height
@@ -175,7 +189,7 @@ export function MessageBody({
}; };
frame = requestAnimationFrame(check); frame = requestAnimationFrame(check);
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [data, prepare]); }, [data, dark, prepare]);
useEffect(() => () => observerRef.current?.disconnect(), []); useEffect(() => () => observerRef.current?.disconnect(), []);
@@ -213,13 +227,15 @@ export function MessageBody({
)} )}
{data !== undefined && !isLoading && ( {data !== undefined && !isLoading && (
<iframe <iframe
// A new frame when the mode changes: the message is rendered afresh.
key={dark ? "dark" : "light"}
ref={frameRef} ref={frameRef}
title={`message-${message.id}`} title={`message-${message.id}`}
sandbox="allow-same-origin" sandbox="allow-same-origin"
srcDoc={data} srcDoc={dark ? withDarkPage(data) : data}
onLoad={onLoad} onLoad={onLoad}
style={{ height }} style={{ height }}
className="block w-full rounded-lg border-0 bg-white" className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
/> />
)} )}
{message.has_attachments && <Attachments messageId={message.id} />} {message.has_attachments && <Attachments messageId={message.id} />}
+33 -2
View File
@@ -3,9 +3,11 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { personHue } from "../../lib/avatarColor"; import { personHue } from "../../lib/avatarColor";
import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { printMessages } from "../../lib/print"; import { printMessages } from "../../lib/print";
import { replyTargets } from "../../lib/replyTargets"; import { replyTargets } from "../../lib/replyTargets";
import { useTheme } from "../../lib/theme";
import { useIsMobile } from "../../lib/useIsMobile"; import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale, getHour12 } from "../../lib/timeFormat"; import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
@@ -726,6 +728,8 @@ export function ThreadView({
const composerRef = useRef<HTMLDivElement | null>(null); const composerRef = useRef<HTMLDivElement | null>(null);
// On a phone, reply/reply-all/forward open full screen rather than inline. // On a phone, reply/reply-all/forward open full screen rather than inline.
const mobile = useIsMobile(); const mobile = useIsMobile();
const resolvedTheme = useTheme().resolved;
const darkMessages = useDarkMessages();
// Bring the inline composer into view when it opens (reply/reply-all/forward). // Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => { useEffect(() => {
if (composing && !mobile) { if (composing && !mobile) {
@@ -754,8 +758,35 @@ export function ThreadView({
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} /> <LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */}
{resolvedTheme === "dark" && (
<button
role="switch"
aria-checked={darkMessages}
className="flex items-center gap-1.5 rounded px-1.5 py-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={darkMessages ? t("mail.darkMessagesOn") : t("mail.darkMessagesOff")}
aria-label={t("mail.darkMessages")}
onClick={() => setDarkMessages(!darkMessages)}
>
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
<span
aria-hidden
className={`relative inline-block h-4 w-7 rounded-full transition-colors ${
darkMessages ? "bg-app-accent" : "bg-app-border"
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-[left] ${
darkMessages ? "left-[14px]" : "left-0.5"
}`}
/>
</span>
</button>
)}
<button <button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.print")} title={t("mail.print")}
aria-label={t("mail.print")} aria-label={t("mail.print")}
onClick={() => void printMessages(subject, visual)} onClick={() => void printMessages(subject, visual)}
@@ -763,7 +794,7 @@ export function ThreadView({
<Icon name="print" size={16} /> <Icon name="print" size={16} />
</button> </button>
<button <button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")} title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")} aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
onClick={toggleAll} onClick={toggleAll}
+13 -1
View File
@@ -391,7 +391,14 @@ export const en = {
"appPasswords.title": "App passwords", "appPasswords.title": "App passwords",
"appPasswords.help": "appPasswords.help":
"Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.", "Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.",
"appPasswords.connectTitle": "Calendars and contacts in other apps", "appPasswords.helpMail":
"Mail apps — Thunderbird, your phone's Mail app, Outlook — and calendar and contacts apps sign in to Ultimo with an app password. Your account password never works in them. Give each app its own, so you can revoke one without affecting the others.",
"appPasswords.mailTitle": "External mail clients configuration:",
"appPasswords.mailIncoming": "Incoming (IMAP)",
"appPasswords.mailOutgoing": "Outgoing (SMTP)",
"appPasswords.mailPorts": "{host}, port {port}, SSL/TLS",
"appPasswords.mailPortsAlt": "or port {port}, STARTTLS",
"appPasswords.connectTitle": "External Calendars and Contacts apps configuration:",
"appPasswords.connectServer": "Server", "appPasswords.connectServer": "Server",
"appPasswords.connectUser": "User name", "appPasswords.connectUser": "User name",
"appPasswords.connectPassword": "Password", "appPasswords.connectPassword": "Password",
@@ -566,6 +573,11 @@ export const en = {
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.", "settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
"settings.signature": "Signature", "settings.signature": "Signature",
"settings.configureAccount": "Configure account", "settings.configureAccount": "Configure account",
"settings.yourMailbox": "Your mailbox",
"mail.darkMessages": "Dark message mode",
"mail.darkMessagesOn": "Dark message mode is on: messages are shown on a dark page. Click to show them as sent.",
"mail.darkMessagesOff": "Dark message mode is off: messages are shown as sent. Click to show them on a dark page.",
"settings.yourMailboxHint": "Your Ultimo address. It stays as long as your account does.",
"settings.editSignature": "Edit signature", "settings.editSignature": "Edit signature",
"settings.theme": "Theme", "settings.theme": "Theme",
"theme.light": "Light", "theme.light": "Light",
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { adaptForDarkMode, contrast, luminance, parseCssColor, sanitizeStyle, withDarkPage } from "./darkMessage";
function styleOf(css: string): CSSStyleDeclaration {
const el = document.createElement("div");
el.setAttribute("style", css);
return el.style;
}
describe("colours", () => {
it("parses hex, rgb and hsl, and refuses what is not a colour", () => {
expect(parseCssColor("#fff")).toEqual([255, 255, 255, 1]);
expect(parseCssColor("#00000080")?.[3]).toBeCloseTo(0.5, 2);
expect(parseCssColor("rgba(10, 20, 30, 0.4)")).toEqual([10, 20, 30, 0.4]);
expect(parseCssColor("rgb(10 20 30 / 50%)")).toEqual([10, 20, 30, 0.5]);
expect(parseCssColor("hsl(0, 100%, 50%)")?.map(Math.round)).toEqual([255, 0, 0, 1]);
expect(parseCssColor("url(x.png)")).toBeNull();
expect(parseCssColor("#fff url(x.png)")).toBeNull();
});
it("uses Thunderbird's luminance (0-255) and contrast", () => {
expect(luminance("#ffffff")).toBeCloseTo(255, 0);
expect(luminance("#000")).toBe(0);
expect(luminance("not a colour")).toBe(0);
expect(contrast("#000", "#fff")).toBeGreaterThan(1000);
expect(contrast("#777", "#888")).toBeLessThan(3.5);
});
});
describe("sanitizeStyle", () => {
it("drops dark text with no background, keeps light text", () => {
const dark = styleOf("color: #333");
sanitizeStyle(dark);
expect(dark.color).toBe("");
const light = styleOf("color: #f0f0f0");
sanitizeStyle(light);
expect(light.color).not.toBe("");
});
it("drops a light background, and the dark text that was on it", () => {
const s = styleOf("background-color: #ffffff; color: #222222");
sanitizeStyle(s);
expect(s.backgroundColor).toBe("");
expect(s.color).toBe("");
});
it("keeps a dark box with light text as designed", () => {
const s = styleOf("background-color: #003366; color: #ffffff");
sanitizeStyle(s);
expect(s.backgroundColor).not.toBe("");
expect(s.color).not.toBe("");
});
it("drops a dark background its text does not stand out from", () => {
const s = styleOf("background-color: #303030; color: #404040");
sanitizeStyle(s);
expect(s.backgroundColor).toBe("");
expect(s.color).toBe("");
});
it("drops gradients", () => {
const s = styleOf("background: linear-gradient(#000, #111); color: #fff");
sanitizeStyle(s);
expect(s.getPropertyValue("background") + s.getPropertyValue("background-image")).not.toContain("gradient");
});
});
describe("adaptForDarkMode", () => {
it("clears the body's colours and colour attributes, and adapts style sheets", () => {
document.head.innerHTML = "<style>.card { background-color: #ffffff; color: #111111 } .ok { color: #eeeeee }</style>";
document.body.innerHTML =
'<table bgcolor="#ffffff"><tr><td><font color="#000080">Hi</font></td></tr></table>' +
'<p style="color: #444444">dark text</p><p style="background-color: #102030; color: #fafafa">box</p>';
document.body.setAttribute("bgcolor", "#ffffff");
document.body.style.color = "#000000";
adaptForDarkMode(document);
expect(document.body.getAttribute("bgcolor")).toBeNull();
expect(document.body.style.color).toBe("");
expect(document.querySelector("table")!.getAttribute("bgcolor")).toBeNull();
expect(document.querySelector("font")!.getAttribute("color")).toBeNull();
const [p1, p2] = Array.from(document.querySelectorAll("p"));
expect(p1.style.color).toBe("");
expect(p2.style.backgroundColor).not.toBe("");
const rules = Array.from((document.querySelector("style")!.sheet as CSSStyleSheet).cssRules) as CSSStyleRule[];
expect(rules[0].style.getPropertyValue("background-color")).toBe("");
expect(rules[0].style.getPropertyValue("color")).toBe("");
expect(rules[1].style.getPropertyValue("color")).not.toBe("");
});
it("puts the dark page in the head", () => {
const html = withDarkPage("<html><head><style>a{}</style></head><body>x</body></html>");
expect(html.indexOf("wm-dark-message")).toBeLessThan(html.indexOf("</head>"));
});
});
+239
View File
@@ -0,0 +1,239 @@
// Dark message mode: show a message on a dark page when the app is dark.
//
// A port of Thunderbird's "dark reader" (mail/base/content/modules/DarkReader.mjs,
// Thunderbird 140 ESR, MPL 2.0). Thunderbird does not invert anything: it gives
// the message a dark page and light text, then takes away the colours a sender
// set that would not read on it — any explicit text colour that is dark
// (luminance ≤ 200 of 255) when it has no background of its own, and any
// explicit background that is light (> 200) or that its text does not stand
// out from (contrast < 3.5). What a sender set that already works on dark — a
// dark coloured box with light text, light text, a picture — is kept.
// Gradients are dropped. Same for `<font color>`, `bgcolor` and table colour
// attributes, the body's own colours, the rules of the message's style sheets,
// and SVG text fills.
import { useSyncExternalStore } from "react";
export type Rgba = [number, number, number, number];
const LUMINANCE_THRESHOLD = 200;
export const CONTRAST_THRESHOLD = 3.5;
/** The dark page and its text: the reading pane's own panel colours. */
export const DARK_MESSAGE_CSS = `<style id="wm-dark-message">
:root { color-scheme: dark; }
html, body { background-color: #212427; color: #e2e4e7; }
a { color: #8ab4f8; }
blockquote { border-left-color: #3f444a; color: #b4b8be; }
</style>`;
// ---- colours -----------------------------------------------------------------
const cache = new Map<string, Rgba | null>();
function parseHex(hex: string): Rgba | null {
const h = hex.slice(1);
if (![3, 4, 6, 8].includes(h.length) || /[^0-9a-f]/i.test(h)) return null;
const full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
const n = (i: number) => parseInt(full.slice(i, i + 2), 16);
return [n(0), n(2), n(4), full.length === 8 ? n(6) / 255 : 1];
}
function parseFunction(value: string): Rgba | null {
const m = value.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
if (!m) return null;
const parts = m[2].split(/[\s,/]+/).filter(Boolean);
if (parts.length < 3) return null;
const num = (p: string, scale: number) => (p.endsWith("%") ? (parseFloat(p) / 100) * scale : parseFloat(p));
const alpha = parts[3] === undefined ? 1 : num(parts[3], 1);
if (m[1].toLowerCase().startsWith("rgb")) {
const [r, g, b] = parts.slice(0, 3).map((p) => num(p, 255));
return [r, g, b, alpha].some(Number.isNaN) ? null : [r, g, b, alpha];
}
const h = (((parseFloat(parts[0]) % 360) + 360) % 360) / 360;
const s = num(parts[1], 1);
const l = num(parts[2], 1);
const k = (n: number) => (n + h * 12) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => 255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
return [h, s, l, alpha].some(Number.isNaN) ? null : [f(0), f(8), f(4), alpha];
}
/** A CSS colour as RGBA (0–255, alpha 0–1), or null when it is not a colour
* (``none``, ``url(…)``, a background shorthand with more than a colour…). Named
* colours are resolved by the browser, in ``doc``. */
export function parseCssColor(value: string, doc?: Document): Rgba | null {
const v = value.trim().toLowerCase();
if (!v) return null;
const known = cache.get(v);
if (known !== undefined) return known;
let out: Rgba | null = null;
if (v === "transparent") out = [0, 0, 0, 0];
else if (v.startsWith("#")) out = parseHex(v);
else if (/^(rgb|hsl)a?\(/.test(v)) out = parseFunction(v);
else if (/^[a-z]+$/.test(v) && doc?.defaultView && doc.body) {
// A named colour (or a keyword like ``currentcolor``): ask the browser.
const probe = doc.createElement("span");
probe.style.color = v;
if (probe.style.color) {
probe.style.display = "none";
doc.body.appendChild(probe);
out = parseFunction(doc.defaultView.getComputedStyle(probe).color.replace(/\s+/g, " "));
probe.remove();
}
}
cache.set(v, out);
return out;
}
/** Thunderbird's luminance: 0.2125 R + 0.7154 G + 0.0721 B, 0–255; 0 when not a colour. */
export function luminance(color: string, doc?: Document): number {
const rgba = parseCssColor(color, doc);
if (!rgba) return 0;
const [r, g, b] = rgba;
return 0.2125 * r + 0.7154 * g + 0.0721 * b;
}
export function contrast(background: string, foreground: string, doc?: Document): number {
const bg = luminance(background, doc);
const fg = luminance(foreground, doc);
return (Math.max(bg, fg) + 0.05) / (Math.min(bg, fg) + 0.05);
}
/** Alpha of 20% or less counts as transparent (and so does "not a colour"). */
export function isTransparent(color: string, doc?: Document): boolean {
const rgba = parseCssColor(color, doc);
return !rgba || rgba[3] <= 0.2;
}
// ---- adapting a message -----------------------------------------------------------
/** Take away the colours of one style (inline, or a style-sheet rule) that would
* not read on a dark page. */
export function sanitizeStyle(style: CSSStyleDeclaration, doc?: Document): void {
const get = (property: string) => style.getPropertyValue(property);
if (!get("color") && !get("background") && !get("background-color")) return;
// Text colour with no background of its own: keep it only if it is light.
if (
(!get("background") || get("background") === "none") &&
(!get("background-color") || isTransparent(get("background-color"), doc))
) {
if (luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) style.removeProperty("color");
return;
}
const dropBackground = (property: "background-color" | "background") => {
const value = get(property);
if (!value || !parseCssColor(value, doc)) return; // none, or a picture etc.: kept
if (
luminance(value, doc) > LUMINANCE_THRESHOLD ||
(get("color") && contrast(get("color"), value, doc) < CONTRAST_THRESHOLD)
) {
style.removeProperty(property);
// On the dark page now: its text colour must be light too.
if (get("color") && luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) {
style.removeProperty("color");
}
}
};
dropBackground("background-color");
dropBackground("background");
// No chances with gradients behind text.
if (get("background").includes("gradient") || get("background-image").includes("gradient")) {
style.removeProperty("background");
style.removeProperty("background-image");
}
}
function sanitizeRules(rules: CSSRuleList, doc: Document): void {
for (const rule of Array.from(rules)) {
if ("selectorText" in rule && "style" in rule) sanitizeStyle((rule as CSSStyleRule).style, doc);
// @media, @supports…: their rules too.
else if ("cssRules" in rule) sanitizeRules((rule as CSSGroupingRule).cssRules, doc);
}
}
/** Adapt a rendered message document (with DARK_MESSAGE_CSS in its head) for
* dark mode. */
export function adaptForDarkMode(doc: Document): void {
const body = doc.body;
if (!body) return;
for (const attribute of ["bgcolor", "text", "link", "vlink"]) body.removeAttribute(attribute);
body.style.removeProperty("background-color");
body.style.removeProperty("color");
for (const node of Array.from(
doc.querySelectorAll<HTMLElement>(
':not(button):is([style*="color"],[style*="background"],[bgcolor],[color])',
),
)) {
node.removeAttribute("bgcolor"); // tables, mostly
node.removeAttribute("color"); // <font color>
if (node.hasAttribute("style")) sanitizeStyle(node.style, doc);
}
for (const node of Array.from(doc.getElementsByTagName("text"))) {
const fill = node.getAttribute("fill");
if (fill && luminance(fill, doc) <= LUMINANCE_THRESHOLD) node.setAttribute("fill", "currentColor");
}
for (const node of Array.from(doc.getElementsByTagName("style"))) {
if (node.id === "wm-dark-message") continue;
const sheet = (node as HTMLStyleElement).sheet;
if (!sheet) continue;
try {
sanitizeRules(sheet.cssRules, doc);
} catch {
/* an unreadable sheet: left as it is */
}
}
}
/** The message's HTML with the dark page in its head (before it renders, so it
* never flashes white). */
export function withDarkPage(html: string): string {
const at = html.search(/<\/head>/i);
return at < 0 ? DARK_MESSAGE_CSS + html : html.slice(0, at) + DARK_MESSAGE_CSS + html.slice(at);
}
// ---- the switch -----------------------------------------------------------------
// Per device, like the theme (lib/theme.tsx). On unless turned off — as in
// Thunderbird, where mail.dark-reader.enabled defaults to true.
const KEY = "darkMessages";
const listeners = new Set<() => void>();
function read(): boolean {
try {
return localStorage.getItem(KEY) !== "0";
} catch {
return true;
}
}
export function setDarkMessages(on: boolean): void {
try {
localStorage.setItem(KEY, on ? "1" : "0");
} catch {
/* kept for this page only */
}
listeners.forEach((l) => l());
}
export function useDarkMessages(): boolean {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
const onStorage = (e: StorageEvent) => e.key === KEY && cb();
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
};
},
read,
() => true,
);
}
+4
View File
@@ -13,6 +13,8 @@ export interface UserOut {
settings: Record<string, unknown>; settings: Record<string, unknown>;
totp_enabled: boolean; totp_enabled: boolean;
is_admin: boolean; is_admin: boolean;
/** The hosted mail server's name for mail apps; null without hosted mailboxes. */
mail_host?: string | null;
} }
export interface AdminUser { export interface AdminUser {
@@ -126,6 +128,8 @@ export interface AccountOut {
sync_error?: string | null; sync_error?: string | null;
sync_error_since?: string | null; sync_error_since?: string | null;
state_detail?: string; state_detail?: string;
/** "native": the user's own hosted mailbox — permanent, its settings fixed. */
kind?: "external" | "native";
} }
/** A send the server could not take: waiting in Scheduled (retrying, or failed). */ /** A send the server could not take: waiting in Scheduled (retrying, or failed). */
+5 -2
View File
@@ -1054,10 +1054,13 @@ export function MailLayout() {
{/* Per-account views are hidden entirely when collapsed. */} {/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && ( {!collapsed && (
<> <>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint"> <div className="px-3 pb-1 pt-6 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")} {t("settings.accounts")}
</div> </div>
{accounts.map((a) => ( {/* Alphabetical by the address shown. */}
{[...accounts]
.sort((x, y) => x.email.localeCompare(y.email, undefined, { sensitivity: "base" }))
.map((a) => (
<SidebarItem <SidebarItem
key={a.id} key={a.id}
active={view === `account:${a.id}`} active={view === `account:${a.id}`}
+50 -16
View File
@@ -516,7 +516,9 @@ function OfflineAccessSetting() {
*/ */
function AppPasswordsSetting() { function AppPasswordsSetting() {
const t = useT(); const t = useT();
const handle = useMe().data?.handle ?? ""; const me = useMe().data;
const handle = me?.handle ?? "";
const mailHost = me?.mail_host ?? null;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords }); const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -558,7 +560,28 @@ function AppPasswordsSetting() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2> <h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
<p className="text-sm text-app-muted">{t("appPasswords.help")}</p> <p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p>
{/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */}
{mailHost && (
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
<p className="font-medium">{t("appPasswords.mailTitle")}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-app-muted">{t("appPasswords.mailIncoming")}</dt>
<dd className="break-all font-mono text-[13px]">
{t("appPasswords.mailPorts", { host: mailHost, port: 993 })}
</dd>
<dt className="text-app-muted">{t("appPasswords.mailOutgoing")}</dt>
<dd className="break-all font-mono text-[13px]">
{t("appPasswords.mailPorts", { host: mailHost, port: 465 })}
<span className="block text-app-muted">{t("appPasswords.mailPortsAlt", { port: 587 })}</span>
</dd>
<dt className="text-app-muted">{t("appPasswords.connectUser")}</dt>
<dd className="select-all break-all font-mono text-[13px]">{handle}</dd>
<dt className="text-app-muted">{t("appPasswords.connectPassword")}</dt>
<dd className="text-app-muted">{t("appPasswords.connectPasswordValue")}</dd>
</dl>
</div>
)}
{/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */} {/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm"> <div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
<p className="font-medium">{t("appPasswords.connectTitle")}</p> <p className="font-medium">{t("appPasswords.connectTitle")}</p>
@@ -578,10 +601,10 @@ function AppPasswordsSetting() {
<p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p> <p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Shown in groups of four; copied (and entered) without spaces. */} {/* Shown in groups of four; copied (and entered) without spaces. */}
<code className="select-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider"> <code className="select-all break-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
{made.secret.match(/.{1,4}/g)?.map((group, i) => ( {made.secret.match(/.{1,4}/g)?.map((group, i) => (
// Spacing only, no characters: copying by hand gives the bare letters. // Spacing only, no characters: copying by hand gives the bare letters.
<span key={i} className={i ? "ml-2.5" : ""}> <span key={i} className={`inline-block ${i ? "ml-2.5" : ""}`}>
{group} {group}
</span> </span>
))} ))}
@@ -888,6 +911,14 @@ function AccountsSection() {
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5"> <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} /> <span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
<span className="min-w-0 break-all">{a.email}</span> <span className="min-w-0 break-all">{a.email}</span>
{a.kind === "native" && (
<span
className="rounded bg-app-accent/15 px-1.5 py-0.5 text-[11px] font-medium text-app-accent"
title={t("settings.yourMailboxHint")}
>
{t("settings.yourMailbox")}
</span>
)}
{a.state === "auth_failed" ? ( {a.state === "auth_failed" ? (
<span className="text-xs text-red-500" title={a.state_detail || undefined}> <span className="text-xs text-red-500" title={a.state_detail || undefined}>
{t("settings.stateAuthFailed")} {t("settings.stateAuthFailed")}
@@ -932,17 +963,20 @@ function AccountsSection() {
> >
{t("settings.configureAccount")} {t("settings.configureAccount")}
</button> </button>
<button {/* The user's own mailbox is permanent (SPEC App. D §D.4). */}
className="text-xs text-red-500" {a.kind !== "native" && (
onClick={() => { <button
if (confirm(t("settings.confirmRemoveAccount"))) { className="text-xs text-red-500"
setDeletingId(a.id); onClick={() => {
remove.mutate(a.id); if (confirm(t("settings.confirmRemoveAccount"))) {
} setDeletingId(a.id);
}} remove.mutate(a.id);
> }
{t("common.remove")} }}
</button> >
{t("common.remove")}
</button>
)}
</span> </span>
</div> </div>
)} )}
@@ -1050,7 +1084,7 @@ function AccountEditor({
/> />
</div> </div>
{!showServer ? ( {detail.data.kind === "native" ? null : !showServer ? (
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}> <button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
{t("settings.serverSettings")} {t("settings.serverSettings")}
</button> </button>
+4 -1
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { ThreadView } from "../components/thread/ThreadView"; import { ThreadView } from "../components/thread/ThreadView";
import { I18nProvider } from "../i18n"; import { I18nProvider } from "../i18n";
import { ThemeProvider } from "../lib/theme";
import type { MessageEnvelope } from "../lib/types"; import type { MessageEnvelope } from "../lib/types";
function msg(id: string, minutes: number, unread = false): MessageEnvelope { function msg(id: string, minutes: number, unread = false): MessageEnvelope {
@@ -38,7 +39,9 @@ function wrap(children: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ( return (
<I18nProvider> <I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider> <ThemeProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</ThemeProvider>
</I18nProvider> </I18nProvider>
); );
} }
+16
View File
@@ -12,3 +12,19 @@ if (typeof globalThis.Worker === "undefined") {
} }
(globalThis as unknown as { Worker: unknown }).Worker = TestWorker; (globalThis as unknown as { Worker: unknown }).Worker = TestWorker;
} }
// jsdom has no matchMedia; the theme (lib/theme.tsx) asks it for the system's
// light/dark preference. Answers "no match" (a light system).
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
@@ -0,0 +1,41 @@
"""antispam: which accounts are filtered, said explicitly
``enabled_accounts`` was "empty = every account", so turning every account off
could not be stored: it read back as all on. Now NULL is every account (the
default, including accounts added later), a list is exactly those accounts, and
an empty list is none. Existing empty lists were "every account": they become
NULL.
Revision ID: antispam0002
Revises: antispam0001
Create Date: 2026-10-11
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "antispam0002"
down_revision: str | None = "antispam0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP NOT NULL")
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP DEFAULT")
op.execute(
"UPDATE plg_antispam_settings SET enabled_accounts = NULL WHERE enabled_accounts = '{}'"
)
def downgrade() -> None:
op.execute(
"UPDATE plg_antispam_settings SET enabled_accounts = '{}' WHERE enabled_accounts IS NULL"
)
op.execute(
"ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET DEFAULT '{}'"
)
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET NOT NULL")
@@ -18,7 +18,7 @@ _SDK = sdk("antispam")
# §C.3 defaults, used when a user has never opened the settings page. # §C.3 defaults, used when a user has never opened the settings page.
DEFAULTS = { DEFAULTS = {
"enabled_accounts": [], # empty = every account "enabled_accounts": None, # every account
"move_threshold": 12.0, "move_threshold": 12.0,
"banner_threshold": 6.0, "banner_threshold": 6.0,
"network_checks": False, "network_checks": False,
@@ -28,15 +28,16 @@ DEFAULTS = {
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
enabled_accounts: list[str] # None: every account (also ones added later); a list: exactly those —
# empty, none.
enabled_accounts: list[str] | None
move_threshold: float move_threshold: float
banner_threshold: float banner_threshold: float
network_checks: bool network_checks: bool
learn_from_provider_junk: bool learn_from_provider_junk: bool
def covers(self, account_id: str) -> bool: def covers(self, account_id: str) -> bool:
"""Empty list means every account — the common case stays cheap.""" return self.enabled_accounts is None or account_id in self.enabled_accounts
return not self.enabled_accounts or account_id in self.enabled_accounts
async def load_settings(user_id: uuid.UUID) -> Settings: async def load_settings(user_id: uuid.UUID) -> Settings:
@@ -54,14 +55,14 @@ async def load_settings(user_id: uuid.UUID) -> Settings:
if row is None: if row is None:
d: Any = DEFAULTS d: Any = DEFAULTS
return Settings( return Settings(
enabled_accounts=[], enabled_accounts=None,
move_threshold=d["move_threshold"], move_threshold=d["move_threshold"],
banner_threshold=d["banner_threshold"], banner_threshold=d["banner_threshold"],
network_checks=d["network_checks"], network_checks=d["network_checks"],
learn_from_provider_junk=d["learn_from_provider_junk"], learn_from_provider_junk=d["learn_from_provider_junk"],
) )
return Settings( return Settings(
enabled_accounts=[str(a) for a in (row[0] or [])], enabled_accounts=None if row[0] is None else [str(a) for a in row[0]],
move_threshold=float(row[1]), move_threshold=float(row[1]),
banner_threshold=float(row[2]), banner_threshold=float(row[2]),
network_checks=bool(row[3]), network_checks=bool(row[3]),
@@ -56,9 +56,11 @@ async def get_settings(user_id: CurrentUserId) -> SettingsOut:
) )
async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut: async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut:
current = await policy.load_settings(user_id) current = await policy.load_settings(user_id)
merged = { merged: dict[str, Any] = {
"enabled_accounts": ( "enabled_accounts": (
current.enabled_accounts None
if body.all_accounts
else current.enabled_accounts
if body.enabled_accounts is None if body.enabled_accounts is None
else body.enabled_accounts else body.enabled_accounts
), ),
@@ -82,13 +84,15 @@ async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOu
# asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop # asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop
# anything that is not a uuid rather than 500 on a malformed account id. # anything that is not a uuid rather than 500 on a malformed account id.
account_ids: list[uuid.UUID] = [] account_ids: list[uuid.UUID] | None = None
for raw in merged["enabled_accounts"]: if merged["enabled_accounts"] is not None:
try: account_ids = []
account_ids.append(uuid.UUID(str(raw))) for raw in merged["enabled_accounts"]:
except (ValueError, AttributeError, TypeError): try:
continue account_ids.append(uuid.UUID(str(raw)))
merged["enabled_accounts"] = [str(a) for a in account_ids] except (ValueError, AttributeError, TypeError):
continue
merged["enabled_accounts"] = [str(a) for a in account_ids]
async with _SDK.session(user_id) as s: async with _SDK.session(user_id) as s:
await s.execute( await s.execute(
@@ -308,4 +312,8 @@ async def bundle(filename: str) -> FileResponse:
raise HTTPException( raise HTTPException(
status_code=404, detail={"code": "not_found", "message": "no bundle"} status_code=404, detail={"code": "not_found", "message": "no bundle"}
) )
return FileResponse(_UI / filename, media_type="text/javascript") # `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
# updated bundle is used at once instead of a copy the browser kept.
return FileResponse(
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
)
@@ -8,7 +8,8 @@ from pydantic import BaseModel, Field
class SettingsOut(BaseModel): class SettingsOut(BaseModel):
enabled_accounts: list[str] # None: every account (also ones added later); a list: exactly those.
enabled_accounts: list[str] | None
move_threshold: float move_threshold: float
banner_threshold: float banner_threshold: float
network_checks: bool network_checks: bool
@@ -19,6 +20,8 @@ class SettingsIn(BaseModel):
"""Merge semantics: omitted fields keep their current value.""" """Merge semantics: omitted fields keep their current value."""
enabled_accounts: list[str] | None = None enabled_accounts: list[str] | None = None
# True: every account again (enabled_accounts is then ignored).
all_accounts: bool | None = None
# Bounds are guard rails, not opinions: 0 would file everything as spam and # Bounds are guard rails, not opinions: 0 would file everything as spam and
# the engine's own scale tops out well below 100. # the engine's own scale tops out well below 100.
move_threshold: float | None = Field(default=None, gt=0, le=100) move_threshold: float | None = Field(default=None, gt=0, le=100)
@@ -1,5 +1,6 @@
// antispam · settings.page slot (SPEC §C.6). Thresholds in plain language, // antispam · settings.page slot (SPEC §C.6). Thresholds in plain language,
// per-account toggles, the allow/deny lists, the privacy switch, and stats. // per-account toggles, the allow/deny lists, the privacy switch. (The engine's
// statistics stay available at GET /stats; the page no longer shows them.)
function esc(s) { function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]); return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
@@ -9,20 +10,17 @@ export default function mount(el, ctx) {
let settings = null; let settings = null;
let rules = []; let rules = [];
let accounts = []; let accounts = [];
let stats = null;
let bootstrapNote = ""; // outcome of the last "learn from my Spam folder now" click let bootstrapNote = ""; // outcome of the last "learn from my Spam folder now" click
let disposed = false; let disposed = false;
async function load() { async function load() {
const [s, r, st] = await Promise.all([ const [s, r] = await Promise.all([
ctx.api.pluginFetch("/settings").catch(() => null), ctx.api.pluginFetch("/settings").catch(() => null),
ctx.api.pluginFetch("/rules").catch(() => []), ctx.api.pluginFetch("/rules").catch(() => []),
ctx.api.pluginFetch("/stats").catch(() => null),
]); ]);
accounts = await ctx.api.fetch("/accounts").catch(() => []); accounts = await ctx.api.fetch("/accounts").catch(() => []);
settings = s; settings = s;
rules = r || []; rules = r || [];
stats = st;
if (!disposed) render(); if (!disposed) render();
} }
@@ -36,9 +34,10 @@ export default function mount(el, ctx) {
function accountRows() { function accountRows() {
if (!accounts.length) return ""; if (!accounts.length) return "";
// null: every account (the default, so an untouched install shows all
// ticked); a list: exactly those — empty, none.
const all = settings.enabled_accounts === null || settings.enabled_accounts === undefined;
const on = new Set(settings.enabled_accounts || []); const on = new Set(settings.enabled_accounts || []);
// Empty list means every account, so an untouched install shows all ticked.
const all = on.size === 0;
return accounts return accounts
.map( .map(
(a) => (a) =>
@@ -58,20 +57,6 @@ export default function mount(el, ctx) {
.join(""); .join("");
} }
function statsBlock() {
if (!stats) return `<p class="wm-faint">Stats unavailable — is the engine running?</p>`;
const c = stats.counts || {};
const rowsOf = Object.entries(c)
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`)
.join("");
const syms = (stats.top_symbols || [])
.map((s) => `<tr><td>${esc(s.symbol)}</td><td>${s.count}</td></tr>`)
.join("");
return `
<table class="wm-table"><tbody>${rowsOf || '<tr><td class="wm-faint">nothing classified yet</td><td></td></tr>'}</tbody></table>
${syms ? `<p class="wm-muted">Most common reasons</p><table class="wm-table"><tbody>${syms}</tbody></table>` : ""}`;
}
function render() { function render() {
if (!settings) { if (!settings) {
el.innerHTML = `<p class="wm-faint">Loading…</p>`; el.innerHTML = `<p class="wm-faint">Loading…</p>`;
@@ -83,18 +68,18 @@ export default function mount(el, ctx) {
<p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.</p> <p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.</p>
<div class="wm-card"> <div class="wm-card">
<label class="wm-row">Move to Spam at score <div style="display:grid;grid-template-columns:max-content 7rem;gap:10px 12px;align-items:center;margin-bottom:10px">
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}"> <label for="as-move">Move to Spam at score</label>
</label> <input id="as-move" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
<label class="wm-row">Warn with a banner at score <label for="as-banner">Warn with a banner at score</label>
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}"> <input id="as-banner" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
</label> </div>
<p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p> <p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p>
</div> </div>
<div class="wm-card"> <div class="wm-card">
<strong>Accounts</strong> <strong>Accounts</strong>
<p class="wm-faint">Tick none to filter every account.</p> <p class="wm-faint">Only the ticked accounts are filtered. While all are ticked, accounts you add later are filtered too.</p>
${accountRows()} ${accountRows()}
</div> </div>
@@ -115,12 +100,10 @@ export default function mount(el, ctx) {
<div class="wm-card"> <div class="wm-card">
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label> <label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
<p class="wm-faint">Off by default. When on, the filter asks <strong>Spamhaus</strong> whether the sending server or a linked domain is known for spam, and <strong>DNSWL</strong> whether the sender is known-good. That reveals those addresses and domains to them — and to whichever DNS resolver you use. The message itself is never sent, and no fingerprint of it leaves your server.</p> <p class="wm-faint">Off by default. When on, the filter asks <strong>Spamhaus</strong> whether the sending server or a linked domain is known for spam, and <strong>DNSWL</strong> whether the sender is known-good. That reveals those addresses and domains to them — and to whichever DNS resolver you use. The message itself is never sent, and no fingerprint of it leaves your server.</p>
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label> <label class="wm-row" style="margin-top:16px"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
<div class="wm-row"><button class="wm-btn" data-bootstrap${settings.learn_from_provider_junk ? "" : " disabled"}>Learn from my Spam folder now</button><span class="wm-faint" data-bootstrap-note>${esc(bootstrapNote)}</span></div> <div class="wm-row"><button class="wm-btn" data-bootstrap${settings.learn_from_provider_junk ? "" : " disabled"}>Learn from my Spam folder now</button><span class="wm-faint" data-bootstrap-note>${esc(bootstrapNote)}</span></div>
<p class="wm-faint">Otherwise this runs on its own once an hour, for accounts with at least 10 messages in Spam. Running it again is harmless: messages the filter already learned are skipped.</p> <p class="wm-faint">Otherwise this runs on its own once an hour, for accounts with at least 10 messages in Spam. Running it again is harmless: messages the filter already learned are skipped.</p>
</div> </div>`;
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
el.querySelectorAll('input[type="number"][data-f]').forEach((i) => el.querySelectorAll('input[type="number"][data-f]').forEach((i) =>
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })), i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })),
@@ -131,9 +114,13 @@ export default function mount(el, ctx) {
el.querySelectorAll("[data-acc]").forEach((i) => el.querySelectorAll("[data-acc]").forEach((i) =>
i.addEventListener("change", () => { i.addEventListener("change", () => {
const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked); const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked);
// All ticked is the same as "every account": store it as empty. // All ticked is "every account" (accounts added later too); none ticked
const ids = ticked.length === accounts.length ? [] : ticked.map((x) => x.getAttribute("data-acc")); // is none.
patch({ enabled_accounts: ids }); patch(
ticked.length === accounts.length
? { all_accounts: true }
: { enabled_accounts: ticked.map((x) => x.getAttribute("data-acc")) },
);
}), }),
); );
const boot = el.querySelector("[data-bootstrap]"); const boot = el.querySelector("[data-bootstrap]");
@@ -48,4 +48,8 @@ async def set_rules(body: RulesIn, user_id: CurrentUserId) -> dict[str, object]:
async def bundle(filename: str) -> FileResponse: async def bundle(filename: str) -> FileResponse:
if filename != "settings.js": if filename != "settings.js":
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript") # `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
# updated bundle is used at once instead of a copy the browser kept.
return FileResponse(
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
)
@@ -52,4 +52,8 @@ async def add_note(body: NoteIn, user_id: CurrentUserId) -> dict[str, str]:
@router.get("/ui/page.js") @router.get("/ui/page.js")
async def page_bundle() -> FileResponse: async def page_bundle() -> FileResponse:
"""Serve the page's ESM bundle for the frontend loader (SPEC §11.3).""" """Serve the page's ESM bundle for the frontend loader (SPEC §11.3)."""
return FileResponse(_UI / "page.js", media_type="text/javascript") # `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
# updated bundle is used at once instead of a copy the browser kept.
return FileResponse(
_UI / "page.js", media_type="text/javascript", headers={"Cache-Control": "no-cache"}
)
@@ -179,4 +179,8 @@ async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
async def bundle(filename: str) -> FileResponse: async def bundle(filename: str) -> FileResponse:
if filename not in _BUNDLES: if filename not in _BUNDLES:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript") # `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
# updated bundle is used at once instead of a copy the browser kept.
return FileResponse(
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
)
@@ -14,7 +14,7 @@ function injectStyles() {
s.id = "lb-styles"; s.id = "lb-styles";
s.textContent = ` s.textContent = `
.lb-sec{position:relative;padding:4px 0 0} .lb-sec{position:relative;padding:4px 0 0}
.lb-head{display:flex;align-items:center;gap:5px;padding:10px 8px 2px 12px} .lb-head{display:flex;align-items:center;gap:5px;padding:24px 8px 2px 12px}
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em} .lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1} .lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} .lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
+42 -31
View File
@@ -832,7 +832,7 @@ At the hosted-mail phase the VPS edge (Stalwart's built-in filter or Postfix+Rsp
## Appendix D — Hosted mailboxes (M6, Stalwart) ## Appendix D — Hosted mailboxes (M6, Stalwart)
Every webmail user gets a real mailbox on a **Stalwart** server running beside the app. The address is the user's handle (`localpart@APP_DOMAIN`, already mail-grade per §4), and the password is the webmail password. That mailbox appears in webmail as an account the user cannot remove. Because Stalwart holds the mail, webmail stops keeping its own full copy of it (§D.6). Every webmail user gets a real mailbox on a **Stalwart** server running beside the app. The address is the user's handle (`localpart@APP_DOMAIN`, already mail-grade per §4); mail apps sign in to it with **app passwords only** (§D.5) — the webmail password never opens it. That mailbox appears in webmail as an account the user cannot remove. Because Stalwart holds the mail, webmail stops keeping its own full copy of it (§D.6).
### D.1 Hosting (decide first — this, not code, is the hard part) ### D.1 Hosting (decide first — this, not code, is the hard part)
Receiving needs inbound port 25, an MX record and a fixed address; many home ISPs block port 25. Sending from a home IP is commonly spam-foldered or refused, and needs reverse DNS (PTR), SPF, DKIM and DMARC. Receiving needs inbound port 25, an MX record and a fixed address; many home ISPs block port 25. Sending from a home IP is commonly spam-foldered or refused, and needs reverse DNS (PTR), SPF, DKIM and DMARC.
@@ -846,46 +846,57 @@ Receiving needs inbound port 25, an MX record and a fixed address; many home ISP
**Chosen (2026-10-10): option A** — everything on the QNAP; see `docs/mail-server-network.md` for the network, DNS and port plan and the connection checks. **Chosen (2026-10-10): option A** — everything on the QNAP; see `docs/mail-server-network.md` for the network, DNS and port plan and the connection checks.
### D.2 Infrastructure ### D.2 Infrastructure
- `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version. - `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version: **`stalwartlabs/stalwart:v0.16.19`**.
- **Stores:** data store = a separate `stalwart` database on the existing Postgres; blob store = filesystem under `DATA_ROOT/stalwart` (bind mount, not a named volume). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately. - **Stores:** data store = a separate `stalwart` database on the existing Postgres, owned by its own role `stalwart` (`STALWART_DB_PASSWORD`); blob store = filesystem (`/var/lib/stalwart/blobs`, bind mount under `DATA_ROOT/stalwart` in production). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately.
- **TLS:** Stalwart's built-in ACME with DNS-01 (nginx owns 80/443), or the existing certificates mounted read-only. - **Configuration (built, D2):** in 0.16 `config.json` holds only the data store (`docker/stalwart/config.json`, committed: its password is read from the environment, `{"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}`); everything else lives in that database and is managed through the JMAP management API (`x:Domain`, `x:Directory`, `x:Role`, …). `app/hosted/provision.py` (`python -m app.hosted.provision`, idempotent) sets it all up: the role and database (the server restart-loops until they exist), the domain and host name, logging to the console, mail on disk (switching the blob store takes a **restart** — the script says so), submission on 587 beside smtp 25 / submissions 465 / imaps 993, plain folder names for new mailboxes (Trash, Junk, Sent, Drafts, Archive), the SQL directory, the worker's role and sign-in, and the native account of every existing user. The management API is signed in to as the recovery admin (`STALWART_RECOVERY_ADMIN=admin:$STALWART_ADMIN_SECRET`).
- **Stalwart 0.16.19 quirks:** three built-in SURBL spam-list rules fail its own validator (`bit_and`), which makes every *ReloadSettings* fail; they never load anyway, so provisioning removes them. A blocked account's IMAP sign-in is not answered — the connection is dropped after a minute.
- **TLS:** Stalwart's built-in ACME with DNS-01 (nginx owns 80/443), or the existing certificates mounted read-only. The webmail itself reaches IMAP/SMTP over the Docker network with TLS but no certificate check.
- **Spam:** SMTP-time filtering at the edge (Stalwart's filter or Rspamd) tags an edge-score header; the anti-spam plugin trusts it for hosted accounts (§C.7). The existing Rspamd keeps per-user learning. - **Spam:** SMTP-time filtering at the edge (Stalwart's filter or Rspamd) tags an edge-score header; the anti-spam plugin trusts it for hosted accounts (§C.7). The existing Rspamd keeps per-user learning.
### D.3 Same username and password — no password copying ### D.3 Same username, no password copying
Stalwart uses a **SQL directory** reading a read-only view over `users` (dedicated read-only role): Stalwart uses a **SQL directory** reading the view `mail_directory` (migration 0025), which the `stalwart` role may SELECT and nothing else in the app's database:
```sql ```sql
CREATE VIEW mail_principals AS CREATE VIEW mail_directory AS
SELECT handle AS name, handle AS email, password_hash AS secret, SELECT handle AS name,
quota_bytes AS quota, (blocked_at IS NOT NULL) AS disabled NULL::text AS secret, -- users: app passwords only (§D.5)
FROM users; 'individual' AS type, handle AS description,
blocked_at IS NULL AS active
FROM users
UNION ALL
SELECT name, secret, 'individual', description, true FROM mail_service_accounts;
``` ```
- Stalwart verifies argon2id PHC hashes, which is what `identity/passwords.py` produces, so registration, password change, reset, block and quota apply **instantly** with no provisioning or sync step. Sign-in query: `… WHERE name = $1 AND active`; recipient query: `… WHERE name = $1` (a blocked user still receives mail).
- User deletion additionally purges the mailbox through Stalwart's management API.
- Webmail never stores the user's password: the worker signs in as a **master user** (`handle%master`, secret in `.env` as `STALWART_MASTER_SECRET`), not via `accounts.secret_ct`. *Verify the master-user feature and syntax in the pinned version during D2.*
- Reserved handles (`postmaster`, `abuse`, …) stay unregistrable; route them to the administrator.
### D.4 The permanent account in webmail - **Decided (2026-10-11): the webmail password never opens the mailbox** — not even with 2FA off. A mail app's password is stored on a phone or laptop and sent on every connection; an app password limits a leak to the mailbox (not the webmail account) and can be revoked on its own. The view still lists every user (for delivery and for the worker's impersonation), with no secret.
- Migration: `accounts.kind text NOT NULL DEFAULT 'external'` (`external` | `native`). The native account is created in `register_user`; a one-off command creates it for users that already exist. - **Built (D2):** the SQL directory returns **one secret per account** (Stalwart reads the first row only), so app passwords cannot be extra rows of this view — see §D.5.
- `DELETE /api/accounts/{id}` on a native account → `409 {error:{code:"native_account"}}`. UI hides Remove and the server-settings fields; signature, colour and display name stay editable. - **Block:** `user_admin.set_blocked` also takes away the mailbox account's *authenticate* permission on the mail server (app passwords and the worker included); unblocking restores it. The mail server goes first: if it cannot be told, nothing changes (502).
- `provider_factory`: native accounts connect to `stalwart:993` / `stalwart:465` on the Docker network with the master credentials. - **User deletion** first deletes the mailbox and its mail through the management API — if that fails nothing is deleted, since a mailbox left behind would be handed to whoever registers the address next.
- **Sync:** first reuse the IMAP provider unchanged (IDLE + QRESYNC already work). Optional later: a JMAP provider with push (Stalwart's native protocol). - **The worker's sign-in (built):** Stalwart's master-user login `user@domain%webmail-worker@domain`. The worker account is a row of `mail_service_accounts` (argon2id of `STALWART_MASTER_SECRET`); on the mail server its only role is *impersonate* + *authenticate*, so it cannot open a session as itself. Webmail stores no user's password (`secret_ct` holds nothing for native accounts). Accounts never signed in to are brought into Stalwart's registry by that first impersonated sign-in.
- Sending goes through Stalwart; Sent copy, retries and the Scheduled folder behave as for any account. - Reserved handles (`postmaster`, `abuse`, …) stay unregistrable; route them to the administrator. `webmail-worker` is taken by the service account.
### D.4 The permanent account in webmail — built (D3)
- Migration 0025: `accounts.kind text NOT NULL DEFAULT 'external'` (`external` | `native`), at most one native per user. `app/hosted/native.py`: the native account is created at registration, by provisioning for users that already exist, and when the user removes an account for their own address (such an account — the same address at its old provider, e.g. home.pl during the move — is left alone: no native account is added while it exists).
- `DELETE /api/accounts/{id}` on a native account → `409 native_account`; `PATCH` with server settings or a password → `400 native_account`. Lists carry `kind`; the UI shows a "Your mailbox" badge and hides Remove and the server settings; signature, colour and display name stay editable.
- `provider_factory`: native accounts connect to `STALWART_IMAP_HOST:993` / `STALWART_SMTP_HOST:465` (from the settings, not the row) as the worker acting for the user, outside the per-host connection cap. The account syncs Junk (the mail server's own filter files there).
- **Sync:** the IMAP provider unchanged (IDLE, CONDSTORE, QRESYNC, BINARY, MOVE, UIDPLUS all advertised). Optional later: a JMAP provider with push. Found on the way: UIDVALIDITY values above 2^31 did not fit the `integer` columns — migration 0026 makes UIDVALIDITY, MODSEQ and UIDs `bigint`.
- Sending goes through Stalwart, which refuses a From address the user does not own; Sent copy, retries and the Scheduled folder behave as for any account.
### D.5 External mail clients and 2FA ### D.5 External mail clients and 2FA
If phones/desktop clients may use the mailbox over IMAP/SMTP, a password alone would bypass TOTP. Rule: users with 2FA enabled must use **app passwords** for IMAP/SMTP — a `user_app_passwords` table (argon2id hashes) added to the directory view, plus a Settings screen to create/revoke them. Stalwart's login rate limits / auto-ban on. If external clients are not wanted, IMAP/SMTP submission is exposed only on the Docker network and this section shrinks to nothing. Mail apps (IMAP/SMTP) sign in with **app passwords only**, whether 2FA is on or not: the webmail password would bypass TOTP, and is better never typed into a mail app. The directory view returns no password hash for users (verified: the password fails, app passwords and the worker work). Stalwart's login rate limits / auto-ban on.
**App passwords — built (v1.3.37, ahead of D5):** table `user_app_passwords` (migration 0024: `id, user_id, name, password_hash, created_at, last_used_at`), `app/identity/app_passwords.py`, routes `GET/POST /api/auth/app-passwords`, `DELETE /api/auth/app-passwords/{id}`, Settings → Security → App passwords. **App passwords — built (v1.3.37, ahead of D5):** table `user_app_passwords` (migration 0024: `id, user_id, name, password_hash, created_at, last_used_at`), `app/identity/app_passwords.py`, routes `GET/POST /api/auth/app-passwords`, `DELETE /api/auth/app-passwords/{id}`, Settings → Security → App passwords.
- 16 random lowercase letters (~75 bits), shown once in groups of four, copied and entered **without spaces**: the hash is of the bare letters, because the mail server compares exactly what an app sends. - Without hosted mailboxes: 16 random lowercase letters (~75 bits), for the DAV server only.
- Creating one takes the account password; at most 25 per user; each is revoked on its own; a password reset revokes them all (as Google does). The webmail's own sign-in never accepts one. - **With hosted mailboxes (built, D2):** the mail server makes the password — Stalwart's own app-password credential, `app_` + 36 base32 characters naming the credential and 144 random bits — and checks it for IMAP/SMTP itself; its argon2id hash is kept in `user_app_passwords` too (for DAV), with the server's id in `server_id` (migration 0025) to revoke it there. One password works for mail and DAV. Revoking goes to the mail server first (502 `mail_server_unavailable` if it does not answer).
- Still for D2/D5: add the hashes to the directory's secrets query (a user's secrets = `users.password_hash` unless 2FA is on, plus every `user_app_passwords.password_hash`), and set `last_used_at` from the mail server's successful sign-ins. - Shown once in groups of four, copied and entered **without spaces**. Creating one takes the account password; at most 25 per user; each is revoked on its own; a password reset revokes them all (as Google does). The webmail's own sign-in never accepts one. Settings → Security shows the mail-app settings (`mail_host` on `/api/auth/me`: IMAP 993 SSL, SMTP 465 SSL or 587 STARTTLS, user name = the address).
- Still for D5: `last_used_at` (Stalwart's app-password credential records no last use).
### D.6 Storing mail once ### D.6 Storing mail once
Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first: Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first:
1. **Attachments are stored twice inside webmail today.** `indexer.py` stores each attachment as its own blob *and* it is inside the raw blob (base64), ≈2.3× its size; attachments are most of mail's bytes. Fix: keep only the raw message, extract an attachment when downloaded, keep thumbnails cached. Cuts attachment storage by about 40% (≈1.37× instead of ≈2.37× the file size) for **all** accounts, independent of Stalwart. A background pass frees existing duplicates. **Done (v1.3.34):** `app/indexer/attachments.py`, migration 0023. 1. **Attachments are stored twice inside webmail today.** `indexer.py` stores each attachment as its own blob *and* it is inside the raw blob (base64), ≈2.3× its size; attachments are most of mail's bytes. Fix: keep only the raw message, extract an attachment when downloaded, keep thumbnails cached. Cuts attachment storage by about 40% (≈1.37× instead of ≈2.37× the file size) for **all** accounts, independent of Stalwart. A background pass frees existing duplicates. **Done (v1.3.34):** `app/indexer/attachments.py`, migration 0023.
2. **Native accounts: no raw copy in webmail.** Stalwart is the master copy; webmail keeps metadata, sanitized body, snippet and search index (~5–10% of the size). Full message, attachments and "show original" are fetched on demand over the local network (IMAP `BODY[part]` or JMAP blob download), with a small cache for repeat opens and thumbnails. 2. **Native accounts: no raw copy in webmail.** Stalwart is the master copy; webmail keeps metadata, sanitized body, snippet and search index (~5–10% of the size). Full message, attachments and "show original" are fetched on demand over the local network (IMAP `BODY[part]` or JMAP blob download), with a small cache for repeat opens and thumbnails. **Built (D4):** `app/indexer/rawstore.py` — a native message's raw source goes into `{BLOB_ROOT}/{user}/rawcache/{sha256}` instead of the blob store, bounded per user (`RAW_CACHE_MB`, default 256; least recently used out first, anything unused for 30 days; pruned every 50 writes and by the nightly clean-up). Readers find it with `rawstore.locate` (blob store, then cache); request handlers call `rawstore.ensure(message)` first, which fetches the whole message (`UID FETCH BODY.PEEK[]`, ~0.3 s on the same host) into the cache when missing. Covered: attachments (download, zip, thumbnails, inline images, forward, reopening a draft), re-sanitizing the HTML, the plugins' header/raw access. The nightly clean-up no longer keeps a blob-store file only native messages refer to. Thumbnails and taken-out attachments keep their own caches, as for every account.
3. **External accounts (optional setting): age out old raw copies.** Keep raw blobs for N days or the newest X GB; refetch older ones over IMAP when needed. Readable bodies stay local; old attachments then need the external server reachable. 3. **External accounts (optional setting): age out old raw copies.** Keep raw blobs for N days or the newest X GB; refetch older ones over IMAP when needed. Readable bodies stay local; old attachments then need the external server reachable.
4. **Filesystem:** at least lz4 compression on the ZFS data share (QuTS hero). ZFS dedup would merge identical copies but costs a lot of RAM and only hits uncompressed byte-identical data — last resort. 4. **Filesystem:** at least lz4 compression on the ZFS data share (QuTS hero). ZFS dedup would merge identical copies but costs a lot of RAM and only hits uncompressed byte-identical data — last resort.
5. **Rejected — MTA-only Stalwart** delivering straight into webmail with no IMAP mailbox: zero duplication, but phones/Thunderbird lose access unless webmail implements IMAP. Items 1 + 2 give nearly the same saving. 5. **Rejected — MTA-only Stalwart** delivering straight into webmail with no IMAP mailbox: zero duplication, but phones/Thunderbird lose access unless webmail implements IMAP. Items 1 + 2 give nearly the same saving.
@@ -895,18 +906,18 @@ Today the indexer keeps every message in full (raw `.eml` blob + sanitized body
### D.7 Phases ### D.7 Phases
- **D0 — Decisions:** hosting option (§D.1), domain, whether external IMAP clients are allowed (§D.5). - **D0 — Decisions:** hosting option (§D.1), domain, whether external IMAP clients are allowed (§D.5).
- **D1 — Attachment de-duplication** (§D.6 item 1): independent, can ship any time. - **D1 — Attachment de-duplication** (§D.6 item 1): independent, can ship any time.
- **D2 — Spike (dev):** Stalwart container, Postgres stores, SQL-directory view, master-user login, IMAP sync of a test user. - **D2 — Spike (dev):** Stalwart container, Postgres stores, SQL-directory view, master-user login, IMAP sync of a test user. **Done (2026-10-11)** — dev compose service `stalwart`, `app/hosted/`.
- **D3 — Native account:** `accounts.kind`, creation at registration + for existing users, undeletable in API and UI, provider wiring, delete/block hooks; tests against a Stalwart container (may replace greenmail). - **D3 — Native account:** `accounts.kind`, creation at registration + for existing users, undeletable in API and UI, provider wiring, delete/block hooks; tests against a Stalwart container (`tests/hosted/`, skipped when it is not running). **Done.**
- **D4 — On-demand bodies for native accounts** (§D.6 item 2). - **D4 — On-demand bodies for native accounts** (§D.6 item 2). **Done.**
- **D5 — Security:** app passwords + 2FA rule, rate limits. - **D5 — Security:** app passwords only for mail apps (**done**), rate limits, `last_used_at`.
- **D6 — Production:** compose, DNS (MX/SPF/DKIM/DMARC/PTR), relay or VPS per §D.1, backup, `docs/nas-install.md` runbook, release. - **D6 — Production:** compose, DNS (MX/SPF/DKIM/DMARC/PTR), relay or VPS per §D.1, backup, `docs/nas-install.md` runbook, release.
### D.8 Configuration additions (§14) ### D.8 Configuration additions (§14)
`STALWART_URL` (management API), `STALWART_IMAP_HOST` / `STALWART_SMTP_HOST`, `STALWART_MASTER_SECRET`, `STALWART_ADMIN_SECRET`, `HOSTED_MAILBOXES` (on/off; off = M6 inert, byte-identical to pre-M6 behaviour). `HOSTED_MAILBOXES` (on/off; off = M6 inert, byte-identical to pre-M6 behaviour), `STALWART_URL` (management API, default `http://stalwart:8080`), `STALWART_ADMIN_SECRET` (its recovery admin), `STALWART_MASTER_SECRET` (the worker's sign-in), `STALWART_DB_PASSWORD` (its Postgres role), `STALWART_DB_NAME` (default `stalwart`), `STALWART_PG_HOST` (Postgres as the mail server sees it, default `postgres`), `STALWART_IMAP_HOST` / `STALWART_IMAP_PORT` (993), `STALWART_SMTP_HOST` / `STALWART_SMTP_PORT` (465), `MAIL_HOSTNAME` (public name, default `mail.APP_DOMAIN`), `RAW_CACHE_MB` (256). Dev values are in `.env.dev`; the dev Stalwart publishes 8180 (API), 1025 (SMTP in), 1465, 1587, 1993 on loopback.
### D.9 Acceptance criteria ### D.9 Acceptance criteria
1. A newly registered user can receive mail from an outside address at their handle and sees it in webmail within one sync cycle; mail they send arrives at Gmail not in spam (SPF/DKIM/DMARC pass). 1. A newly registered user can receive mail from an outside address at their handle and sees it in webmail within one sync cycle; mail they send arrives at Gmail not in spam (SPF/DKIM/DMARC pass).
2. The same handle + password signs in to IMAP (when §D.5 allows it); after a webmail password change or reset, the old password fails and the new one works with no other step; a blocked user cannot sign in to IMAP. 2. The handle + an app password signs in to IMAP/SMTP; the webmail password never does; revoking the app password (or a password reset) stops it at once; a blocked user cannot sign in to IMAP.
3. The native account cannot be removed via API (409) or UI; deleting the user removes the Stalwart mailbox. 3. The native account cannot be removed via API (409) or UI; deleting the user removes the Stalwart mailbox.
4. Webmail stores no copy of the account's password (`secret_ct` unused for native accounts). 4. Webmail stores no copy of the account's password (`secret_ct` unused for native accounts).
5. After D1, an attachment-heavy fixture mailbox uses ≤ 60% of the pre-D1 blob storage, and every attachment still downloads byte-identical. 5. After D1, an attachment-heavy fixture mailbox uses ≤ 60% of the pre-D1 blob storage, and every attachment still downloads byte-identical.