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
+24 -5
View File
@@ -11,7 +11,12 @@ that for every user, whatever the way the rows went:
a sent message (its draft is gone) or a composer closed without saving;
* message/attachment files no row refers to;
* attachment previews (thumbnails) of attachments that are gone;
* streaming copies past their keep time (app/indexer/attachments.py).
* streaming copies past their keep time (app/indexer/attachments.py);
* cached raw messages of a hosted mailbox beyond the cache's room
(app/indexer/rawstore.py).
It also recounts threads whose unread/message counters disagree with their
messages, and drops threads left empty (app/threading_/threader.py).
A file younger than an hour is left alone: it may belong to work in progress
(a message being stored, a sent message waiting for its Sent copy).
@@ -31,6 +36,7 @@ from sqlalchemy import delete, or_, select
from app.config import get_settings
from app.indexer import attachments as att_store
from app.indexer import rawstore
logger = logging.getLogger(__name__)
@@ -94,9 +100,9 @@ def _old(path: Path, now: float) -> bool:
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
"""Clean one user's stored files; returns what was removed."""
from app.db import session_scope
from app.models import Attachment, ComposeAttachment, Draft, Message
from app.models import Account, Attachment, ComposeAttachment, Draft, Message
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
async with session_scope() as db:
# Uploads no draft refers to, once a week old.
used = _draft_upload_ids(
@@ -116,8 +122,14 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
async with session_scope() as db:
# Every file still referred to. Attachments count only where their file
# is their only copy (not found in the message, or not yet looked for).
# A hosted mailbox's messages are kept by its mail server: their raw
# file here (left by a Sent copy filed late) is not needed.
referenced = set(
await db.scalars(select(Message.blob_sha256).where(Message.user_id == user_id))
await db.scalars(
select(Message.blob_sha256)
.join(Account, Account.id == Message.account_id)
.where(Message.user_id == user_id, Account.kind != "native")
)
)
referenced |= set(
await db.scalars(
@@ -140,6 +152,12 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id))
)
# Counters out of step with their messages (see repair_threads).
from app.threading_.threader import repair_threads
async with session_scope() as db:
stats["threads_repaired"] = await repair_threads(db, user_id)
root = Path(get_settings().blob_root) / str(user_id)
if not root.is_dir():
return stats
@@ -168,6 +186,7 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
cache = root / "cache"
if cache.is_dir():
att_store.prune_cache(cache)
rawstore.prune(user_id)
return stats
@@ -178,7 +197,7 @@ async def sweep(user_id: uuid.UUID | None = None) -> dict[str, int]:
async with session_scope() as db:
users = [user_id] if user_id is not None else list(await db.scalars(select(User.id)))
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
for uid in users:
try:
stats = await sweep_user(uid)