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:
@@ -23,6 +23,7 @@ from app.providers.imap_smtp import ImapSmtpProvider
|
||||
from app.search.factory import get_search_backend
|
||||
from app.sync.folder_names import infer_special_use
|
||||
from app.sync.provider_factory import build_provider
|
||||
from app.threading_.threader import denormalize_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -371,22 +372,42 @@ async def apply_delta(
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
max_uid = max(max_uid, remote.uid)
|
||||
# Threads whose denormalized counters this batch invalidates. A flag set
|
||||
# on the server (read on a phone, filed by a server-side rule) and an
|
||||
# expunge both change what `threads.unread_count` should say, and neither
|
||||
# goes through apply_flags_to_messages — so without this the row keeps its
|
||||
# old count and the list shows a message as unread that the reading pane
|
||||
# shows as read.
|
||||
touched_threads: set[uuid.UUID] = set()
|
||||
|
||||
for change in delta.flag_changes:
|
||||
await session.execute(
|
||||
update(Message)
|
||||
.where(Message.mailbox_id == mailbox.id, Message.uid == change.uid)
|
||||
.values(flags=sorted(change.flags))
|
||||
)
|
||||
if delta.expunged_uids:
|
||||
# Server-side expunges (QRESYNC/VANISHED, SPEC §6.3). Capture the row ids
|
||||
# first so we can drop them from the search index after the delete.
|
||||
gone_ids = list(
|
||||
if delta.flag_changes:
|
||||
touched_threads.update(
|
||||
await session.scalars(
|
||||
select(Message.id).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
select(Message.thread_id).where(
|
||||
Message.mailbox_id == mailbox.id,
|
||||
Message.uid.in_([c.uid for c in delta.flag_changes]),
|
||||
Message.thread_id.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
if delta.expunged_uids:
|
||||
# Server-side expunges (QRESYNC/VANISHED, SPEC §6.3). Capture the row ids
|
||||
# first so we can drop them from the search index after the delete.
|
||||
gone = (
|
||||
await session.execute(
|
||||
select(Message.id, Message.thread_id).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
gone_ids = [row[0] for row in gone]
|
||||
touched_threads.update(row[1] for row in gone if row[1] is not None)
|
||||
await session.execute(
|
||||
delete(Message).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
@@ -398,6 +419,11 @@ async def apply_delta(
|
||||
.where(Draft.account_id == account.id, Draft.imap_uid.in_(delta.expunged_uids))
|
||||
.values(imap_uid=None)
|
||||
)
|
||||
# After the deletes, so a thread left with no messages is removed rather
|
||||
# than recounted (denormalize_thread handles that case).
|
||||
for tid in touched_threads:
|
||||
await denormalize_thread(session, tid)
|
||||
|
||||
updates = {"last_seen_uid": max_uid}
|
||||
if delta.new_highestmodseq is not None:
|
||||
updates["highestmodseq"] = delta.new_highestmodseq
|
||||
|
||||
Reference in New Issue
Block a user