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
+61 -6
View File
@@ -11,12 +11,12 @@ from __future__ import annotations
import uuid
from datetime import timedelta
from sqlalchemy import ColumnElement, delete, literal, select, update
from sqlalchemy import ColumnElement, delete, exists, func, literal, select, update
from sqlalchemy import any_ as sa_any
from sqlalchemy import or_ as sa_or
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Thread
from app.models import Draft, Message, Thread
from app.threading_.normalize import is_synthetic, normalize_subject
_SUBJECT_FALLBACK_WINDOW = timedelta(days=7)
@@ -172,18 +172,38 @@ async def _create_thread(session: AsyncSession, message: Message) -> uuid.UUID:
async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None:
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6)."""
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6).
The thread row is locked *before* its messages are read: two transactions
recounting one thread at once (read in the webmail while a sync brings the
same flag from a phone) then take turns, and the second reads what the
first committed — instead of both reading first and the slower one writing
an old count (a message shown read, its thread still "unread"). The
messages are reloaded from the database, not taken from the session's
copies, which a bulk UPDATE may have left behind."""
thread = await session.scalar(
select(Thread)
.where(Thread.id == thread_id)
.with_for_update()
.execution_options(populate_existing=True)
)
if thread is None:
return
messages = list(
await session.scalars(
select(Message)
.where(Message.thread_id == thread_id)
.order_by(Message.internal_date.desc())
.execution_options(populate_existing=True)
)
)
thread = await session.get(Thread, thread_id)
if thread is None:
return
if not messages:
# A reply still being written keeps its (now empty) thread: the draft
# points at it.
if await session.scalar(select(exists().where(Draft.thread_id == thread_id))):
thread.message_count = 0
thread.unread_count = 0
return
await session.execute(delete(Thread).where(Thread.id == thread_id))
return
@@ -241,3 +261,38 @@ async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
await denormalize_thread(session, thread_id)
await session.flush()
return thread_id
async def repair_threads(session: AsyncSession, user_id: uuid.UUID) -> int:
"""Recount the user's threads whose stored counters disagree with their
messages (and drop threads left without any). A safety net run nightly
(app/indexer/gc.py): every write path recounts its threads, but a missed
one would otherwise show a read message as unread until it changed again.
Returns how many were repaired."""
seen = Message.flags.any("\\Seen") # type: ignore[arg-type]
actual = (
select(
Message.thread_id.label("tid"),
func.count().label("n"),
func.count().filter(~seen).label("unread"),
)
.where(Message.user_id == user_id, Message.thread_id.is_not(None))
.group_by(Message.thread_id)
.subquery()
)
stale = list(
await session.scalars(
select(Thread.id)
.outerjoin(actual, actual.c.tid == Thread.id)
.where(
Thread.user_id == user_id,
(actual.c.tid.is_(None) & (Thread.message_count != 0))
| (actual.c.tid.is_(None) & ~exists().where(Draft.thread_id == Thread.id))
| (actual.c.n != Thread.message_count)
| (actual.c.unread != Thread.unread_count),
)
)
)
for tid in sorted(stale):
await denormalize_thread(session, tid)
return len(stale)