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:
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user