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:
@@ -0,0 +1,104 @@
|
||||
"""Thread counters that drifted from their messages are repaired (nightly safety
|
||||
net, app/threading_/threader.py repair_threads)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.db import session_scope
|
||||
from app.identity.passwords import hash_password
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.models import Account, Draft, Mailbox, Thread, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.threading_.threader import repair_threads
|
||||
|
||||
|
||||
def _raw(subject: str) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = subject
|
||||
m["From"] = "billing@example.com"
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = f"<{uuid.uuid4().hex}@example.com>"
|
||||
m.set_content("receipt")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
async def test_repair_recounts_stale_threads_and_drops_empty_ones() -> None:
|
||||
async with session_scope() as db:
|
||||
user = User(
|
||||
handle=f"rep{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
db.add(inbox)
|
||||
await db.flush()
|
||||
result = await persist_message(
|
||||
db,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=inbox.id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=RemoteMessage(
|
||||
uid=1,
|
||||
raw=_raw("Your receipt"),
|
||||
flags=frozenset({"\\Seen"}),
|
||||
internal_date=datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
assert result is not None
|
||||
tid = result.message.thread_id
|
||||
assert tid is not None
|
||||
# A read message whose thread still says "unread" (the bug), and a
|
||||
# thread left behind with no messages at all.
|
||||
await db.execute(update(Thread).where(Thread.id == tid).values(unread_count=1))
|
||||
empty = Thread(
|
||||
user_id=user.id,
|
||||
subject_norm="gone",
|
||||
message_count=1,
|
||||
unread_count=1,
|
||||
last_message_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(empty)
|
||||
# An emptied thread a reply draft still points at: kept, counts zeroed.
|
||||
replied = Thread(
|
||||
user_id=user.id,
|
||||
subject_norm="replying",
|
||||
message_count=1,
|
||||
unread_count=0,
|
||||
last_message_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(replied)
|
||||
await db.flush()
|
||||
db.add(Draft(user_id=user.id, account_id=account.id, thread_id=replied.id, payload={}))
|
||||
await db.flush()
|
||||
empty_id = empty.id
|
||||
replied_id = replied.id
|
||||
try:
|
||||
async with session_scope() as db:
|
||||
assert await repair_threads(db, user.id) == 3
|
||||
async with session_scope() as db:
|
||||
thread = await db.get(Thread, tid)
|
||||
assert thread is not None and thread.unread_count == 0 and thread.message_count == 1
|
||||
assert await db.get(Thread, empty_id) is None
|
||||
kept = await db.get(Thread, replied_id)
|
||||
assert kept is not None and kept.message_count == 0
|
||||
assert await repair_threads(db, user.id) == 0 # nothing left to repair
|
||||
finally:
|
||||
async with session_scope() as db:
|
||||
await db.execute(delete(User).where(User.id == user.id)) # cascades
|
||||
assert await db.scalar(select(User.id).where(User.id == user.id)) is None
|
||||
Reference in New Issue
Block a user