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
320 lines
11 KiB
Python
320 lines
11 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()
|
|
|
|
|
|
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()
|