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