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
+13 -6
View File
@@ -10,7 +10,9 @@ decoded on its own). It is taken out of the raw message when wanted:
* :func:`dedupe_existing` — the one-time pass removing the separate copies
stored before, each only after checking it can be taken out of its message.
Blocking (parsing, disk): call these in a thread.
Blocking (parsing, disk): call these in a thread — after
:func:`app.indexer.rawstore.ensure` for the message, which fetches a hosted
mailbox's message from its mail server when it is not cached.
"""
from __future__ import annotations
@@ -23,7 +25,7 @@ import uuid
from collections import defaultdict
from pathlib import Path
from app.indexer import blobstore
from app.indexer import blobstore, rawstore
from app.indexer.mime import load_message, part_payload
logger = logging.getLogger(__name__)
@@ -45,8 +47,8 @@ def load(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None)
legacy = _legacy(user_id, sha)
if legacy is not None:
return legacy.read_bytes()
raw = blobstore.blob_path(user_id, message_sha)
if part_index == NOT_IN_MESSAGE or not raw.exists():
raw = rawstore.locate(user_id, message_sha)
if part_index == NOT_IN_MESSAGE or raw is None:
raise FileNotFoundError(sha)
payload = part_payload(load_message(path=raw), part_index, sha)
if payload is None:
@@ -82,6 +84,11 @@ def prune_cache(cache: Path) -> None:
path.unlink(missing_ok=True)
def has_file(user_id: uuid.UUID, sha: str) -> bool:
"""Whether :func:`file` can answer without reading the message."""
return _legacy(user_id, sha) is not None or (_cache_dir(user_id) / sha).exists()
def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path:
"""A file holding the attachment, to stream from. Raises FileNotFoundError."""
legacy = _legacy(user_id, sha)
@@ -167,9 +174,9 @@ async def dedupe_existing(batch: int = 200, *, user_id: uuid.UUID | None = None)
located: dict[uuid.UUID, int] = {}
for (owner, msg_sha), atts in by_message.items():
raw = blobstore.blob_path(owner, msg_sha)
raw = rawstore.locate(owner, msg_sha)
try:
indexes = await asyncio.to_thread(_part_indexes, raw) if raw.exists() else {}
indexes = await asyncio.to_thread(_part_indexes, raw) if raw is not None else {}
except Exception: # noqa: BLE001 - unreadable message: keep the copies
logger.warning("attachments: cannot parse %s", raw, exc_info=True)
indexes = {}