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
244 lines
8.8 KiB
Python
244 lines
8.8 KiB
Python
"""Secondary (non-INBOX) folder polling — multi-folder CONDSTORE sync (SPEC §6.3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from email.message import EmailMessage
|
|
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.config import get_settings
|
|
from app.db import session_scope
|
|
from app.identity.passwords import hash_password
|
|
from app.models import Account, Draft, Mailbox, Message, User
|
|
from app.providers.base import MailboxDelta, RemoteMessage
|
|
from app.sync import engine as sync_engine
|
|
|
|
|
|
def _raw(subject: str, mid: str) -> bytes:
|
|
m = EmailMessage()
|
|
m["Subject"] = subject
|
|
m["From"] = "me@example.com"
|
|
m["To"] = "bob@example.com"
|
|
m["Message-ID"] = mid
|
|
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
|
m.set_content("body")
|
|
return m.as_bytes()
|
|
|
|
|
|
async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
session = sm()
|
|
user = User(handle=f"sec{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")
|
|
sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent")
|
|
custom = Mailbox(account_id=account.id, user_id=user.id, path="Notes", special_use=None)
|
|
session.add_all([inbox, sent, custom])
|
|
await session.commit()
|
|
|
|
polled: list[str] = []
|
|
|
|
class FakeProvider:
|
|
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
|
|
polled.append(mailbox)
|
|
if mailbox == "SENT":
|
|
return MailboxDelta(
|
|
[
|
|
RemoteMessage(
|
|
uid=5,
|
|
raw=_raw("Sent one", "<s1@x.com>"),
|
|
flags=frozenset({"\\Seen"}),
|
|
internal_date=datetime.now(UTC),
|
|
)
|
|
],
|
|
[],
|
|
[],
|
|
None,
|
|
None,
|
|
)
|
|
return MailboxDelta([], [], [], None, None)
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
|
|
|
|
try:
|
|
await sync_engine._poll_secondary_once(account.id)
|
|
|
|
# Only the non-INBOX special-use folder is polled (SENT); INBOX and the
|
|
# custom folder (no special_use) are skipped.
|
|
assert polled == ["SENT"]
|
|
|
|
# The delta was applied: the sent message is now in the DB under SENT.
|
|
session.expunge_all()
|
|
n = await session.scalar(
|
|
select(func.count()).select_from(Message).where(Message.mailbox_id == sent.id)
|
|
)
|
|
assert n == 1
|
|
finally:
|
|
async with session_scope() as cleanup:
|
|
await cleanup.execute(delete(User).where(User.id == user.id))
|
|
await session.close()
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_drafts_poll_reconciles_expunged_rows(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
"""A Drafts poll drops DB rows whose server copy was expunged (phantoms) and
|
|
un-links a local draft whose mirrored copy vanished."""
|
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
session = sm()
|
|
user = User(handle=f"rec{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()
|
|
drafts = Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts")
|
|
session.add(drafts)
|
|
await session.flush()
|
|
|
|
# uid 2 survives on the server; uids 1 and 3 were expunged (phantom DB rows).
|
|
for uid in (1, 2, 3):
|
|
session.add(
|
|
Message(
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
mailbox_id=drafts.id,
|
|
uid=uid,
|
|
internal_date=datetime.now(UTC),
|
|
blob_sha256="",
|
|
)
|
|
)
|
|
# A local draft still linking the vanished uid 3 must be un-linked (imap_uid -> NULL).
|
|
linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=3)
|
|
kept = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=2)
|
|
session.add_all([linked, kept])
|
|
await session.commit()
|
|
|
|
class FakeProvider:
|
|
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
|
|
return MailboxDelta([], [], [], None, None)
|
|
|
|
async def list_uids(self, mailbox: str) -> set[int]:
|
|
return {2} # only uid 2 remains on the server
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
|
|
|
|
try:
|
|
await sync_engine._poll_secondary_once(account.id)
|
|
|
|
session.expunge_all()
|
|
remaining = set(
|
|
await session.scalars(select(Message.uid).where(Message.mailbox_id == drafts.id))
|
|
)
|
|
assert remaining == {2} # phantoms 1 and 3 removed
|
|
assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked
|
|
assert (await session.get(Draft, kept.id)).imap_uid == 2 # untouched
|
|
finally:
|
|
async with session_scope() as cleanup:
|
|
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()
|