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:
2026-07-26 18:28:09 +02:00
co-authored by Claude Opus 4.8
parent dd0b089049
commit 4d5a30cffb
5 changed files with 210 additions and 5 deletions
+11
View File
@@ -10,6 +10,7 @@ from app.providers.imap_smtp.parsing import (
parse_list_line,
parse_search_uids,
parse_select_untagged,
parse_vanished,
)
@@ -57,6 +58,16 @@ def test_parse_search_uids() -> None:
assert parse_search_uids([]) == []
def test_parse_vanished() -> None:
# aioimaplib strips the leading "* " before buffering the line.
assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [300, 301, 302, 303, 304, 305, 405] # noqa: E501
# Live-expunge form (no EARLIER) and a descending range are both handled.
assert parse_vanished([b"VANISHED 41", b"VANISHED (EARLIER) 5:3"]) == [41, 3, 4, 5]
# Non-VANISHED lines are ignored; non-bytes entries are skipped.
assert parse_vanished([b"12 FETCH (UID 12 FLAGS (\\Seen))", b"OK done"]) == []
assert parse_vanished([]) == []
def test_parse_select_untagged() -> None:
lines = [
b"6 EXISTS",
+74
View File
@@ -167,3 +167,77 @@ async def test_drafts_poll_reconciles_expunged_rows(monkeypatch) -> None: # typ
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_apply_delta_expunges_vanished_uids() -> None: # type: ignore[no-untyped-def]
"""A QRESYNC delta carrying ``expunged_uids`` removes those rows, drops them from
the search index (tsvector cleared), and un-links any local draft that mirrored
a vanished UID — the VANISHED path (SPEC §6.3)."""
from app.models import Message as MessageModel
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"van{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=10,
)
session.add(inbox)
await session.flush()
for uid in (7, 8, 9):
session.add(
MessageModel(
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
uid=uid,
internal_date=datetime.now(UTC),
blob_sha256="",
)
)
linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=9)
session.add(linked)
await session.commit()
class FakeProvider:
qresync_enabled = True
async def close(self) -> None:
return None
# uids 7 and 9 vanished on the server; 8 survives.
delta = MailboxDelta([], [], [7, 9], None, 4242)
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type]
try:
session.expunge_all()
remaining = set(
await session.scalars(
select(MessageModel.uid).where(MessageModel.mailbox_id == inbox.id)
)
)
assert remaining == {8} # 7 and 9 expunged
assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked
refreshed = await session.get(Mailbox, inbox.id)
assert refreshed.highestmodseq == 4242 # modseq baseline advanced
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()