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
+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.