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):