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 -5
View File
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.concurrency import loop_semaphore
from app.config import get_settings
from app.indexer import blobstore
from app.indexer import blobstore, rawstore
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
from app.indexer.sanitize import sanitize_html
from app.models import Attachment, BodyPart, KnownRecipient, Message
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage
from app.search.base import SearchDoc
from app.threading_.normalize import synthesize_message_id
@@ -118,6 +118,13 @@ async def _resolve_message_id(
return parsed_id
async def _keeps_raw(session: AsyncSession, account_id: uuid.UUID) -> bool:
"""Whether the raw message is kept for good: not for a hosted mailbox, whose
mail server holds it (it is only cached, App. D §D.6 item 2)."""
kind = await session.scalar(select(Account.kind).where(Account.id == account_id))
return kind != "native"
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
@@ -128,8 +135,9 @@ async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmai
settings = get_settings()
threshold = settings.stream_parse_mb * 1024 * 1024
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
if len(raw) > threshold:
return parse_message_file(blobstore.blob_path(user_id, sha))
path = rawstore.locate(user_id, sha) if len(raw) > threshold else None
if path is not None:
return parse_message_file(path)
return parse_message(raw)
@@ -154,7 +162,10 @@ async def persist_message(
if existing is not None:
return None
sha = blobstore.store(user_id, remote.raw)
if await _keeps_raw(session, account_id):
sha = blobstore.store(user_id, remote.raw)
else:
sha = rawstore.put_cached(user_id, remote.raw)
parsed = await _parse_bounded(user_id, sha, remote.raw)
message_id_hdr = await _resolve_message_id(