Files
webmail-client/backend/tests/sync/test_secondary_poll.py
T
lukasandClaude Opus 5.5 fda4cc6f16 Installable app, push notifications about new mail, Install app entry
The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar
badge), theme-coloured title bar following the chosen theme, and an app-icon
badge with the unread Inbox count. The service worker is always installed;
offline access is on only when it is registered as /sw.js?offline=1, so
turning it on or off is an update that applies at once.

Notifications (Settings → Accounts → Notifications, per device) use Web Push.
Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in
app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and
checked against the RFC's worked example. The server makes its key pair on
first use (private half encrypted with MASTER_KEY). New mail found by the live
sync in an Inbox enqueues a job 5 s later that tells the user's devices about
messages still unread there, under 2 hours old and not from the user; more
than three become one "N new messages". A subscription belongs to the session
it was made under: signed out or revoked, nothing more is sent there. Endpoints
are limited to the known push services (no SSRF). Nothing shows while the app
is in front (except on Apple, which requires it). Tested with real pushes
through Google's service in Chromium and Opera.

The mobile drawer gets "Install app": Chrome's own install dialog where the
browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with
"Don't show this again". docs/nas-install.md §5.3 covers installing,
notifications, Public URL and a later Android app (TWA).

Migration 0022: push_subscriptions, app_settings.vapid_*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:52:26 +02:00

374 lines
14 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()
async def test_new_inbox_mail_while_live_asks_for_a_push(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""New mail found by the live sync in an Inbox enqueues the new-mail push
(app/push); new mail in any other folder does not."""
from app.push import service as push_service
asked: list[tuple[uuid.UUID, list[uuid.UUID]]] = []
async def record(user_id: uuid.UUID, ids: list[uuid.UUID]) -> None:
asked.append((user_id, ids))
monkeypatch.setattr(push_service, "enqueue_new_mail", record)
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"psh{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")
session.add_all([inbox, sent])
await session.commit()
class FakeProvider:
async def close(self) -> None:
return None
def delta(uid: int, mid: str) -> MailboxDelta:
msg = RemoteMessage(
uid=uid, raw=_raw("Hi", mid), flags=frozenset(), internal_date=datetime.now(UTC)
)
return MailboxDelta([msg], [], [], None, None)
try:
await sync_engine.apply_delta(FakeProvider(), account, sent, delta(3, "<p1@x.com>")) # type: ignore[arg-type]
assert asked == []
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta(4, "<p2@x.com>")) # type: ignore[arg-type]
new_id = await session.scalar(select(Message.id).where(Message.mailbox_id == inbox.id))
assert asked == [(user.id, [new_id])]
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()