Files
webmail-client/backend/app/api/push.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

81 lines
2.5 KiB
Python

"""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"}