sync: QRESYNC VANISHED expunge detection (SPEC §6.3 M2 fast resync)
Server-side deletions now propagate to the DB. Previously poll() detected new messages and CONDSTORE flag deltas but never expunges (the old TODO), so mail deleted in another client lingered as phantoms outside the small Drafts reconcile. - provider: enable QRESYNC when advertised (_maybe_enable_qresync + qresync_enabled property). poll() harvests VANISHED (expunged UIDs) and changed FLAGS in one pass via a raw `SELECT <mbox> (QRESYNC (uidvalidity modseq))` — issued as a low-level SELECT because aioimaplib only surfaces untagged VANISHED lines on a sync command, not on an async UID FETCH. CONDSTORE-only servers (e.g. Gmail) keep the CHANGEDSINCE flag delta and fall back to the engine reconcile. - parsing: parse_vanished() for `VANISHED [(EARLIER)] <uid-set>` with range expansion. - engine: apply_delta now drops expunged rows from the search index, un-links any local draft mirroring a vanished UID, and publishes message.deleted for the UI. - tests: parse_vanished unit cases + apply_delta expunge (removal, draft un-link, modseq advance). Verified live: the worker issues `SELECT "INBOX" (QRESYNC (uidvalidity modseq))` against QRESYNC-capable accounts. ruff + mypy --strict clean; 296 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -323,6 +323,7 @@ async def apply_delta(
|
||||
|
||||
results: list[PersistResult] = []
|
||||
max_uid = mailbox.last_seen_uid
|
||||
gone_ids: list[uuid.UUID] = []
|
||||
async with session_scope() as session:
|
||||
for remote in delta.new:
|
||||
r = await persist_message(
|
||||
@@ -343,17 +344,49 @@ async def apply_delta(
|
||||
.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(
|
||||
await session.scalars(
|
||||
select(Message.id).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(Message).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
)
|
||||
)
|
||||
# A local draft whose mirrored copy vanished reverts to local-only.
|
||||
await session.execute(
|
||||
update(Draft)
|
||||
.where(Draft.account_id == account.id, Draft.imap_uid.in_(delta.expunged_uids))
|
||||
.values(imap_uid=None)
|
||||
)
|
||||
updates = {"last_seen_uid": max_uid}
|
||||
if delta.new_highestmodseq is not None:
|
||||
updates["highestmodseq"] = delta.new_highestmodseq
|
||||
await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates))
|
||||
|
||||
await _emit_batch(results, account, mailbox.path, len(results))
|
||||
|
||||
if gone_ids:
|
||||
# Drop expunged messages from the search index (PG keeps its tsvector on the
|
||||
# row, so this is a no-op there; Meili needs the explicit removal) and tell
|
||||
# the UI to refresh, mirroring the drafts-reconcile notification.
|
||||
backend = get_search_backend()
|
||||
for mid in gone_ids:
|
||||
await backend.delete(mid)
|
||||
await bus.publish(
|
||||
"message.deleted",
|
||||
{
|
||||
"user_id": str(account.user_id),
|
||||
"account_id": str(account.id),
|
||||
"mailbox": mailbox.path,
|
||||
},
|
||||
)
|
||||
|
||||
for change in delta.flag_changes:
|
||||
await bus.publish(
|
||||
"flags.changed",
|
||||
|
||||
Reference in New Issue
Block a user