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
+14
View File
@@ -104,6 +104,7 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
sync_error=a.sync_error,
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
state_detail=a.state_detail,
kind=a.kind,
)
for a in accounts
]
@@ -225,6 +226,8 @@ async def update_account(
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
# stored secret is only decrypted in the worker, never here).
conn_change = body.imap is not None or body.smtp is not None or body.password is not None
if conn_change and account.kind == "native":
raise_api_error(400, "native_account", "the settings of your own mailbox cannot be changed")
if conn_change:
if not body.password:
raise_api_error(
@@ -267,10 +270,20 @@ async def update_account(
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
kind = await db.scalar(
select(Account.kind).where(Account.id == account_id, Account.user_id == user.id)
)
if kind == "native":
raise_api_error(409, "native_account", "your own mailbox cannot be removed")
ok = await delete_account(db, user_id=user.id, account_id=account_id)
if not ok:
raise_api_error(404, "not_found", "account not found")
await db.commit()
# Removing an account for the user's own address (the mail at its old
# provider) makes room for their hosted mailbox's (App. D §D.4).
from app.hosted import native
await native.ensure_and_sync(db, user)
return {"status": "deleted"}
@@ -288,4 +301,5 @@ def _detail(account: Account) -> AccountDetailOut:
backfill_done=account.backfill_done,
imap_config=account.imap_config,
smtp_config=account.smtp_config,
kind=account.kind,
)
+11 -2
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.hosted.admin import MailServerError
from app.identity.deps import CurrentAdmin, require_csrf
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
from app.identity.schemas import (
@@ -183,7 +184,12 @@ async def patch_user(
if body.quota_bytes is not None:
user.quota_bytes = body.quota_bytes
if body.blocked is not None:
await set_blocked(db, user, body.blocked)
try:
await set_blocked(db, user, body.blocked)
except MailServerError as exc:
raise_api_error(
502, "mail_server_unavailable", f"the mail server did not answer: {exc}"
)
await db.commit()
used = await db.scalar(
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
@@ -200,5 +206,8 @@ async def patch_user(
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
"""Delete a user and all of their data. Cannot be undone."""
user = await _target(db, admin, user_id)
await delete_user(db, user)
try:
await delete_user(db, user)
except MailServerError as exc:
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
return {"status": "deleted"}
+5 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import uuid
from datetime import UTC, datetime, timedelta
from typing import Annotated
@@ -17,7 +18,7 @@ from app.config import get_settings
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store
from app.indexer import blobstore
from app.indexer import blobstore, rawstore
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
from app.outbox.jobs import RETRY_WINDOW
from app.outbox.schemas import (
@@ -208,6 +209,9 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
)
)
attachment_ids: list[str] = []
if att_rows:
with contextlib.suppress(FileNotFoundError): # fetched if a hosted mailbox's
await rawstore.ensure(msg)
for a in att_rows:
# Kept only inside the message: give the compose attachment a blob of its own.
if not await asyncio.to_thread(
+16 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import html as html_lib
import io
import uuid
@@ -21,7 +22,7 @@ from app.api.settings import add_allowed_image_sender, allowed_image_senders
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store
from app.indexer import blobstore, thumbs
from app.indexer import rawstore, thumbs
from app.indexer.mime import parse_message
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
from app.indexer.thumbs import thumb_type
@@ -128,7 +129,7 @@ async def message_html(
# If the raw blob is unavailable, fall back to whatever is already stored.
if allow_remote or stale:
try:
raw = blobstore.load(user.id, message.blob_sha256)
raw = await rawstore.ensure_bytes(message)
except FileNotFoundError:
raw = None
if raw is not None:
@@ -191,8 +192,16 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
async def _raw_ready(msg: Message) -> None:
"""Have the message's raw source here (a hosted mailbox's is fetched from
the mail server when not cached); a miss is left to the reader to report."""
with contextlib.suppress(FileNotFoundError):
await rawstore.ensure(msg)
async def _attachment_bytes(msg: Message, att: Attachment) -> bytes:
"""An attachment's bytes, taken out of its message (404 if they cannot be)."""
await _raw_ready(msg)
try:
return await asyncio.to_thread(
att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
@@ -236,6 +245,7 @@ async def forward_attachments(
of its own (shared, by hash, with any other use of the same file).
Returns them as the composer lists its attachments."""
msg = await _owned_message(db, user.id, message_id)
await _raw_ready(msg)
rows = [
ComposeAttachment(
user_id=user.id,
@@ -315,6 +325,8 @@ async def attachment_thumbnail(
kind = thumb_type(att.mime_type, att.filename) if att is not None else None
if att is None or kind is None:
raise_api_error(404, "not_found", "no thumbnail")
if not thumbs.made(msg.user_id, att.blob_sha256):
await _raw_ready(msg)
data = await asyncio.to_thread(
thumbs.thumbnail,
msg.user_id,
@@ -350,6 +362,8 @@ async def message_attachment(
)
if att is None:
raise_api_error(404, "not_found", "attachment not found")
if not att_store.has_file(msg.user_id, att.blob_sha256):
await _raw_ready(msg)
try:
# Taken out of the message into a short-lived file (kept a day after
# its last use), so it can be streamed with HTTP Range like before.
+2
View File
@@ -102,6 +102,8 @@ class AccountOut(BaseModel):
sync_error_since: str | None = None
# Why the state is what it is (e.g. the server's reply to a refused sign-in).
state_detail: str = ""
# "native": the user's own hosted mailbox — cannot be removed (App. D §D.4).
kind: str = "external"
class SearchThreadHit(BaseModel):