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:
2026-10-08 18:52:26 +02:00
co-authored by Claude Opus 5.5
parent 0729dbb691
commit fda4cc6f16
37 changed files with 1765 additions and 42 deletions
+2
View File
@@ -19,6 +19,7 @@ def register_routers(app: FastAPI) -> None:
from app.api.mailboxes import router as mailboxes_router
from app.api.messages import router as messages_router
from app.api.plugins import router as plugins_router
from app.api.push import router as push_router
from app.api.recipients import router as recipients_router
from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
@@ -44,6 +45,7 @@ def register_routers(app: FastAPI) -> None:
plugins_router,
admin_router,
spellcheck_router,
push_router,
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
+80
View File
@@ -0,0 +1,80 @@
"""Push notifications about new mail: the server's public key, and the devices
that want them (Settings → Accounts → Notifications)."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.push import service, webpush
router = APIRouter(prefix="/push", tags=["push"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class PushKeyOut(BaseModel):
public_key: str
class PushKeys(BaseModel):
p256dh: str = Field(min_length=1, max_length=200)
auth: str = Field(min_length=1, max_length=100)
class SubscriptionIn(BaseModel):
"""The browser's ``PushSubscription.toJSON()``."""
endpoint: str = Field(min_length=1, max_length=2000)
keys: PushKeys
class EndpointIn(BaseModel):
endpoint: str = Field(min_length=1, max_length=2000)
@router.get("/key", response_model=PushKeyOut)
async def push_key(user: CurrentUser, db: DbSession) -> PushKeyOut:
"""The key a browser subscribes with (``applicationServerKey``)."""
return PushKeyOut(public_key=(await service.vapid(db)).public_b64)
@router.post("/subscriptions")
async def add_subscription(
body: SubscriptionIn,
request: Request,
user: CurrentUser,
session: CurrentSession,
db: DbSession,
) -> dict[str, str]:
if not webpush.allowed_endpoint(body.endpoint):
raise_api_error(400, "push_service", "not a known push service")
try:
p256dh = webpush.b64u_decode(body.keys.p256dh)
auth = webpush.b64u_decode(body.keys.auth)
if len(p256dh) != 65 or len(auth) != 16:
raise ValueError
except ValueError:
raise_api_error(400, "push_keys", "invalid subscription keys")
await service.subscribe(
db,
user_id=user.id,
session_sid=session.sid,
endpoint=body.endpoint,
p256dh=body.keys.p256dh,
auth=body.keys.auth,
user_agent=request.headers.get("user-agent", ""),
)
return {"status": "ok"}
@router.post("/subscriptions/remove")
async def remove_subscription(body: EndpointIn, user: CurrentUser, db: DbSession) -> dict[str, str]:
await service.unsubscribe(db, user_id=user.id, endpoint=body.endpoint)
return {"status": "ok"}
+7
View File
@@ -121,6 +121,13 @@ async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionI
return out
async def session_alive(user_id: uuid.UUID | str, sid: str) -> bool:
"""Whether the session with public id ``sid`` is still signed in."""
redis = get_redis()
token = await aw(redis.hget(_index_key(user_id), sid))
return token is not None and bool(await redis.exists(_PREFIX + token))
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
"""Revoke a specific session by its public sid. Returns False if not found."""
redis = get_redis()
+26
View File
@@ -82,6 +82,11 @@ class AppSettings(Base):
# Where users reach the app, e.g. https://mail.example.com — reset links are
# built from this, never from the request's Host header.
public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
# The key pair push notifications are signed with (VAPID, RFC 8292): made on
# first use; the private half vault-encrypted, the public half as browsers
# take it (base64url, uncompressed P-256 point).
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
updated_at: Mapped[dt.datetime] = _now()
@@ -344,6 +349,27 @@ class SavedSearch(Base):
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
__tablename__ = "push_subscriptions"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# The push service URL the browser handed out; posting to it reaches the device.
endpoint: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
# The browser's keys the message is encrypted to (RFC 8291), base64url.
p256dh: Mapped[str] = mapped_column(Text, nullable=False)
auth: Mapped[str] = mapped_column(Text, nullable=False)
# The sign-in it was made under: nothing is sent once that session is gone.
session_sid: Mapped[str] = mapped_column(Text, nullable=False)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
created_at: Mapped[dt.datetime] = _now()
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class PluginKv(Base):
"""Namespaced key/value store for plugins (SPEC §11 ``plugin_kv``).
+1
View File
@@ -0,0 +1 @@
"""Push notifications about new mail (Web Push)."""
+279
View File
@@ -0,0 +1,279 @@
"""Push notifications about new mail: the server's keys, the devices, and the job.
New Inbox mail found by the live sync enqueues :func:`push_new_mail_job` a few
seconds later, so filters and the spam check have moved what they move by then;
the job looks again at each message and tells the user's devices only about
mail that is still unread in an Inbox, recent, and not from the user.
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Any
import httpx
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.sessions import session_alive
from app.models import Account, AppSettings, Mailbox, Message, PushSubscription
from app.push import webpush
from app.security.crypto import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
# How long after new Inbox mail is found the job runs (filters, spam check).
NOTIFY_DELAY_S = 5
# Mail older than this is not news: a message moved back into the Inbox, or a
# backlog after the server was unreachable for a while.
MAX_AGE = dt.timedelta(hours=2)
# More new messages than this at once become a single "N new messages".
MAX_SINGLE = 3
SNIPPET_CHARS = 140
# ---- the server's key pair ------------------------------------------------------------
async def vapid(db: AsyncSession) -> webpush.Vapid:
"""The server's VAPID key pair, made (once) on first use."""
row = await db.get(AppSettings, 1)
if row is not None and row.vapid_private_ct:
return webpush.Vapid.from_pem(decrypt_secret(row.vapid_private_ct))
row = await db.scalar(select(AppSettings).where(AppSettings.id == 1).with_for_update())
if row is None:
row = AppSettings(id=1)
db.add(row)
elif row.vapid_private_ct: # made by another request meanwhile
return webpush.Vapid.from_pem(decrypt_secret(row.vapid_private_ct))
key = webpush.Vapid.generate()
row.vapid_private_ct = encrypt_secret(key.pem())
row.vapid_public = key.public_b64
await db.commit()
return key
def _subject(row: AppSettings | None) -> str:
"""Who to contact about our pushes (the VAPID ``sub``): the app's address
when it is set and https, else the postmaster of the app's domain."""
url = row.public_url if row is not None else ""
if url.startswith("https://"):
return url
return f"mailto:postmaster@{get_settings().app_domain}"
# ---- devices ------------------------------------------------------------------------
async def subscribe(
db: AsyncSession,
*,
user_id: uuid.UUID,
session_sid: str,
endpoint: str,
p256dh: str,
auth: str,
user_agent: str,
) -> None:
"""Remember a device (or refresh it). One row per endpoint: a browser that
is now signed in as someone else is theirs from now on."""
row = await db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
if row is None:
row = PushSubscription(endpoint=endpoint)
db.add(row)
row.user_id = user_id
row.session_sid = session_sid
row.p256dh = p256dh
row.auth = auth
row.user_agent = user_agent[:256]
await db.commit()
async def unsubscribe(db: AsyncSession, *, user_id: uuid.UUID, endpoint: str) -> None:
await db.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
)
)
await db.commit()
# ---- what to say --------------------------------------------------------------------
def _sender(msg: Message) -> str:
first = (msg.from_addr or [{}])[0]
return (first.get("name") or first.get("email") or "Unknown sender").strip()
def _payloads(messages: list[Message], unread: int) -> list[dict[str, object]]:
"""One notification per message, or one summary when there are many."""
if len(messages) > MAX_SINGLE:
senders: list[str] = []
for m in messages:
name = _sender(m)
if name not in senders:
senders.append(name)
shown = ", ".join(senders[:3]) + ("…" if len(senders) > 3 else "")
return [
{
"type": "mail",
"title": f"{len(messages)} new messages",
"body": shown,
"tag": "new-mail",
"url": "/mail",
"unread": unread,
}
]
out: list[dict[str, object]] = []
for m in messages:
subject = m.subject.strip() or "(no subject)"
snippet = " ".join(m.snippet.split())[:SNIPPET_CHARS]
out.append(
{
"type": "mail",
"title": _sender(m),
"body": f"{subject}\n{snippet}" if snippet else subject,
"tag": f"thread-{m.thread_id}",
"url": f"/mail/t/{m.thread_id}" if m.thread_id else "/mail",
"thread_id": str(m.thread_id) if m.thread_id else None,
"unread": unread,
}
)
return out
async def _news(db: AsyncSession, user_id: uuid.UUID, ids: list[uuid.UUID]) -> list[Message]:
"""The messages still worth a notification, oldest first."""
own = {
e.lower() for e in await db.scalars(select(Account.email).where(Account.user_id == user_id))
}
rows = await db.scalars(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Message.id.in_(ids),
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
Message.internal_date >= dt.datetime.now(dt.UTC) - MAX_AGE,
)
.order_by(Message.internal_date)
)
out: list[Message] = []
seen: set[str] = set()
for m in rows:
sender = ((m.from_addr or [{}])[0].get("email") or "").lower()
if sender in own:
continue # sent to yourself, or a copy of your own message
key = m.delivery_id or str(m.id) # one email that reached two accounts
if key in seen:
continue
seen.add(key)
out.append(m)
return out
async def _unread(db: AsyncSession, user_id: uuid.UUID) -> int:
"""Unread Inbox messages, for the app icon's badge."""
count = await db.scalar(
select(func.count())
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
)
)
return int(count or 0)
# ---- the job ------------------------------------------------------------------------
async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
"""Tell the user's signed-in devices about ``message_ids``; returns how many
pushes were delivered to push services."""
from app.db import session_scope
async with session_scope() as db:
# Plain values: the rows expire when the key pair is first saved below.
subs = [
(row.id, row.endpoint, row.p256dh, row.auth, row.session_sid)
for row in await db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
]
if not subs:
return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
key = await vapid(db)
subject = _subject(await db.get(AppSettings, 1))
sent = 0
gone: list[uuid.UUID] = []
used: list[uuid.UUID] = []
async with httpx.AsyncClient(timeout=10.0) as client:
for sub_id, endpoint, p256dh, auth, sid in subs:
if not await session_alive(user_id, sid):
gone.append(sub_id) # signed out: this device hears nothing more
continue
try:
for payload in payloads:
await webpush.send(
client,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
payload=payload,
vapid=key,
subject=subject,
)
sent += 1
used.append(sub_id)
except webpush.Gone:
gone.append(sub_id)
except webpush.Failed as err:
logger.warning("push to %s failed: %s", endpoint.split("/")[2], err)
if gone or used:
async with session_scope() as db:
if gone:
await db.execute(delete(PushSubscription).where(PushSubscription.id.in_(gone)))
if used:
await db.execute(
update(PushSubscription)
.where(PushSubscription.id.in_(used))
.values(last_used_at=func.now())
)
return sent
async def push_new_mail_job(ctx: dict[str, Any], user_id: str, message_ids: list[str]) -> int:
"""arq job: notify about new Inbox mail (enqueued by the live sync)."""
return await notify(uuid.UUID(user_id), [uuid.UUID(m) for m in message_ids])
async def enqueue_new_mail(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> None:
"""Called by the live sync with new Inbox mail. Never raises: a missing
notification must not stop the sync."""
if not message_ids:
return
from app.queue import enqueue_job
try:
await enqueue_job(
"push_new_mail_job",
str(user_id),
[str(m) for m in message_ids],
_defer_by=NOTIFY_DELAY_S,
)
except Exception: # noqa: BLE001 - best effort
logger.warning("could not enqueue new-mail push for user %s", user_id, exc_info=True)
+193
View File
@@ -0,0 +1,193 @@
"""Web Push, the protocol (RFC 8030 delivery, RFC 8291 encryption, RFC 8292 VAPID).
A browser that wants notifications gives us a push service URL (Google's,
Mozilla's, Apple's, Microsoft's) and two keys. A message is encrypted to those
keys, so the push service only carries it, and signed with our own key pair
(VAPID), so the push service knows it comes from the server the browser
subscribed to. Written here rather than taken from pywebpush, which needs a
newer ``cryptography`` than the rest of the backend pins; it is small, and
``tests/push`` checks it against the RFC 8291 worked example.
"""
from __future__ import annotations
import base64
import json
import os
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
RECORD_SIZE = 4096
# Push services only: the endpoint comes from the browser, i.e. from the user,
# and the server posts to it — anything else would let a user aim the server at
# an internal address (SSRF). Every browser in use delivers through one of these.
PUSH_HOSTS = (
"fcm.googleapis.com", # Chrome, Edge on Android, Opera, Samsung Internet
"android.googleapis.com",
".google.com", # Chromium builds subscribe at jmt17.google.com and the like
".push.services.mozilla.com", # Firefox
".push.apple.com", # Safari, and installed apps on iPhone/iPad
".notify.windows.com", # Edge on Windows
)
def b64u_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def b64u_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def allowed_endpoint(endpoint: str) -> bool:
"""Whether ``endpoint`` is an https URL of a known push service."""
parts = urlsplit(endpoint)
host = (parts.hostname or "").lower()
if parts.scheme != "https" or not host or parts.port not in (None, 443):
return False
return any(host == h or (h.startswith(".") and host.endswith(h)) for h in PUSH_HOSTS)
def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
return key.public_key().public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, info=info).derive(ikm)
def encrypt(
payload: bytes,
p256dh: str,
auth: str,
*,
salt: bytes | None = None,
sender: ec.EllipticCurvePrivateKey | None = None,
) -> bytes:
"""``payload`` encrypted for one browser (RFC 8291, ``aes128gcm``), as the
request body: header (salt, record size, our one-off public key) + one
record. ``salt`` and ``sender`` are for the RFC test vector only."""
ua_public = b64u_decode(p256dh)
auth_secret = b64u_decode(auth)
sender = sender or ec.generate_private_key(ec.SECP256R1())
as_public = _public_bytes(sender)
ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
ecdh_secret = sender.exchange(ec.ECDH(), ua_key)
ikm = _hkdf(auth_secret, ecdh_secret, b"WebPush: info\x00" + ua_public + as_public, 32)
salt = salt or os.urandom(16)
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
# One record, no padding: 0x02 marks the last (only) record.
record = AESGCM(cek).encrypt(nonce, payload + b"\x02", None)
header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public
return header + record
# ---- VAPID (RFC 8292) ---------------------------------------------------------------
@dataclass(frozen=True)
class Vapid:
"""The server's signing key pair."""
private: ec.EllipticCurvePrivateKey
@classmethod
def generate(cls) -> Vapid:
return cls(ec.generate_private_key(ec.SECP256R1()))
@classmethod
def from_pem(cls, pem: bytes) -> Vapid:
key = serialization.load_pem_private_key(pem, password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey):
raise ValueError("VAPID key is not an EC key")
return cls(key)
def pem(self) -> bytes:
return self.private.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
@property
def public_b64(self) -> str:
"""The public key as browsers take it (``applicationServerKey``)."""
return b64u_encode(_public_bytes(self.private))
def authorization(self, endpoint: str, subject: str, *, ttl: int = 12 * 3600) -> str:
"""The ``Authorization`` header for a push to ``endpoint``: a JWT (ES256)
naming the push service's origin, with our public key."""
parts = urlsplit(endpoint)
claims = {
"aud": f"{parts.scheme}://{parts.netloc}",
"exp": int(time.time()) + ttl,
"sub": subject,
}
header = {"typ": "JWT", "alg": "ES256"}
signing_input = (
b64u_encode(json.dumps(header, separators=(",", ":")).encode())
+ "."
+ b64u_encode(json.dumps(claims, separators=(",", ":")).encode())
)
der = self.private.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return f"vapid t={signing_input}.{b64u_encode(signature)}, k={self.public_b64}"
# ---- delivery (RFC 8030) ------------------------------------------------------------
class Gone(Exception):
"""The subscription no longer exists (404/410): forget it."""
class Failed(Exception):
"""The push service refused or could not be reached; worth trying later."""
async def send(
client: httpx.AsyncClient,
*,
endpoint: str,
p256dh: str,
auth: str,
payload: dict[str, object],
vapid: Vapid,
subject: str,
ttl: int = 24 * 3600,
urgency: str = "normal",
topic: str | None = None,
) -> None:
"""Deliver one encrypted message; raise :class:`Gone` or :class:`Failed`."""
if not allowed_endpoint(endpoint):
raise Gone("not a push service")
body = encrypt(json.dumps(payload, separators=(",", ":")).encode(), p256dh, auth)
headers = {
"Authorization": vapid.authorization(endpoint, subject),
"Content-Encoding": "aes128gcm",
"Content-Type": "application/octet-stream",
"TTL": str(ttl),
"Urgency": urgency,
}
if topic:
headers["Topic"] = topic # replaces an undelivered message with the same topic
try:
resp = await client.post(endpoint, content=body, headers=headers)
except httpx.HTTPError as err:
raise Failed(str(err)) from err
if resp.status_code in (404, 410):
raise Gone(f"HTTP {resp.status_code}")
if resp.status_code >= 400:
raise Failed(f"HTTP {resp.status_code}: {resp.text[:200]}")
+6
View File
@@ -437,6 +437,12 @@ async def apply_delta(
await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates))
await _emit_batch(results, account, mailbox.path, len(results))
if results and mailbox.special_use == "inbox":
# New mail while live (backfills never come through here): tell the
# user's devices, a few seconds later (app/push/service.py).
from app.push.service import enqueue_new_mail
await enqueue_new_mail(account.user_id, [r.message.id for r in results])
if gone_ids:
# Drop expunged messages from the search index (PG keeps its tsvector on the
@@ -0,0 +1,54 @@
"""push notifications about new mail (Web Push, RFC 8030/8291/8292)
``push_subscriptions`` holds one row per browser (or installed app) that asked
to be told about new mail: the push service URL it gave, and the two keys the
message is encrypted to. ``session_sid`` is the sign-in it was made under — once
that session is gone (signed out, revoked, expired) nothing is sent there any
more, so a signed-out device never shows a subject line again.
The server signs every push with its own key pair (VAPID). It is generated on
first use and kept in ``app_settings``; the private half is vault-encrypted
with MASTER_KEY like every other server secret.
Revision ID: 0022
Revises: 0021
Create Date: 2026-10-08
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0022"
down_revision: str | None = "0021"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE push_subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint text NOT NULL UNIQUE,
p256dh text NOT NULL,
auth text NOT NULL,
session_sid text NOT NULL,
user_agent text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz
)
"""
)
op.execute("CREATE INDEX ix_push_subscriptions_user ON push_subscriptions (user_id)")
op.execute("ALTER TABLE app_settings ADD COLUMN vapid_public text NOT NULL DEFAULT ''")
op.execute("ALTER TABLE app_settings ADD COLUMN vapid_private_ct bytea")
def downgrade() -> None:
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS vapid_private_ct")
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS vapid_public")
op.execute("DROP TABLE IF EXISTS push_subscriptions")
@@ -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
+120
View File
@@ -0,0 +1,120 @@
"""Web Push protocol: RFC 8291 encryption (against the RFC's worked example and
by decrypting as a browser would), VAPID signatures, the push-service allow-list."""
from __future__ import annotations
import json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.push import webpush
from app.push.webpush import b64u_decode, b64u_encode
# RFC 8291 §5 / Appendix A.
RFC_AUTH = "BTBZMqHH6r4Tts7J_aSIgg"
RFC_UA_PUBLIC = (
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
)
RFC_AS_PRIVATE = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
RFC_SALT = "DGv6ra1nlYgDCS1FRnbzlw"
RFC_BODY = (
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml"
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT"
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
)
def _hkdf(salt: bytes, ikm: bytes, info: bytes, n: int) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=n, salt=salt, info=info).derive(ikm)
def browser_decrypt(body: bytes, ua: ec.EllipticCurvePrivateKey, auth: bytes) -> bytes:
"""What the browser does with a push body (RFC 8291 receiver side)."""
salt, idlen = body[:16], body[20]
as_public = body[21 : 21 + idlen]
ua_public = ua.public_key().public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
as_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)
ikm = _hkdf(
auth, ua.exchange(ec.ECDH(), as_key), b"WebPush: info\x00" + ua_public + as_public, 32
)
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
plain = AESGCM(cek).decrypt(nonce, body[21 + idlen :], None)
assert plain.endswith(b"\x02")
return plain[:-1]
def test_rfc8291_worked_example() -> None:
sender = ec.derive_private_key(
int.from_bytes(b64u_decode(RFC_AS_PRIVATE), "big"), ec.SECP256R1()
)
body = webpush.encrypt(
b"When I grow up, I want to be a watermelon",
RFC_UA_PUBLIC,
RFC_AUTH,
salt=b64u_decode(RFC_SALT),
sender=sender,
)
assert b64u_encode(body) == RFC_BODY
def test_browser_can_decrypt() -> None:
ua = ec.generate_private_key(ec.SECP256R1())
auth = b"0123456789abcdef"
p256dh = b64u_encode(
ua.public_key().public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
)
payload = json.dumps({"title": "Zażółć gęślą jaźń"}).encode()
body = webpush.encrypt(payload, p256dh, b64u_encode(auth))
assert browser_decrypt(body, ua, auth) == payload
def test_vapid_authorization_is_a_valid_es256_jwt() -> None:
key = webpush.Vapid.generate()
again = webpush.Vapid.from_pem(key.pem())
assert again.public_b64 == key.public_b64
header = key.authorization("https://fcm.googleapis.com/fcm/send/abc", "https://ultimo.email")
token = header.split("t=")[1].split(",")[0]
assert header.endswith(f"k={key.public_b64}")
head, claims, sig = token.split(".")
assert json.loads(b64u_decode(claims)) | {"exp": 0} == {
"aud": "https://fcm.googleapis.com",
"sub": "https://ultimo.email",
"exp": 0,
}
raw = b64u_decode(sig)
der = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
public = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), b64u_decode(key.public_b64)
)
public.verify(der, f"{head}.{claims}".encode(), ec.ECDSA(hashes.SHA256())) # raises if bad
def test_only_push_services_are_allowed() -> None:
ok = [
"https://fcm.googleapis.com/fcm/send/x",
"https://jmt17.google.com/fcm/send/x",
"https://updates.push.services.mozilla.com/wpush/v2/x",
"https://web.push.apple.com/x",
"https://wns2-db5p.notify.windows.com/w/?token=x",
]
bad = [
"http://fcm.googleapis.com/fcm/send/x",
"https://fcm.googleapis.com:8443/x",
"https://evil.com/fcm.googleapis.com",
"https://fcm.googleapis.com.evil.com/x",
"https://google.com.evil.com/x",
"https://notapple-push.apple.com.evil.com/x",
"https://127.0.0.1/x",
"https://postgres/x",
]
assert all(webpush.allowed_endpoint(u) for u in ok)
assert not any(webpush.allowed_endpoint(u) for u in bad)
+54
View File
@@ -317,3 +317,57 @@ async def test_server_side_flag_change_updates_the_thread_counters() -> None:
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()
+2
View File
@@ -16,6 +16,7 @@ from arq import func
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
from app.outbox.jobs import file_sent_copy_job, send_job
from app.push.service import push_new_mail_job
from app.sync.actions import (
ACTION_MAX_TRIES,
delete_thread_job,
@@ -57,6 +58,7 @@ INTERACTIVE_FUNCTIONS: list[Any] = [
push_draft_to_imap_job,
remove_imap_draft_job,
refresh_account_job,
push_new_mail_job,
]
# Bulk work (§6): long timeout, low concurrency, isolated from interactive jobs.