"""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