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
+47 -11
View File
@@ -1,14 +1,18 @@
"""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
over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its
own — 16 random letters, shown once — that can be revoked without touching the
main password or the other apps. The webmail itself never accepts one.
over IMAP/SMTP, and a calendar or contacts app to the DAV server — never with
the account password (no 2FA code can be asked for there), always with one of
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
SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail
server compares exactly what an app sends, so the password is copied and
entered without the spaces it is shown with.
With hosted mailboxes on, the mail server makes the password (``app_`` and 36
more letters and digits: its format names the credential) and checks it for
IMAP/SMTP itself; its argon2id hash is kept here too, for the DAV server, 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
@@ -20,10 +24,12 @@ import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
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
SERVER_LENGTH = 40 # the mail server's: "app_" + base32 of a 4-byte id and 18 random bytes
MAX_PER_USER = 25
NAME_MAX = 60
_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:
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(
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)
await db.commit()
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:
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(
delete(UserAppPassword).where(
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:
"""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.commit()
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:
"""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."""
if len(password) != LENGTH:
if len(password) not in (LENGTH, SERVER_LENGTH):
return None
for row in await list_for(db, user_id):
if verify_password(row.password_hash, password):
+29 -3
View File
@@ -6,16 +6,18 @@ from __future__ import annotations
import logging
import re
import uuid
from typing import Annotated
from typing import Annotated, NoReturn
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
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 totp as totp_mod
from app.identity.cookies import (
@@ -105,6 +107,7 @@ def _user_out(user: User) -> UserOut:
settings=user.settings,
totp_enabled=user.totp_secret is not None,
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:
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
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)
return _user_out(user)
@@ -316,7 +325,12 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
await db.commit()
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.
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
# device keeps skipping the code on the old trust.
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) -----------------------------------------
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:
from app.models import UserAppPassword
@@ -361,6 +381,8 @@ async def create_app_password(
raise_api_error(
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)
@@ -372,7 +394,11 @@ async def create_app_password(
async def revoke_app_password(
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> 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")
return MessageOut()
+3
View File
@@ -93,6 +93,9 @@ class UserOut(BaseModel):
settings: dict[str, object]
totp_enabled: 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):
+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:
"""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:
user.blocked_at = dt.datetime.now(dt.UTC)
await db.flush()
@@ -97,8 +106,18 @@ async def _plugin_tables(db: AsyncSession) -> list[str]:
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
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
# tidies threads, exactly as when the user removes an account themselves.