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>
55 lines
2.0 KiB
Python
55 lines
2.0 KiB
Python
"""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")
|