Sync: a flag set on the server left the thread counters stale
Reported from the running app: a message showed as unread in the list — bold, blue dot — while the reading pane showed it as read, and only a manual "mark as read" reconciled them. The two views read different things. The pane reads the message's own flags; the list reads `threads.unread_count`, denormalized from them. `apply_delta` updated message flags with a bare UPDATE and never recomputed the thread, so a flag set anywhere but in this app — read on a phone, filed by a server-side rule — moved one and not the other. Auto-mark-on-read could not fix it either: it sees a message that is already \Seen and correctly does nothing, leaving the counter wrong forever. Expunges had the same hole, which also left `message_count` overstated. `apply_delta` now collects the affected threads and re-denormalizes them after the deletes, so a thread emptied by an expunge is removed rather than recounted. Migration 0018 repairs rows that already drifted — recomputed from the messages, so it is a no-op on a healthy database. One thread was stale here; installs that have been syncing longer will have more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -241,3 +241,79 @@ async def test_apply_delta_expunges_vanished_uids() -> None: # type: ignore[no-
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_server_side_flag_change_updates_the_thread_counters() -> None:
|
||||
"""A flag set elsewhere (phone, webmail, server rule) must re-denormalize.
|
||||
|
||||
`threads.unread_count` is what the message list renders, while the reading
|
||||
pane renders the message's own flags. When a delta updated flags without
|
||||
recomputing the thread, the two disagreed forever: the list kept showing a
|
||||
message as unread that the pane showed as read, and only a manual "mark as
|
||||
read" — which goes through a different path — reconciled them.
|
||||
"""
|
||||
from app.models import Thread
|
||||
from app.threading_.threader import denormalize_thread
|
||||
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"fc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
inbox = Mailbox(
|
||||
account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox", last_seen_uid=5
|
||||
)
|
||||
thread = Thread(user_id=user.id, subject_norm="t", last_message_at=datetime.now(UTC))
|
||||
session.add_all([inbox, thread])
|
||||
await session.flush()
|
||||
message = Message(
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=inbox.id,
|
||||
thread_id=thread.id,
|
||||
uid=5,
|
||||
internal_date=datetime.now(UTC),
|
||||
blob_sha256="0" * 64,
|
||||
flags=[],
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
await denormalize_thread(session, thread.id)
|
||||
await session.commit()
|
||||
assert (await session.get(Thread, thread.id)).unread_count == 1
|
||||
|
||||
class FakeProvider:
|
||||
qresync_enabled = True
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
class _FlagChange:
|
||||
uid = 5
|
||||
flags = ["\\Seen"]
|
||||
|
||||
delta = MailboxDelta([], [_FlagChange()], [], None, None)
|
||||
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type]
|
||||
|
||||
try:
|
||||
session.expunge_all()
|
||||
refreshed = await session.get(Thread, thread.id)
|
||||
assert refreshed.unread_count == 0, (
|
||||
"the server said the message was read; the thread counter did not follow"
|
||||
)
|
||||
finally:
|
||||
async with session_scope() as cleanup:
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user