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>
105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
"""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
|