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
+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()