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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user