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>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""Push notifications about new mail: the API devices subscribe through, and the
|
||||
job that decides what to tell them — delivered to a mocked push service and
|
||||
decrypted the way the browser would."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
import respx
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
from test_webpush import browser_decrypt
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session, destroy_session
|
||||
from app.main import create_app
|
||||
from app.models import Account, AppSettings, Mailbox, Message, PushSubscription, User
|
||||
from app.push import service
|
||||
from app.push.webpush import b64u_encode
|
||||
|
||||
CSRF = "csrf"
|
||||
ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-device"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
db: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
token: str
|
||||
inbox: Mailbox
|
||||
archive: Mailbox
|
||||
ua: ec.EllipticCurvePrivateKey
|
||||
auth: bytes
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
db = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
# The server's key pair lives in the one app_settings row: put it back as it was.
|
||||
row = await db.get(AppSettings, 1)
|
||||
saved = (row.vapid_public, row.vapid_private_ct) if row else None
|
||||
user = User(handle=f"push{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"x",
|
||||
discovery_src="manual",
|
||||
state="live",
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
||||
db.add_all([inbox, archive])
|
||||
await db.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(
|
||||
client,
|
||||
db,
|
||||
user.id,
|
||||
token,
|
||||
inbox,
|
||||
archive,
|
||||
ec.generate_private_key(ec.SECP256R1()),
|
||||
b"0123456789abcdef",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await db.execute(delete(User).where(User.id == user.id))
|
||||
if saved is not None:
|
||||
await db.execute(
|
||||
update(AppSettings)
|
||||
.where(AppSettings.id == 1)
|
||||
.values(vapid_public=saved[0], vapid_private_ct=saved[1])
|
||||
)
|
||||
await db.commit()
|
||||
await db.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _keys(c: Ctx) -> dict[str, str]:
|
||||
public = c.ua.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
return {"p256dh": b64u_encode(public), "auth": b64u_encode(c.auth)}
|
||||
|
||||
|
||||
async def _subscribe(c: Ctx, endpoint: str = ENDPOINT) -> httpx.Response:
|
||||
return await c.client.post(
|
||||
"/api/push/subscriptions",
|
||||
json={"endpoint": endpoint, "keys": _keys(c)},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
|
||||
|
||||
_uid = iter(range(1, 10_000))
|
||||
|
||||
|
||||
async def _mail(
|
||||
c: Ctx,
|
||||
*,
|
||||
subject: str = "Hello",
|
||||
sender: str = "alice@example.org",
|
||||
name: str = "Alice",
|
||||
box: Mailbox | None = None,
|
||||
flags: list[str] | None = None,
|
||||
age: dt.timedelta = dt.timedelta(minutes=1),
|
||||
) -> uuid.UUID:
|
||||
m = Message(
|
||||
user_id=c.user_id,
|
||||
account_id=c.inbox.account_id,
|
||||
mailbox_id=(box or c.inbox).id,
|
||||
uid=next(_uid),
|
||||
thread_id=None,
|
||||
subject=subject,
|
||||
from_addr=[{"name": name, "email": sender}],
|
||||
internal_date=dt.datetime.now(dt.UTC) - age,
|
||||
flags=flags or [],
|
||||
snippet=" First line of the message ",
|
||||
blob_sha256="0" * 64,
|
||||
)
|
||||
c.db.add(m)
|
||||
await c.db.commit()
|
||||
return m.id
|
||||
|
||||
|
||||
def _decrypted(c: Ctx, route: respx.Route) -> list[dict[str, object]]:
|
||||
return [json.loads(browser_decrypt(call.request.content, c.ua, c.auth)) for call in route.calls]
|
||||
|
||||
|
||||
async def test_subscribe_validates_and_hands_out_the_key(ctx: Ctx) -> None:
|
||||
key = await ctx.client.get("/api/push/key")
|
||||
assert key.status_code == 200 and len(key.json()["public_key"]) == 87
|
||||
assert (await ctx.client.get("/api/push/key")).json() == key.json() # made once
|
||||
|
||||
assert (await _subscribe(ctx, "https://169.254.169.254/latest")).status_code == 400
|
||||
bad = await ctx.client.post(
|
||||
"/api/push/subscriptions",
|
||||
json={"endpoint": ENDPOINT, "keys": {"p256dh": "AAAA", "auth": "AAAA"}},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
assert (await _subscribe(ctx)).status_code == 200
|
||||
assert (await _subscribe(ctx)).status_code == 200 # again: refreshed, not doubled
|
||||
rows = (
|
||||
await ctx.db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == ctx.user_id)
|
||||
)
|
||||
).all()
|
||||
assert len(rows) == 1
|
||||
|
||||
gone = await ctx.client.post(
|
||||
"/api/push/subscriptions/remove", json={"endpoint": ENDPOINT}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert gone.status_code == 200
|
||||
assert (
|
||||
await ctx.db.scalar(select(PushSubscription).where(PushSubscription.endpoint == ENDPOINT))
|
||||
) is None
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_new_inbox_mail_is_pushed_encrypted_and_signed(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
route = respx.post(ENDPOINT).mock(return_value=httpx.Response(201))
|
||||
news = await _mail(ctx, subject="Lunch?")
|
||||
skipped = [
|
||||
await _mail(ctx, flags=["\\Seen"]), # read on another device already
|
||||
await _mail(ctx, box=ctx.archive), # filed away by a filter
|
||||
await _mail(ctx, age=dt.timedelta(days=1)), # old mail moved back to the Inbox
|
||||
await _mail(ctx, sender="ME@example.com"), # from yourself
|
||||
]
|
||||
assert await service.notify(ctx.user_id, [news, *skipped]) == 1
|
||||
request = route.calls[0].request
|
||||
assert request.headers["Content-Encoding"] == "aes128gcm"
|
||||
assert request.headers["Authorization"].startswith("vapid t=")
|
||||
[payload] = _decrypted(ctx, route)
|
||||
assert payload["title"] == "Alice"
|
||||
assert payload["body"] == "Lunch?\nFirst line of the message"
|
||||
assert payload["url"] == "/mail" and payload["type"] == "mail"
|
||||
assert payload["unread"] == 3 # Inbox unread: news, old, from-me
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_many_at_once_become_one_summary(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
route = respx.post(ENDPOINT).mock(return_value=httpx.Response(201))
|
||||
ids = [
|
||||
await _mail(ctx, name=n, sender=f"{n.lower()}@example.org")
|
||||
for n in ("Ann", "Bob", "Cy", "Dee", "Ann")
|
||||
]
|
||||
assert await service.notify(ctx.user_id, ids) == 1
|
||||
[payload] = _decrypted(ctx, route)
|
||||
assert payload["title"] == "5 new messages"
|
||||
assert payload["body"] == "Ann, Bob, Cy…"
|
||||
assert payload["tag"] == "new-mail"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_signed_out_or_expired_devices_are_forgotten(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
other = "https://updates.push.services.mozilla.com/wpush/v2/other"
|
||||
await _subscribe(ctx, other)
|
||||
respx.post(ENDPOINT).mock(return_value=httpx.Response(410)) # browser unsubscribed
|
||||
firefox = respx.post(other).mock(return_value=httpx.Response(201))
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 1
|
||||
left = (
|
||||
await ctx.db.scalars(
|
||||
select(PushSubscription.endpoint).where(PushSubscription.user_id == ctx.user_id)
|
||||
)
|
||||
).all()
|
||||
assert left == [other]
|
||||
|
||||
await destroy_session(ctx.token) # signed out: no subject lines on that device any more
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 0
|
||||
assert firefox.call_count == 1
|
||||
assert (
|
||||
await ctx.db.scalar(select(PushSubscription).where(PushSubscription.user_id == ctx.user_id))
|
||||
) is None
|
||||
|
||||
|
||||
async def test_no_devices_no_work(ctx: Ctx) -> None:
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 0
|
||||
Reference in New Issue
Block a user