From fda4cc6f16c7e508dc324ef9087c6d5b8216967e Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 8 Oct 2026 18:52:26 +0200 Subject: [PATCH] Installable app, push notifications about new mail, Install app entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/__init__.py | 2 + backend/app/api/push.py | 80 +++++ backend/app/identity/sessions.py | 7 + backend/app/models.py | 26 ++ backend/app/push/__init__.py | 1 + backend/app/push/service.py | 279 ++++++++++++++++++ backend/app/push/webpush.py | 193 ++++++++++++ backend/app/sync/engine.py | 6 + .../versions/0022_push_notifications.py | 54 ++++ backend/tests/push/test_push_notifications.py | 245 +++++++++++++++ backend/tests/push/test_webpush.py | 120 ++++++++ backend/tests/sync/test_secondary_poll.py | 54 ++++ backend/workers/worker.py | 2 + docker/nginx-app.conf | 9 + docs/nas-install.md | 27 ++ frontend/index.html | 7 + frontend/public/badge-96.png | Bin 0 -> 2430 bytes frontend/public/icon-192.png | Bin 0 -> 9074 bytes frontend/public/icon-512.png | Bin 0 -> 25482 bytes frontend/public/icon-maskable-192.png | Bin 0 -> 5322 bytes frontend/public/icon-maskable-512.png | Bin 0 -> 15222 bytes frontend/public/manifest.webmanifest | 23 ++ frontend/public/sw.js | 119 +++++++- frontend/src/App.tsx | 2 + frontend/src/components/InstallApp.tsx | 96 ++++++ frontend/src/components/Offline.tsx | 2 +- frontend/src/components/PushController.tsx | 33 +++ frontend/src/i18n/en.ts | 19 ++ frontend/src/lib/auth.ts | 2 + frontend/src/lib/install.ts | 108 +++++++ frontend/src/lib/offline.ts | 54 ++-- frontend/src/lib/offlinePrefetch.ts | 11 +- frontend/src/lib/push.ts | 145 +++++++++ frontend/src/lib/theme.tsx | 7 + frontend/src/main.tsx | 1 + frontend/src/routes/MailLayout.tsx | 9 + frontend/src/routes/settings/Settings.tsx | 64 ++++ 37 files changed, 1765 insertions(+), 42 deletions(-) create mode 100644 backend/app/api/push.py create mode 100644 backend/app/push/__init__.py create mode 100644 backend/app/push/service.py create mode 100644 backend/app/push/webpush.py create mode 100644 backend/migrations/versions/0022_push_notifications.py create mode 100644 backend/tests/push/test_push_notifications.py create mode 100644 backend/tests/push/test_webpush.py create mode 100644 frontend/public/badge-96.png create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/icon-maskable-192.png create mode 100644 frontend/public/icon-maskable-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/src/components/InstallApp.tsx create mode 100644 frontend/src/components/PushController.tsx create mode 100644 frontend/src/lib/install.ts create mode 100644 frontend/src/lib/push.ts diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 41e40c5..c27cc72 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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") diff --git a/backend/app/api/push.py b/backend/app/api/push.py new file mode 100644 index 0000000..39b4bee --- /dev/null +++ b/backend/app/api/push.py @@ -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"} diff --git a/backend/app/identity/sessions.py b/backend/app/identity/sessions.py index 085dfe8..5f772e3 100644 --- a/backend/app/identity/sessions.py +++ b/backend/app/identity/sessions.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index 522c540..8866de4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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``). diff --git a/backend/app/push/__init__.py b/backend/app/push/__init__.py new file mode 100644 index 0000000..d305148 --- /dev/null +++ b/backend/app/push/__init__.py @@ -0,0 +1 @@ +"""Push notifications about new mail (Web Push).""" diff --git a/backend/app/push/service.py b/backend/app/push/service.py new file mode 100644 index 0000000..f55aeb9 --- /dev/null +++ b/backend/app/push/service.py @@ -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) diff --git a/backend/app/push/webpush.py b/backend/app/push/webpush.py new file mode 100644 index 0000000..1c2cde1 --- /dev/null +++ b/backend/app/push/webpush.py @@ -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]}") diff --git a/backend/app/sync/engine.py b/backend/app/sync/engine.py index 0ea8662..2065831 100644 --- a/backend/app/sync/engine.py +++ b/backend/app/sync/engine.py @@ -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 diff --git a/backend/migrations/versions/0022_push_notifications.py b/backend/migrations/versions/0022_push_notifications.py new file mode 100644 index 0000000..dff4c1c --- /dev/null +++ b/backend/migrations/versions/0022_push_notifications.py @@ -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") diff --git a/backend/tests/push/test_push_notifications.py b/backend/tests/push/test_push_notifications.py new file mode 100644 index 0000000..87f6e5a --- /dev/null +++ b/backend/tests/push/test_push_notifications.py @@ -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 diff --git a/backend/tests/push/test_webpush.py b/backend/tests/push/test_webpush.py new file mode 100644 index 0000000..6a8cb50 --- /dev/null +++ b/backend/tests/push/test_webpush.py @@ -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) diff --git a/backend/tests/sync/test_secondary_poll.py b/backend/tests/sync/test_secondary_poll.py index 0adc8c0..26e4eb6 100644 --- a/backend/tests/sync/test_secondary_poll.py +++ b/backend/tests/sync/test_secondary_poll.py @@ -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, "")) # type: ignore[arg-type] + assert asked == [] + await sync_engine.apply_delta(FakeProvider(), account, inbox, delta(4, "")) # 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() diff --git a/backend/workers/worker.py b/backend/workers/worker.py index b59564e..ed3755b 100644 --- a/backend/workers/worker.py +++ b/backend/workers/worker.py @@ -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. diff --git a/docker/nginx-app.conf b/docker/nginx-app.conf index d44eccd..ee492fd 100644 --- a/docker/nginx-app.conf +++ b/docker/nginx-app.conf @@ -83,3 +83,12 @@ location = /sw.js { add_header Cache-Control "no-cache"; try_files $uri =404; } + +# The installable app's manifest (frontend/public/manifest.webmanifest): +# re-checked on each load so a change of name or icons reaches installed apps. +location = /manifest.webmanifest { + default_type application/manifest+json; + types { } + add_header Cache-Control "no-cache"; + try_files $uri =404; +} diff --git a/docs/nas-install.md b/docs/nas-install.md index fd010d3..3fff490 100644 --- a/docs/nas-install.md +++ b/docs/nas-install.md @@ -569,6 +569,33 @@ Session cookies get the `Secure` and `__Host-` treatment automatically as soon as the app sees an HTTPS request: nginx forwards `X-Forwarded-Proto`, and the API is started with `--proxy-headers`. Nothing to configure. +### 5.3 Installing as an app, and notifications about new mail + +Over HTTPS the web app is installable (Chrome/Edge: the install icon in the +address bar; Android: *Add to Home screen*; iPhone/iPad: *Share → Add to Home +Screen*). Each user turns on **Settings → Accounts → Notifications** per +device; new mail reaching an Inbox then shows a notification even with the app +closed. Desktop Opera shows them too but has no "install app"; Opera on Android +can install. On iPhone/iPad notifications work only from the Home Screen app +(iOS 16.4+). + +- The server signs notifications with a key pair it makes on first use, stored + in the database (private half encrypted with `MASTER_KEY`). Nothing to set. +- Set **Public URL** in the admin panel (e.g. `https://ultimo.email`): push + services see it as the contact for our notifications. Without it they see + `mailto:postmaster@APP_DOMAIN`. +- The API container must reach the push services over HTTPS (port 443): + `fcm.googleapis.com`, `*.google.com`, `*.push.services.mozilla.com`, + `*.push.apple.com`, `*.notify.windows.com`. +- The notification shows sender, subject and the first line; it travels + end-to-end encrypted through the browser vendor's push service. + +**Later: an Android app (TWA).** The installable web app is what a Trusted +Web Activity wraps (e.g. with Bubblewrap from `/manifest.webmanifest`). It +then also needs `https://ultimo.email/.well-known/assetlinks.json` naming the +Android package and its signing-certificate fingerprint — served by nginx +like any other static file. + --- ## 6. Resource usage diff --git a/frontend/index.html b/frontend/index.html index ee58609..fda1954 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,13 @@ + + + + + + + Ultimo Email diff --git a/frontend/public/badge-96.png b/frontend/public/badge-96.png new file mode 100644 index 0000000000000000000000000000000000000000..638eb2467c0b2dec4997762632287883c8e08b9f GIT binary patch literal 2430 zcmV-^34!*BP)7Z9Xv7!9Xkud2G)j!c z2ZV$~Nl0kqA*iV3p;8jV)ADF5tyn;yP@uJ9OYhA2{A1Rpo5S4RIp@sGJ!d>$GMTwI zGiTOX-`?xnd!4=aK#dwTYSgGvqehJyHEPtTQ6mLOjp+5!k}i?7M$%?UTO{2rX{Drh z_WH~i_}`3PpCjo;Nl#0fl=NRo8zlWi(z^%lf4GpO{Um)`(l$+vU6QVnblA9%5P~o1 zt&(ojzOh@0*Dt&$^~Jrg$Dg-InkT7IMH1X@le8cT{-mZy@c(gdxP^Pc<$l3e z)IWj|H&nG3L$|3FnHK^r ze{EU)@W-ceetsqqZ2d&!M_#NbLcnPSg4giRog~SCPK5~QxXtf!9~wFR2Ut{ufW!NI z-@aPW8-dwoCU>gr>Br1W!2ZD80u3Ui*&j)i zD%2BZEAydT)?8q@sU)^%zgx%3B2SEwo4lP7O)98#mugE z+dIQdpFbfGNyJ_P?ZV3CC{MPs%F;l6%uA< zUj#lM*)d9S4r03P*jZmCJzTW^WHnW?9*bZx? zQz`;_i1Qy@7uF3~oMW-Q6*$?<9v+q0mWqHL;=Iwz7dLr;20ipkBC%Zse8kLN7^T>j zl7N&r?=Z8ofgkYt5^zzm4Yi$%%Yr0*p{Av9^!ll@IK(ajN&ZsH7(#~ zAL(qEX|b&Y0X@X|A9U;RtNRq^kXD+&56$c>Guu%Hv8@yV5eW@5`;VEO3w+lD+iRU^ z7-9y^_^g?IB`h&7gVU&zHm3)CGW@4UA>(ETg>dAbca!;&G(fi zAet{U%q;QQ^Tt<2z_&q_Ip0^FfS?Oez{9$x(oC$zo`5tFBgVirWf&rCj4x&lnzZQ% z*pq|#Im2o#CBTt6KJ#)B0=DPqO>a)kO2+{~*pJJQIYI;6R)m0OGPLH9dge=-Ta`_D z(?-!Z?MZr5M1W-=U`r7KHf1Cr0S*S^qLm7E&vW;?^64IGr{A9QjnQF?C7g zbV$_>ZHkVoInmGl$#Pszc;9x{j$+-=gZE&MW4Zc0FXuYH)j(!m&6f0N)S&vs zq{r&on9RJ!wEX$v9H&Ob@Bdb~UYSYoBihU#zs`ABQy}ZKK@boQt()iPrX@K}&I%-a zE$UUx5(d8&_0;QfHn@5DoJPf|Yn7x~QEO|?WRLL1(2D*(M$(^h`Wg3@ z^I}p5l@pKlLrOb2dO>X5eUd(qZhcNhW0-_Qn$}7k#yTPCbJ3Zod4r!wdf2bm7-(+v z=amHvOy|>EB%LW~|0x31^gs>b(X*#{YF!~|-4r(FNwsHW$^F)E%klRf1Uy3bW_Xr~ zNqUkJpCz#w_#<#T-Cg!inAz?j^?-wc`M`UC57J%qb!b#_Zse?Ab$tGFlbN0E3j;a> zBOoy9ld6vbH?nu_WnRQvgaX>Y)4-F!Q^50dcUJBKCNmboW_k5;E-;Vo&6J~f{TXh( z?8sAq+z!0m?YDk!nOfFVc0i8DSOn z1aOT9UwMO{n}8GvkI~&=x7H0C>e&i2h%j1e0V~bylflm-i+~Hw)*=#~@t$x6d)M%{ z$E?+dQ&i43v#&>MQD?rfrG8`PJW0<^@yilbLXw*C-y!LQ!WGUT*I7cz;s{As_bA9E z6%71VNf)K7rseIguPt&dmUL^6nn|Z*WP}D1_tt+)x=hmHaUri7Ni$XRm$XFEYX6m` zG#Tv~ASC?o&}5Gk`J|-pNjj!iqZ%}91ieAgn*IeobXyLbI7L}PDcfuSA{syUDnIN5 z{sLSJ++t?iBm0wK3ayPWExl$Fg`~FuOMp{=g>)D7PK>CN?)16KJwB1V%gp|@FMBIZ zKx(wqNY_gw9RM5)oB$l}<&7hO!|1LxJ;2A`{o0%Or0`{62i@J1PXK=h9tGBiJ!NUz w@{|*5)TmLTMvWRZYSgGvqehJyH7bJt0q>u@kbQmViU0rr07*qoM6N<$f=OSW@&Et; literal 0 HcmV?d00001 diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..971a11bb0fb05dba089918ebce91575428e3e0cf GIT binary patch literal 9074 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 BYI6Vp literal 0 HcmV?d00001 diff --git a/frontend/public/icon-maskable-192.png b/frontend/public/icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..facbea5f4711b0511b06350d1f9a81f85ed65342 GIT binary patch literal 5322 zcmd5=<6&_3XRqm=HWQ|S)rI2uF{MM7zil+Hsqx=T_(LXeaaq&rkPg`*B6B#)4T zgS(gCykFiw;hov|u(La}v%AmEJaI+_5Hey$VgLX@rmdxJeD8bzQxL(uHHNzq0sz!$ z+UhDM0n0}pgTGpuhK?xpwEPR?zDg&@4b(|B(+V$+;CR8q2NI;lp^<-nfLFe}uLrK3 zb%~*9nIV*W?u^uSK6+vO(VRvjO4T_F5}@8k$d_`e!^XqTo)XLwTz`4M8eEi51>?Gc z2YbJpxDIvrXLq(F9bA8huDe9vEqK&&S5?sury^>INU6f#1_jlyeEg_=7JR=s4=6r~ao8mYv&r6-;?0&G{XU8#$J_Tk zDQW&A#3+BJM7OGzAE)+yS&tq3(%{|1yq3KkJgJc_U|k-w@uW6inDcjlH-=sEa2iG8 zNHniy+mZeRDcIPdm=9Ga!*jE=_W*0m zxd6#rb;C~z*6zYHs`by`)zWhG5C7Q!os;-)GOA;l!qNx_y_7YK5b?^u(hj4@ea_4 ztdL;xs<#+g{jTOr%>9iV8d^(K%!?avO8D$UO0ogpBAa5^fq`}4R0xz!u_4}A!$=yx zw17{Yz3%&``uxvO-hQdo6o&@!a)oQ)*k?3>Ji*%S#)NYQKe_2Bb?6N{m9j9%HlO|P zT-mWLMrCX1a~~<*a?%_eS5C5?mycc5)m4(N67uwPZe=XILS_6rxm3r?B%aA3?2VM_d4ro{Bk#rH#X|p8Lk#gwRvr!K3E$nn zp5@uQ3D)ij)_zJOr?vK`+uT5U4YCaPJu^x}-ax5(jed8AQ>k-u=#Zv17xsCyxxHSO zEz_a(Ghli2fY+;1p%gttcx|W?wZh^h2!+wP)eq*r`14N96+s z5ciHwbk(w=wdV3jIr+?L)COgWk{y25B8KVB0+C5I3-{P+$@?y5Q5m3N%U@8!M7htg z>F6kKfwzW&NvoR+r?RlI)X@A;D2|hRUe9mgMQT&;YO8a_%P#V1ElMF8G1_J3AlIgko$>jKk)R}S2WOys$y+|01 zi#=$J6!BOoQR%Nhtn)NMSh~XZr2n#%cSDb2MG)3E28cuMwtPiBLANQ;^^c0MMHQc9 zb5oEwEt?}&!F}yr?3)nBM9byI$7pGs3z3ooKcFXAA$yyo!?qWE>4Mnf=zplF?0cxq zsGmFc?q}hHRKUd3YPo(mm8TAiu|jr~TeubRj&_VM*7)im&*et$yGM52XPbMuH?bIPXJN8Zx)cRau=9 z9K#4~0{Y7)-6YL?My!!G^CtJnaFH(qB7N24R&o%R{)~R(J4SAuM%5xh2`$!t6k}|8 zdCei?*LL@>WzTqD7y$<0t6c^>FUa8)FN=>IVOMog@f!hs zVsIuU!<^O#m!VQrXH7AkmkONWkfYG$H&ybjC0xTy5&E^G!zXPBphk-aBU)R0kPO*H z2(_mN+>Ly}{3Vn)P$iA0THQUze;PHYGA7?Ymc3yZ__J+AYV%Gm@;+-1SO0XNCDOKt zZTFVucg>KA`}aJF1iR_YC4Zr(>XlirUuHzRA>=)`reH_rw$ND?JOu7#Ao&v_9G}bD zgdm0_uKECy&0adTb@VHjml>Mm>RJ0N#3eE=_7M8ql#Wx*Hhf*)jT1w){2V~gA6%S< z1Of88HV;Zxi~H`bA5Y`aQFaG1%pV;%H*-){Kma;nEULAL*z=ST%Tso{i{dY$7(aQx zjJ1}d>0o+1J&y1KpGi~TNaJh%vk%pvb2B4B-ayYM^Wq(kwB(m8gJ{o&4Y!At(q8-5 zYt9O-%mSyX>3e-GnUuH-btsrSrXxK)Zh)IPP3O6`mxKHQZ?!y4I(Z9%L@|=A%IS74 zDL#z?Q{?!4ZC@C03OQx|?PBrYOxRROTdU=%(2j|#XAt>OvFkn>JjYT(4}TorPfRQ< zI$1FVryb=q<96d?$ie53Y=~Av6ljdL))jwfsdN+-F3R~Dv->40N+bw$T=H;}7OW5m zh58@cN#KxbQL3u92TpB0x@OLd(@dsBx1Xk{RWeB{9jw@kUPiEC=@ zfQd;S2(LXpw%xi%oO%FH7QyOBceQt*MQY~jF)?_${Up+0IdUe#0J;Vg;o=NG7$EaV~C+w5Ly~qbFJGaG!NiMGJqpcWjSma@wPn2y@KR%6|uW1GYzEbqs zgnzOPdG*zZnSgQ&W*hz_&i)+G)fcGFt+Z*Us^;&0YhM5A>fzpweiR_YOKf5-h1md@ zUUmOT6<#vzLUsDZ*k6{k42BlemKg z)zaixrv5{Sm(JS1+QF8TjPfX?VHucvU2JCD(g}C(eyu#Fo2vM)j??*?aWk375L<7F zgXx8EoDz$50`|;Inv~6(b)~EO;Qib@;e}67{c7hm8pR3RAw(0rgVylbY0d}F2>6N; zKuX`KPBj2e`lLuzN2zI99qwyK3R|gihR?d|1aRhQHqQHRi^52!JHuU$r4~ju3OmNk zaY_s-?@Yklu;<2of{hf#5TPkty9Jd@?)@KRE@e8`(o9o?P}4HC=rZ!pOM-InK>0t< zb#(D__izF!D6=={1T-C9k;9pO(AXhKD6)%ZhK|Vt3Ig&LtrxFW(E2r3lZFG@&;YQg zX_+n^rJ1T#TQfG}&E4&3*#5g=>Gr%0ZuS{h2UPG0x8lucK+BFYRl*u};Z>#kvX(gC z%o_b$4`E*?qfrh_8&~;nvK5f&_e*Q(vKgL4W=&y6xSuY-J#U1~I9o9z^4*I>= ztr>`>uhr|&4b_P-eAH%}5@TL_0?}VQQ7}JC&*rGhH2me$RQ=LuMuHN1_jqq-{5y!i zjSGZRz*%FZe^$ym>x6I7$~!>F#)sK{Mrbp4h`ud?Uh_AbzhwF4+fe#mojpO-84`I| zHFG;ebk+$|vHF@5fJ!sT z5L^Dk$ENayu>4}Gw5+J7{YA4FKvX4mL!sTC1esx$e(03y;DvCW{Du`~InhPU8brR6 zrORI0Y3mwy0hBjQ9)fn-S47MjwG@8x)tc-7i-Pj|Ic30OZ>AM~;RZl%#|aSYA?e%F z`A)zg-`|Sy1Ncq^Lp?5klrJz-i@ZaTaxs+%L%Fo!_gIanAn2TR{AJfYZ)s=$g31+GHWIXbqeBYHcxHZdypr>VYK2O`c$Tc_DTkwO-= zVbj`dBtP*?*WN30xkB{H64B8BeL~Y4Wovenh zx8-UstD5qz(^^$ti|zl&e%8J{jyH>b7!6o%2=|7WRwIkLqc!!;HCQt48lsZsb+~O} zVdanw!(fK965F)Mhe2VS^ZbgEje?E4lrLiU@p=cJuO8hBO8hw#xFdlcTWAzqhd`=_-nSC#hFoKIh*dYQc zwBwMSUl9x~`_K_L`6;seDac|;PgraKc3`yql1n>|UM%nFSjs}qZSXFW6G+mUy>sgl zC2q)6xd#0ToC-@y%GX{;i#kbYw{#5f7p~JyaAy@~hKhx$8*}QWO;gC60^l`I>A@-v z3sdii(t-P*TN8<$bzCLVz`i~QQungdnPglVr<;zXuViV2V^ie%$Z-9X4%Oc?Xy-08 zK#VX`MPvoYhaUiPmY=YeQ9XaF=WOF)U!YwfM{V2M+aQB1b>=nS<5Qkz-ar-fId%S_ zgJ^om)LJqZ-^_7c#Todij-m=glkaYT@Xf!yUSRh}F1$;r36)q>7Vm7k2U$!0aT&|2 zQR>C&L{{{Cp@gQ>mmLOK+%0;In2<#zwF|pJ1UM@c z)%*dyC7AJLs=Pa+7;b(!*R3}z(w+^KQozp6rMM$t@sBesLloVIM28a3Uf1O@3D8x} zLTRVKzWwD`t+OS9VH*;Wo07e=fKc;E?k*P?c9rSMog$j@5g3WK6v3Km9is5 znB2o${}%4A<=0G4B4|ha*Z#)Eh`O)ZnBR2lIde30@*p+PJ9+{YqYF4nV_RR$Z*%8F zHZ7*Z?_OJ}8Znzak{gXK!paSpY5ja~wIhzCtA!7lGRyILRsu!biR2xPx5)fUt=lPj zoS9xOaQMo3s!rMLcI2IhlN-n~`Xf)+V_1v}D1VDKx#H4uP&8LFwl2o4cSfO(RxxN{-=4+FA=Ro4RK-kphF_YhGa2SP38PKh^UHSt!PY3`0c z!cNw;_xFRW_Gry3J&J^svW6UHsh4P{^@!M!_BMKPj~|R!1`r=N+d_ZRF#G8NqEwvb zkZVS*i_@%kxLt1~H`gHNKPKs&q7sel47rGP4s)x^I5g}|h9BPXB@Cb`0sBt)*!Vx5 z3khB$;01Qn=EbExAlTkC>b*}Je)|sp#R3}Cx(W$-4W_vqQVD)fE^eGOgnNTvNdjlM zW>_UicFRU-?f#99HT!fJVwEoVz(!N9Bl?w>%)7tuw#zuvUW)s7XXkC^19u5ZDL6cQCtTsMO}2=SFK?PUW?=Q->pm4C}V|AyHJ3W2#7!d=St`^FUj{!1b7 zMzyr94{Q)YSnm98vNtJ}@}iB?2+^cend9)3Z+@(yt8&p&tYS+P>OyKe7wl9@bSbf3 z2#aIcHoG+|2RdYWi8~fIIUsh&RcV=l2|FAKyBy~ZYULL-IXvGEa%S0io%`pW@r`lrxIcEXMzYsjbItn9x%Pf-WblBAk&_Vs045#n zyCwiY4GyURhI8Q8e#jUJ094g=?rNEZOmEGFw%D0R9_;S;Rz}9=gz`OS(T<9G5q0r; zO1#KIloE60o#V%KxEIT81CLpmC<3i zvlbrPI=*w{yc6zw-8435CD$LaGqKr%r@rmCtsGuGA511rd*w7Y13<@{e-0S{fDdHo zKg|6X2AXHIXu<2hFo*)l03d(Be`7!e(qREF;J+|9QzYLRt^dM+7SI5x{I_;_M(4k^ z%QHHB;PwB0mzkI7c&UQ2g>{6ch)2VUbNt?x0`mbm5q&DFgXgj`P%Nv7%8cj?ZU6Kh z_G@A}&9;nPou^qHe{vNYNr5@evpdZ?unGCzU_0^l10j#Syx!JCJj#@OI%)QJ@O|Qz z@PN9}vyEXC;%6^0JasJl;2m7y@6-Odyd)}B+e1mS80+Mw&DHiVCa^vPNTdkgCNGA! zU}HkU`syE8klR<eh>eqX<{wJ~wGHqFopt>ilvrr)+s@r%~$^U5&K$ zuf?}2mUADctS7ibd!-Jr`!{uNX5N0sTE16~Ym{?`PoaK_T*JFeJulJOqNDX-0czsh zq})=>XW`Fc@Jtjjhdpn$&^v+rWdUif>LD8+;)5+N+upnU=_6^6D(&D1pKFN)#t>yD68&fOxY;b?% z9W4{Ij1`*Xf1F+Y^l+5zZP@!K!%!|y1#@rSV zIY5Q=ltEv2br53?Cy&{FQettx&?Vn~e&3;weL7y?+j@7S zJPG-12$QTcXOJFOW4hi?HXJDP|9IK1mThc@u1jDrg`1LtqKi{=JfPwO9O>Q3n2Vhg zcG8g)0hFp#x<2+z^Q``w6nYL4Aw(~mGCj~___V_V_4BmtNzd>>IHW_fPlwUCZB=Tu z4^nuDmezCQ+-cI+Dwgmd#o7g@d=y=&SgR-n?#Rm=7jQj^U4S6*CBDZ_})3+!v^L?qRkk;H(h+T*DWe-cGlgL(w}* z4lWm&D3jez&e8W6Iwjm{)Q@qMce{s}deF^tP{K%mZAg#`)Z{WcBVvANg`@r6GqOC^ zM+e`N)o0JCx7l7TuUc*!k-YRTTqq}eVda?STm`9b&Xqm2nxq$l)DR1XDrWWX{f-p& zF_EduG1)LCPC|?#_N#qI1Nms9fGB#=Q;&fzyf39T-Y;6kZVMmS>yb5_=}{q>)&*m9 zFK`%eW1zZPv*f^L4<(Hklz&;B$IM|dFIrn|Pa5D*V=b!zE3H-jA=76+Y><7AZ4GFs zfSDwA^KkXbQ;v<12Hi621&-7Mvdsmp%0FVxHj5Q|Y*4l5EKy%YPwmOsQW4Qh(rv}t z>^=VZ>4cl3BTTP~i4vyPVX;e7ys3}$Ocnoq0RV2N{9<+opW;55=|*lT~bP?8$}rVm?lzKl$x?h~*QF zMdh$0Lx#+rKQzWCNGe>cv>0rM8+-6j)T=NaF0U_`rh|tW9_6%`v=ASd@A7thWtT;q zPIOVi*O>!$tKGtM@+wW94Y}%ake@|z`Ls@)V*oN=3s|-;>=4CWGFtAnn&@sbDr82# z=)o8kXR7vcoaRStHep$a{lTwWBeq?vUg<)}gu|8=_dGkh;XeVtNk$>(-{TJ;Vh$Alb2o9ADR|Qc((`v@S(6>_D^=GZ1v z8)-4%R<^=P0vwf*6KV0P(op;0$VkyBt&5{DqFKhE52eaFF9)-^{=9V^zHY#mlAKy~ zG=p`&m`Y0x+)s`XAt?0to}f%7;_%ZHnjWF9V)6OE)hW*MGFw!dCZrw?gp!IOM%EBZ zwCjq+;Oii)+;ZSV%eK`=@^xC|Paj%|6isLUZC6B2n`l~Im?R(yj4B@|nfMmrB{Rf= zN4sCT>-eSImKB*h)aWynOe-5oB^{p!G1KE3 z(^F7cY}7DsDM<5a)z?*T^95Hl@kQgOdAq}lqFfUB+L!TL=e!-8s5P6Gb;}q8 z6g{)u3E@&RHTRG53;sr40c4VwC)E|Z>t_+aS>{Oxg(#h=w4Ii69?X~Y*BhLINavn$ zJ7I)G;zC}rmGMAv--P|Z!9$=!q<6E*6z%HQHGI6-zl!Lan76H@so|5NaSU^#XxRs? za`~D^Zub9qdpVf=Y9|+ndVj5No80qf{BAHIMcmg)*xRmQV1WrY{@i@E@ArL#12zby{eoqXh=v%qG|S*K$JN%f`3HA%#w825m%2SYQift zVmZ-=5}E*T&LZ6}=tv!6k&kXSBf7iVSVIQqKrL8>G`49)-E~yh#G1jbn|w_gb>7X(!KPIzZrM=sy4u z0LYBYVmWx}YM=(n87>NUz33kY{-OfTU9Wx`^5|8PLstA_F{L+J^Z?B*F{X#~g>&yz zTFafC?$1zneoJMO8&6iz&|}yC(4Zsg^;eZj4)_B&2GAEfh0w`{J}Ga1e{OT4~jDdc<;5jAzbBue*5euA9|xMZTbn@*Z#OUXm9e zd!Ed55vTwC_B%CE9@5m5OV$Yo86e2+g# z>*Z+Eac&Jqc0D&>=*X{1(qczg^qEzK<;M%R)ryf$`MM~ub?yW}3+Uh*sEXTA&o-Zn z%4N04y{>*>s_Q@zs8gIRG!X8-#Ses69V&j*V^1{*%ZtBaZ^2#4+%PFUN(E-m0HD~H z{hyC3R!(0TkNh>t_6HiuBah)_&2`i8ysqb&<4upXwqnf`*2HrO1~GREj5uIO-@Wij zNYo{;?3s*En;7WT&}Vl1+QC6dFUv)nEPCvi zTDD-nRv$nnYxU{DDidep6rcjk!~%^gRUss=G>!_hlksMZwt66`SW_5iw!ScT!?bSF zcWT)zckfb}I($8zvar)~y7B+&2jnwqPv(<*aWc?c4#q4c7W216prym$k43cuQcOi}U6j#t+P2umH&24*2&OH$b(x?8n;%iko+nUeD$UNidGym$W+< zw8g{?fN_dQe6LZf^b{VmPKm)U4jCcYT-*X^+*bM_k1Jez{nNYi$?wY#*Kc?qTy+39 zdf$DF3w5a@fKm(UWAdH)s$tq1Z^aK+UZ_D;8(PZoelB$~EB5E1l#eh9^wIzzimIFaN&*dfNwoT1!7-1O*V`YQLd?0B1nf@cTr4Dso4k0J7!FvA*lBZ9 zBQrSVjy(C_Vs|QB?SXQgzhlJrl(cneC}|{T>~~nRih90?Qap-|ZOv2e1^#@s(egfY z;^&n8vcYni#ijBj z1Mv?a-s|}*ij_a*Kvhry0?KHfZ&}){O26EsO?FTA%MSVDO>-pF)2D9tI_*!m ziv7k-y8Pz_ZD6?k*5B;{cEF%2SjG5`sB=dhQv#h(Y__?3KS3KPKOCn^)**j)j){|B zw@4tr7=b+y*I2egh3XwV+${lPU{|@Y<8@kXd@ieDSS@a9C#7M%Y;Hl}t5b?F`*EGV z2ga1m{?K@;1%dBh(*U@m{#Cs-_;&{2C05sY(q!hcUV^vme@qgOJd*VEwJtK4u)L;p zG46|g;zIn*Qnd|?m;pR=?w@W{q{T@i6AP#Wer0hiOHDx9w`!Q}s#Zz)+$9qRzjNKs z^N*6`^~duiAv;6mO9ki~>o?oZR`Z$kp_!fF??68pkLX%&Gu&vGGTO|RF3myN3i&rt zMaw^9;`E|VLtioq8EepVu`(c_fu*$9i2}(12B6QKdg;sQcwk{<{*|+sUY< z*FzbSO4>(|-&H}rMJnngS89^n$?TsjWJS0`^W#feU?_iCqXaVkU2eDmSr~A9`HX9! zsx`0dCu3Zv!FzjrhOR?I!t^G*%AmylT6afdhF4mdlewl|2}Qcg)ZUh^uC*3D?~@lC z0D#4A+cxzts>x8$RynHQwXZnqI>0ify`+pAmI*)nZ2y@QkGs#{$zkF-_&)DgnDK!c z%+tMH%XXrl6Kn@*(mT?+@dq;0j6dmGr!@w42$FdZ9CM+DT%v)H0Hre&!H4@0c3SFiqeqUS=64wW( zHlEywac{lnH2#8LY+omNuPdMcU(6E#- zPg=wSh+)@Mb>4JaRvsBFiGo7pT!lM4!gGW$s`Nk7gnR9Y&k*qNoauv;mw7ZLI_YKq z-bm8gc-@XXoLq-~_2T0=R-X-B$I-re{*7qa;ZtzWI6vjvHySp3k`uz*y?ib{na=gx z*jzSfMbQz&+fx_j|1XW|K=V6lp>aBwE?Eae zz%~~fr*|S-aX%KzjfD94A%lo|ak*mQ8*yyu6)QS0Fs$nL>EDgkxKhh+pZLV2RDmfz z3098(wkk&1;^v$|z3qC=ta>Czmy(@W7_d|=l26FS&abQG%2IlGCBWbahUK+fD?+gr zJ#W$9OHG7O)lo+hm)}h@q7YjBb05Fx|0ALSXjd8j@#&xQ7y^tdJr26H5SkIU z()!{O5VgS|i|Kq>F>Dqw&Xr~ekFma4${^%!r&)(~P%Up?dCDeD>2}$Lz9M8Zo*_`C zqRPqhPYYf~d6w^NGa80ujeJ{0Nw+&}`5{#h1Kq-mOaXFaJ6TnG=#mQ?KrC++x7gXnoj7jA40mtZfn2`voTP?vf#tG=E-in+9Peey8<1j zIGF_#(flgK8%_N;Y^gKLxTBmx5?B6(N?%g@dl?0dxVwsffSh~lcL5_vHSwp-YzsFb zrC&Q`p|O*MTyl^ma%~JmD(#%P7^TVkmA$|W`aL2 zS639)x|t~lzs9-lK$W(0%U2-+Zuzsa$LBv9 zl)bm)c(>_WJ9kOh`)Gd@oFXG zt)M!Yr&l@Xn?C-CFO*TRIcoH^c(&H}DHlmm8nr&n4F>Cfpg8Bz<%PnyD2W_(!$sE} z3zgF>PsN0=XA6HLe0^f32MXmVnp4&74vUkro10`OH(2#8rU`1>e%1=A%rd(ur_gFy zDcZ3F8rS%tLvBPDec2)Qr%MmUKjUZtCSva}__*OkFHMqh#8)oYJ@%xvHI3$Js)byz zrF9&n@Gblrju-Cv%u$V?+?&8`LRyDFo6(K-*D}v-OSq(T%G8$7lXWta7> zou?^LsXfnej?n#GKlk-hNxzPti{9+?JkOGs#37$P&+~gKR$54^jHTlc+n9gbd3&mThxBpv1EC%lpNKE_+c zqF}n%h5i$$S12GMr)Uf-%iNYb>8Lh0y1D@y5? zU{;nj7(c#+#t(%&WHY#xK>h=!E+9(XQtyeUT}Sz{OEoEKmcA<9 zPn|c3SfeD80N(k$ITQnFgl zlO>%1Rn^OH81u)TaP-C%eER!hqUXn2&1BM9EdiVplF0%QE3bb&J~=wA{gA`6n%tO* z@PRz6WUjGQFW!N1oVRtW?TyVJW7HaF(Df9_+r8d}wl^Uwupe)QF`U&S2k6ThvG2_w z`e2Lm(+M1AbQ*oZ-2PO4Y5;${n+jn23@dxtjbHXUt-e(8 zeh>L*u0jlZZ|{_1nE%Hd>c#lRi#+~i5l+86XB_5OY3ovU07 zKp{A&M280(WwOreza%+o?UxIBP{@yjD?H@B@%ZWg)g0{pfkB8l!=``1FCL(15GDggb1J zdqS(bmAX^Kr{pN1bjRY^?EiJU{7)?X*NZ{af5XnX+9-@OQCS!|O&Zi=GYN!)Qg0K# z3?}?DT$N+8+rV0L5e?W9OW|}**4J2=xUT-7=Oex~7;0$Zt_;MY==hG*gLT+f*G!0C zVGZn`*s0E{mE?hJ!JcyVW0wBHleWMagW{;g1S&7z>VGPjiAFYw=Y?{bqofnT8*gn5 z*ztv$8+pG=D0da5Uv1tgrx*4UiKk4s$k!6q$P$k z-Uf$S=~)e~PBBS-RAG8yf?DR{=qbtaAp)Nl+?@h#pt`gl>YkkSO0iYnjsu$6xGyy8~Vg1wE-doHk zu{YLC{5&LFR_hwXC9H4L1D&Hlw-tNQNqqs5?ZpptABs7Eo=+QFTrvKLxjy z9=k|?HuBM?8v*4KZELnUQ;Z*iSyN=K*gBJea(QvsD3eJRa_>n;v(NCy-lK#S^cw2C z;#tuoHIOAfU{g9dWi467)2Xdqw>1i%r;8^d%q~9;@Tl zJ7H_ZT+{h9-&Is^v4brl5>&)KM;orNYzCkwBP0Z&p<5W!XmObuG(6+>JKw2_fX+bO zR+H^ek-@q#X@o2NF881em|s2iQe5eEMEhFX6hjN?%}m?%@j1T9^TA0^7MDDsb($<4 z08f>2-|ctRZTGie4$JH=p&h(55~ib{U|#x_U3y3VYygg*XgXlW$Yo=utca!I4jK{) zC&o5LtO(e`5bld*Nnef*^|8X7N>_jROmc5{UT~C?_3_?RvMc7g)A$tV!yjc?gQrgD zP=d$E%gbZ_!Sp2a)}IlWU}W8e*-Nj!=AJ??cumX;J49BJ$Y#>k+_=3yb?uvCJbs5` zLsX#EK8-bTD7x6kT*X+{^|f6i%Z_aC0PAGJhmT_}T+Ko_c8n#FB&)xeRpyQbBZDYh zhk*(h7+27r#fUgiY=?3PMrv^=CMMvQI|Z{9h4^T#rJLbk&0ODaBJ)_lcZ2Q7VdFOcC2 z(@CF1FKepQ$#f9pLd}pnCHbV@=F?`Cc}Ye&mwD_$Zgxn_10FlFL`5Y(;P@Rg1c+Fu ze{3s&X)}5lVE6Sr2!VK;3A1H3JK&;rja2)|j%O^NRcSSaSs}wEA0w_eRsLWM6Xgf8 z{pJt#OM$nk$$Fy91NvQTn9qfHNZXC9T4_GwGqy_J6aV8%Jg@BspX!S3b61o1%3O^@ zhfQK!Z7E<08ym5Vf%?Q%nFuRjWVqr30Ya)nE6kyZqzgI)njN6I43H>9I{ATA$%}?u z>UXV-MR)?`CMf5DOb?ojft->ro@w!uzkXHFbeohF#_EybWNI3AVxFz{Ns4ca)*=>( z*+@eEBE&j{~22ypNaYj8oZ=dFs{mvJg>MrGuvI9pYlE^OJb{OvhA09V8} zcl;A>#NQM5=QLE&R5nwkw(PL`frT^Ge-`Fn9{;Wq^?*CWI=i95xarU)fT{-SPQI1mJ6*bOSYQfS4dYr^RE7%;2C8m%8?a!V^{4q*qc0ePt05!s)AwB>3wL7ij&cNva$jnfg@^51ZbT!~{llv{UDa=6C z;h$x!nC2M=v2|3~htJxn*Ya-Em+6HB* zkUz;ho0+f4Rb?NO;2bFzaM4TubqZ=uh&~FGNx5QhzBT{j$+e+$w2Eulk6>wN6ZRN& z)x-T!(%?y*Yr;s024Y3y)AP|AvTP-}w+Q~vfvA740)FY)4`Y_!LSH44ZbdO+`cjbn zRr`tsizWauH>`M;ykCNuMVYtaP)@2))^cL|y_jXgNuTQ=268J6=@so2#WYiUmf8!I zWuUWj8cI(#+ui*6^xIQET{X84cyA50Ll|T~CNoboUccZemyIfVn=WZ>7Q!m==w6ja&w zGKL;X(R2&?L#0wATvwCG2>f(&IZKBGeY5q6V3~wDY(;u(w#GPbh4D^^Pq!em@lAHd zk~}f_9CM{L5~LHtO9dK)xCF!qcDU#%pN;3=qR!Nh%;3yv5P=_3AEj5CcQ_gI1=SxK zs{L8NQed`RoFi}VMtAT*O?svH9m1yikAdbO=a5yG z^tK7_6X#7e4a?oK*qlB2-~CC*bNFQp6_4ZZkis3!mPl`7Sg zygd=g^7r&YYQtOT+@IDZ3_wUoqJP21Hg_wYM6Z~1yXCU#FKZU7 z!EMkD5-$|ZUllWGTNM`9UKp|I-MbT3ojq}Cx$J7FaDDOD9iunKyJhY@(~@qKFOPu5cs z5rH3yFt}HOd{$ujSCM+XHj9&AWFW6-jsCZ2jy#fQ_dJN%qv-Ws5~{oW3qHVf1xD%K zeR_v(674ZHIcb&#Eo&K1;3{8&)KXs)$n}Xt9UiYq>J}Vwbs0w7Z*xV~r>}Oig2_$$ zo&@WV`U8dVAi~P+cnEk77}pF?)~V&;`_A4EUF+^FbYFan5itJw=!4((wJE)P0T;M*S~oev7BUah*|HwWN%L{745lwgW+P}Vn!-b_(;d3 zjm*(Y*}umIJ<;5*dW+W{APK{9j-2Azy3&ztOQZQMbArrZqUF9sHMd8pwuagl?T+yd zd__&4w?cC=r46$V!ajZrUDr-v_Zht`<7RkAVR=5NtGRk!gwh)$*6#N{Thyy%J||No zX=QizO$j+;}h%Iu|%G7{T7whb8UQbmzjfE>nqwiF+FT6Js!;? zt-AgiMP~PQFk_&Jw%czwf}Q+6PHjJxTuqR&aQ1?>!VG)#6~5(Yg)(p^7;gl_?(BX} z`H$7lZBp+;$e?%aa(izbutmJ2Hg@q>MH+0y<`T|*VS#hSDkxYQN2*td>0 zY0^m0J*n!(hp^$`)HeUd?7aM5RCPXdW@(avIoLACn$W;Kb=uQ1{#ZLVsqax@Kb@ms38OVh#r~ zOisXaOu58P<-Qw%^ZW3zmLnqeyoZK={YYMz(2FrS(I2<*6~A+>@I^^R&rWVKKB!8T zoS{5VnEYJ? z=jEDL*h+E3Ug5gUQFoXvEK%B?4ZNTKG#;;z*QGM%q_1W2lwy`f#&)=0k+zsoA7MEM zklNC*R#?ZN!t+00Vd)?!o|x-}w3~x`jTrh`P||1Qim}B0T-`wbs&BW6|Azl8w)tf# zkqSFljW2=3PMrmFS^h#w7TjVP(q6ec%hfYO9qhA!adGGSf@Dj;=KBW+_O^Xniovhy znO7)TTDO@kF3_y*t&2+J3fI|OGrdBJJR&8Iv~s2TG~Nk1woYr9t@@kLY*y85<9rXz zcwV$DBPF@KH9Pfc6DrG%jfr5OVm&ZHvA}pU_MhqEvtgtiHUoR|RTX0!UaeB*BE8Wg zW8^Es@~~HEufpOLW~svn5+Wj)m?PSsBWcs3Y2BjjG#ueEOg;4?6cy_4>hIR~(;5|| zH}0uh=@uv*%)mU&&Rb}q$;OI?a)}a1CMC$(4eez+k3Nf|*oTy$g$-m*Ejz6iNW4pYP{V;3NH`v%&g|<0sUYGo1G10Cr9H`#s%!+rnUi z=1)lUByxRZy)plD$WzAL>#h)Eds<{@Lq*VnxZbLd1NH#JXzb+Y-lftVl0RGAuwUV~ zWLN>IF3)_uy!rKeVLh8}b$HBDneZ}1j^x^Sccjw z%d_o-4pcck5FVT^EW274EZ(}BMQQNDBYAsDeA$d% z<95;<_qRscsJViCvxIjMTk5*-fVOX>MC{7lCfcBeLp{&9($9`n@dqbuxG1vaK0Vm1 zr~z>oip(M2vYXbs70Y=uo{Q%BTsNki2cI${8Kc;S5<9(Oc%K%`q=ottj4gPTjUh^b zUzS-|{_N(tj!W$Dloxa{*Tq^k%;S?n$5xK$z=qnwYb{-?E}aK`umeF)Ya^%klJ-$~ z?*!?x7!y$Q;i6r<;TMCc9Ftx~9ez(S3kvgu;R-PFrue(UK**ORUz5}_C*#v;2jgcy zoL5|v*=D!T_$)T)Fg1i8*$kEO&C=D4GHR)xA<8D?^b>y4+ znvGEK4Vc649GC$F3e{i9vu)W=+Y2MSWPmalGs&(_v}t!Kmy8fW0@-o2M}+~(Ry>y#qS z4BHZecJU0f5=&9|B(iOw`mvZg*(GK^tX|@NK92tESxd9W*ylnUdmOq2q<9yTiB@}# z4yV`e|8-j(9wQdsIu|pcX0!Wm2{i7D>>elyS$SIR+gawG&(wBJQ2R&`}6@e3T#QNo`ZuDLIcok&@fA^!psQndV z`))wjx>}pYVB>F4JbwX!^)bQc0ffX6hX~`{x8azL;7O&co$sB^)g2LYkCE_0D#rFb z+b)4i%n9a7eVb5h=rj}8PE6?Ig=Der3akI<)xP zdvPHe$qQt#GaU4R%Vqm5ko8XH)raW@t%t+m@Pips74;1KJ**HS|+3Vl;Ud}{K|BaJtSC@n@6%hbjiEk zoxbz40)$15Ljg2$jUKv9f9QMR#}DPVXG>AIGTvMQ)nsQ(yY<{WzL13x(Rq*o+q%|e z`nDpvyzk+y__ICbGf`{&Ha{IXNNxY?Ew4Zv6K@tX->LrQl(><*OK&QW6aTU7^Zd1` zgrQV*@(nA7g9{~~aZRSww@N%cI5!1}d_bnO*bd|)Gn#a@0%&W(<1FW}zP)DOi%EMn zj6*697pF>k*51zTDA9te+2hXX&)yQzIT{RKr9@=mp=~Ai=j$8gIyk9tvv1~IJ#CC$ zF%i~S#qtBQaZ@F|?EPgIAmN~^02e!6Rc`T=FEz|po~(+ewg;tg=7je)R#&;}2<`Q+ z$IxFr|4=F=e7wR)o4?#vuK4d8QWK6^ke?rU z@8H*Zf`s7AJWXz@fhAEOtUmNMrse(~ai;S8a+T9LAUIg{xEF;FGXm~U6!S}2i? zQ8XNeWW%Y33vr-1T;0$$9y)}1EF2WfiMHeKukM^$h7u$_i}`xe{L89J#FSi8QqR-U z+Vb>0Xq%K<|Fpd|nSwqJi@cvp>bKz+dwDxqbTvPM5`%KK z5Xp`Bgqytc32|YYqMq}N83OtukHp27TToaq3D4z|;t-7P^_tLD{QY66ZGiEEF$Jze zwk@Bn*Tm$#@T5=~p!3A`F~6Hk{`Nd_urw05+f&X)a>a`7C>p9nWG&UkiSYzy2K+fS zX;vo0J)FJ#?S)1Zc(PsdddaDY5>#!|j0XilUQtMcwAD2QJpI|~N!W9Fp6u>wh+VIE z64>78V-9WIw2ixngk|xUEV(=fPg`8Ru4W(F+73TVWN~4yKa$W3w28rW+(UIN@!DH-pN|sNOrT??-q+ijswxT zW*ukt&8pkvzgI)KSy0LAVqV>wUegEjHot}De=KafJhs?r{@dZ9tMDGt&nhWmNe(SN zRhttuk>QCy_>vCEV)`52=-D)nti6rsWcL{T5cdD4CKYJ6+ zG59Z^*Z<#MZUdF}|L{tj=Gm|RUwrTJOb`F}-w5P86VLzlz`f?#um5L4YhI(!Jgt5L U=++t|$!Gq_uI+wsN!08jhGF#rGn literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..0434241 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "id": "/", + "name": "Ultimo Email", + "short_name": "Ultimo", + "description": "Your email, calendar and contacts.", + "start_url": "/mail", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#f3f4f6", + "categories": ["productivity", "business"], + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "shortcuts": [ + { "name": "Inbox", "url": "/mail", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] }, + { "name": "Calendar", "url": "/p/calendar", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 6bd968d..1def2dc 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,12 +1,18 @@ -// Offline access (Settings → Accounts → "Offline access"). +// The app's service worker: notifications about new mail (always), and offline +// access (Settings → Accounts → "Offline access") on devices where it is on. // -// Registered only on devices where the user turned it on. While the server -// answers, everything comes from the network exactly as before; each answer is -// also saved here. Only when the server cannot be reached is the saved copy -// served instead — marked with `X-WM-Offline: 1`, which tells the page to show -// that it is offline. Saved mail is the newest Inbox conversations (fetched by -// the page, see lib/offlinePrefetch.ts) plus the ones opened recently, at most -// MAX_THREADS; older ones are dropped. +// Offline access is on when this worker was registered as /sw.js?offline=1 +// (lib/offline.ts); registered as plain /sw.js it leaves every request alone. +// Turning the setting on or off re-registers under the other URL, which the +// browser treats as an update, so the change takes effect at once. +// +// With offline access on: while the server answers, everything comes from the +// network exactly as before; each answer is also saved here. Only when the +// server cannot be reached is the saved copy served instead — marked with +// `X-WM-Offline: 1`, which tells the page to show that it is offline. Saved +// mail is the newest Inbox conversations (fetched by the page, see +// lib/offlinePrefetch.ts) plus the ones opened recently, at most MAX_THREADS; +// older ones are dropped. // // Saved mail is wiped when the server answers a request with 401 (signed out, // session expired or revoked), when another user signs in, and on request from @@ -18,7 +24,9 @@ // What the page expects this worker to understand (lib/offlinePrefetch.ts // WORKER_VERSION): raise both together when the messages below change, so a // page never talks to an older worker that would not answer. -const VERSION = 3; +const VERSION = 4; + +const OFFLINE = new URL(self.location.href).searchParams.get("offline") === "1"; const SHELL = "wm-shell-v1"; const DATA = "wm-data-v1"; @@ -413,6 +421,7 @@ function isAppPage(url) { } self.addEventListener("fetch", (event) => { + if (!OFFLINE) return; // the browser does what it would without a worker const req = event.request; if (req.method !== "GET") return; const url = new URL(req.url); @@ -454,7 +463,9 @@ async function fetchForPage(url, port) { self.addEventListener("message", (event) => { if (event.data?.type === "purge") event.waitUntil(purge()); - if (event.data?.type === "version" && event.ports[0]) event.ports[0].postMessage({ version: VERSION }); + if (event.data?.type === "version" && event.ports[0]) { + event.ports[0].postMessage({ version: VERSION, offline: OFFLINE }); + } if (event.data?.type === "fetch" && event.ports[0]) { event.waitUntil(fetchForPage(event.data.url, event.ports[0])); } @@ -465,11 +476,13 @@ self.addEventListener("install", (event) => { // turned this on is never reloaded. event.waitUntil( (async () => { - try { - const res = await fetch("/", { cache: "no-store" }); - if (res.ok) await saveApp(res); - } catch { - /* saved on the next visit */ + if (OFFLINE) { + try { + const res = await fetch("/", { cache: "no-store" }); + if (res.ok) await saveApp(res); + } catch { + /* saved on the next visit */ + } } await self.skipWaiting(); })(), @@ -479,10 +492,84 @@ self.addEventListener("install", (event) => { self.addEventListener("activate", (event) => { event.waitUntil( (async () => { + // Offline access off: nothing may stay saved on this device. for (const name of await caches.keys()) { - if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name); + if (name.startsWith("wm-") && (!OFFLINE || (name !== SHELL && name !== DATA))) await caches.delete(name); } await self.clients.claim(); })(), ); }); + +// ---- notifications about new mail --------------------------------------------------- +// +// The server (app/push/service.py) sends { type: "mail", title, body, tag, url, +// unread } encrypted to this browser. One notification per conversation (`tag`), +// replaced when more arrives in it; the app icon's badge shows unread Inbox mail. + +/** A window of the app the user is looking at right now. */ +async function lookingAtApp() { + const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + return wins.some((w) => w.visibilityState === "visible" && w.focused); +} + +async function showMail(data) { + if (typeof data.unread === "number" && self.navigator.setAppBadge) { + await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {}); + } + // The app is open in front of the user: the new mail is already on screen. + // Not on Apple's push service, which withdraws permission from a site whose + // pushes show nothing. + const sub = await self.registration.pushManager.getSubscription(); + const apple = Boolean(sub?.endpoint.includes(".push.apple.com")); + if (!apple && (await lookingAtApp())) return; + await self.registration.showNotification(data.title || "New mail", { + body: data.body || "", + tag: data.tag || "new-mail", + renotify: true, + icon: "/icon-192.png", + badge: "/badge-96.png", + timestamp: Date.now(), + data: { url: data.url || "/mail" }, + }); +} + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + /* not ours to read: still say something, browsers require it */ + } + event.waitUntil(showMail(data)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = new URL(event.notification.data?.url || "/mail", self.location.origin).href; + event.waitUntil( + (async () => { + const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // An open window of the app: bring it forward and let the app go there + // itself (lib/push.ts), keeping whatever else it has open. + const win = wins.find((w) => w.focused) ?? wins.find((w) => w.visibilityState === "visible") ?? wins[0]; + if (win) { + await win.focus().catch(() => {}); + win.postMessage({ type: "open", url }); + return; + } + await self.clients.openWindow(url); + })(), + ); +}); + +// The push service replaced this browser's subscription (rare; Firefox does it +// after long idle). Subscribe again with the same server key; the app sends the +// new one to the server the next time it starts (lib/push.ts). +self.addEventListener("pushsubscriptionchange", (event) => { + const key = event.oldSubscription?.options?.applicationServerKey; + if (!key) return; + event.waitUntil( + self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).catch(() => {}), + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f6140d..de70d94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { OfflineController } from "./components/Offline"; +import { PushController } from "./components/PushController"; import { useT } from "./i18n"; import { isOfflineError } from "./lib/api"; import { useMe } from "./lib/auth"; @@ -50,6 +51,7 @@ function RequireAuth({ children }: { children: JSX.Element }) { return ( <> + {children} ); diff --git a/frontend/src/components/InstallApp.tsx b/frontend/src/components/InstallApp.tsx new file mode 100644 index 0000000..6339731 --- /dev/null +++ b/frontend/src/components/InstallApp.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { createPortal } from "react-dom"; + +import { useT } from "../i18n"; +import { hideInstall, promptInstall, useInstallWay, type InstallWay } from "../lib/install"; + +const STEPS: Record, "install.stepsIos" | "install.stepsOpera" | "install.stepsFirefox"> = { + ios: "install.stepsIos", + opera: "install.stepsOpera", + firefox: "install.stepsFirefox", +}; + +function InstallGlyph() { + // A phone with an arrow into it. + return ( + + + + + ); +} + +/** "Install app" in the mobile drawer: the browser's own install dialog where + * there is one, else how to add Ultimo to the Home Screen in this browser. + * Hidden once installed, when opened as the app, or when the user hides it. */ +export function InstallAppEntry({ onDone }: { onDone?: () => void }) { + const t = useT(); + const way = useInstallWay(); + const [steps, setSteps] = useState | null>(null); + if (!way && !steps) return null; + + const open = async () => { + if (way === "prompt") { + await promptInstall(); + onDone?.(); + } else if (way) { + setSteps(way); + } + }; + + return ( + <> + {way && ( + + )} + {steps && + createPortal( +
setSteps(null)} + > +
e.stopPropagation()} + > +
+ +

{t("install.title")}

+
+

{t(STEPS[steps])}

+
+ + +
+
+
, + document.body, + )} + + ); +} diff --git a/frontend/src/components/Offline.tsx b/frontend/src/components/Offline.tsx index 074c750..c390fa1 100644 --- a/frontend/src/components/Offline.tsx +++ b/frontend/src/components/Offline.tsx @@ -94,7 +94,7 @@ export function OfflineController() { const current = useOfflineNotice(); const wasOffline = useRef(false); - // Install (or remove) the service worker to match this device's setting. + // Install the service worker, with offline access as this device has it set. useEffect(() => { void startOffline(); }, [enabled]); diff --git a/frontend/src/components/PushController.tsx b/frontend/src/components/PushController.tsx new file mode 100644 index 0000000..fe690b9 --- /dev/null +++ b/frontend/src/components/PushController.tsx @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { closeThreadNotification, resyncPush } from "../lib/push"; + +/** Notifications about new mail, the page's side (signed-in pages only): keep + * the server's copy of this device's subscription current, open what a clicked + * notification points at, and close a conversation's notification once it is + * open here. */ +export function PushController() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + + useEffect(() => { + void resyncPush(); + if (!("serviceWorker" in navigator)) return; + // A notification was clicked while the app was open (public/sw.js). + const onMessage = (e: MessageEvent) => { + if (e.data?.type !== "open" || typeof e.data.url !== "string") return; + const url = new URL(e.data.url, window.location.origin); + if (url.origin === window.location.origin) navigate(url.pathname + url.search); + }; + navigator.serviceWorker.addEventListener("message", onMessage); + return () => navigator.serviceWorker.removeEventListener("message", onMessage); + }, [navigate]); + + useEffect(() => { + const m = pathname.match(/^\/mail\/t\/([0-9a-fA-F-]{36})/); + if (m) void closeThreadNotification(m[1]).catch(() => {}); + }, [pathname]); + + return null; +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index af64b9c..3ba56ed 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -597,6 +597,25 @@ export const en = { "offline.settingSaving": "Saving recent mail…", "offline.settingUnsupported": "Not available here: browsers allow offline access only when the app is opened over HTTPS.", + "push.settingTitle": "Notifications", + "push.settingHelp": + "Get a notification on this device when new mail arrives in an Inbox, even when Ultimo isn't open. Shows the sender and subject. Turned off when you sign out.", + "push.needsInstall": + "On iPhone and iPad, add Ultimo to your Home Screen first (Share → Add to Home Screen), then turn this on from the app.", + "push.blocked": + "Notifications are blocked for this site in your browser. Allow them in the browser's site settings, then turn this on.", + "push.unsupported": "Not available here: this browser can't show notifications from a website, or the app isn't opened over HTTPS.", + "push.notAllowed": "Notifications weren't allowed.", + "push.failed": "Couldn't turn notifications on.", + "install.entry": "Install app", + "install.title": "Install Ultimo", + "install.stepsIos": + "1. Tap the Share button (the square with an arrow) in Safari's toolbar.\n2. Choose Add to Home Screen.\n3. Tap Add. Ultimo opens from your Home Screen like an app, and can show notifications about new mail.", + "install.stepsOpera": + "1. Open Opera's menu (⋮).\n2. Choose Add to → Home screen.\n\nOpera adds a shortcut to Ultimo on your Home Screen. To install Ultimo as a full app, open this page in Chrome and tap Install app there.", + "install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).", + "install.hide": "Don't show this again", + "install.gotIt": "Got it", } as const; export type MessageKey = keyof typeof en; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 26b7c79..495aba2 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -2,6 +2,7 @@ import { type QueryClient, useQuery } from "@tanstack/react-query"; import { api, ApiError } from "./api"; import { clearOfflineData } from "./offline"; +import { disablePush } from "./push"; import { wipeLocal } from "./scheduled"; export function useMe() { @@ -28,6 +29,7 @@ export function signedOut(queryClient: QueryClient) { queryClient.setQueryData(["me"], null); void clearOfflineData(); // mail saved for offline reading goes with the session void wipeLocal(); // and so does anything written offline + void disablePush(); // and notifications: a signed-out device shows no mail queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); } diff --git a/frontend/src/lib/install.ts b/frontend/src/lib/install.ts new file mode 100644 index 0000000..2da0c79 --- /dev/null +++ b/frontend/src/lib/install.ts @@ -0,0 +1,108 @@ +// "Install app" (the mobile drawer). Browsers that can install the web app +// themselves (Chrome, Edge, Samsung Internet on Android) announce it with +// `beforeinstallprompt`; we keep that event and show their own install dialog +// on a click. The others only add a Home Screen shortcut from their own menu +// (iPhone/iPad Safari: Share → Add to Home Screen; Opera, Firefox: the browser +// menu) — for those the entry says how. Imported by main.tsx before the app +// renders: the event can come before React has mounted anything. + +import { useSyncExternalStore } from "react"; + +interface InstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +/** How this browser installs the app, or null when there is nothing to offer. */ +export type InstallWay = + | "prompt" // the browser's own install dialog + | "ios" // Share → Add to Home Screen + | "opera" // Opera menu → Add to → Home screen + | "firefox" // Firefox menu → Add app to Home screen + | null; + +const HIDDEN_KEY = "install.hidden"; + +let deferred: InstallPromptEvent | null = null; +let installed = false; +const listeners = new Set<() => void>(); +let version = 0; + +function changed() { + version++; + for (const l of listeners) l(); +} + +window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); // no mini-infobar: the drawer entry offers it instead + deferred = e as InstallPromptEvent; + changed(); +}); + +window.addEventListener("appinstalled", () => { + deferred = null; + installed = true; + changed(); +}); + +/** Opened as the installed app (or from a Home Screen shortcut). */ +export function isStandalone(): boolean { + return ( + window.matchMedia?.("(display-mode: standalone)").matches || + window.matchMedia?.("(display-mode: fullscreen)").matches || + (navigator as { standalone?: boolean }).standalone === true + ); +} + +function hidden(): boolean { + try { + return localStorage.getItem(HIDDEN_KEY) === "1"; + } catch { + return false; + } +} + +export function hideInstall(): void { + try { + localStorage.setItem(HIDDEN_KEY, "1"); + } catch { + /* shown again next time: harmless */ + } + changed(); +} + +export function installWay(): InstallWay { + if (installed || isStandalone() || hidden()) return null; + if (deferred) return "prompt"; + const ua = navigator.userAgent; + const iOS = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + if (iOS) return "ios"; + if (/\bOPR\/|\bOPT\//.test(ua)) return "opera"; + if (/Firefox\//.test(ua) && /Android/.test(ua)) return "firefox"; + // Chrome without the event: already installed, or not installable here (no + // HTTPS) — nothing worth offering. + return null; +} + +export function useInstallWay(): InstallWay { + useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => version, + ); + return installWay(); +} + +/** Show the browser's own install dialog; true when the user installed. */ +export async function promptInstall(): Promise { + const event = deferred; + if (!event) return false; + deferred = null; // each event can show its dialog once + await event.prompt(); + const { outcome } = await event.userChoice; + if (outcome === "accepted") installed = true; + changed(); + return outcome === "accepted"; +} diff --git a/frontend/src/lib/offline.ts b/frontend/src/lib/offline.ts index 56a8150..8850e57 100644 --- a/frontend/src/lib/offline.ts +++ b/frontend/src/lib/offline.ts @@ -133,33 +133,43 @@ export function setLastSync(ms: number): void { emitSetting(); } -/** Install the service worker, if this device keeps mail (call on app start). */ -export async function startOffline(): Promise { - if (!("serviceWorker" in navigator)) return; - if (offlineEnabled() && offlineSupported()) { - try { - const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); - // register() does not look for a newer sw.js when one is installed - // already; ask explicitly, so a release reaches this device on its next start. - void reg.update().catch(() => {}); - } catch (err) { - console.warn("offline access: service worker not installed", err); - } - return; - } - // Turned off (perhaps in another tab): make sure nothing is left behind. - const regs = await navigator.serviceWorker.getRegistrations(); - if (regs.length) await disableOffline(); +/** The service worker's URL: its offline access is on only under `?offline=1`. */ +function workerUrl(): string { + return offlineEnabled() ? "/sw.js?offline=1" : "/sw.js"; } -/** Whether the service worker is installed and running (it does the saving). */ +/** + * Install the app's service worker (call on app start, and when the setting + * changes). Always installed where the browser allows it — it shows + * notifications about new mail and makes the app installable — and with offline + * access only on devices that keep mail. Registering under the other URL is an + * update the browser applies at once (the worker skips waiting). + */ +export async function startOffline(): Promise { + if (!("serviceWorker" in navigator) || !window.isSecureContext) return; + try { + const reg = await navigator.serviceWorker.register(workerUrl(), { scope: "/" }); + // register() does not look for a newer sw.js when one is installed + // already; ask explicitly, so a release reaches this device on its next start. + void reg.update().catch(() => {}); + } catch (err) { + console.warn("service worker not installed", err); + } +} + +/** Whether the service worker that saves mail is installed and running. */ export async function offlineReady(): Promise { if (!("serviceWorker" in navigator) || !offlineEnabled()) return false; // Not `controller`: a page loaded with a hard reload is never controlled, // yet the worker is there and can fetch and save on its behalf. + const deadline = Date.now() + 10_000; const timeout = new Promise((resolve) => window.setTimeout(() => resolve(null), 10_000)); const reg = await Promise.race([navigator.serviceWorker.ready, timeout]); - return Boolean(reg?.active); + // Just turned on: the worker without offline access may still be the active one. + while (reg?.active && !reg.active.scriptURL.includes("offline=1") && Date.now() < deadline) { + await new Promise((r) => window.setTimeout(r, 200)); + } + return Boolean(reg?.active?.scriptURL.includes("offline=1")); } export async function enableOffline(): Promise { @@ -176,9 +186,9 @@ export async function disableOffline(): Promise { /* ignore */ } emitSetting(); - if ("serviceWorker" in navigator) { - for (const reg of await navigator.serviceWorker.getRegistrations()) await reg.unregister(); - } + // The worker stays (notifications), without offline access; it clears what + // it saved when it takes over, and so does this, in case it is slow to. + await startOffline(); if ("caches" in window) { for (const name of await caches.keys()) if (name.startsWith("wm-")) await caches.delete(name); } diff --git a/frontend/src/lib/offlinePrefetch.ts b/frontend/src/lib/offlinePrefetch.ts index a695299..69fe7cf 100644 --- a/frontend/src/lib/offlinePrefetch.ts +++ b/frontend/src/lib/offlinePrefetch.ts @@ -19,7 +19,7 @@ const COUNT = 50; // the list's page size, so the first page is saved as shown const PARALLEL = 3; const TIMEOUT_MS = 30_000; // The message protocol this page speaks with public/sw.js (its VERSION). -const WORKER_VERSION = 3; +const WORKER_VERSION = 4; const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g; // What the app asks for when it starts: without these it cannot open offline. @@ -51,8 +51,13 @@ function ask(worker: ServiceWorker, message: object, ms: number): Promise async function upToDate(worker: ServiceWorker | null): Promise { if (!worker) return false; try { - const { version } = await ask<{ version?: number }>(worker, { type: "version" }, 2000); - return (version ?? 0) >= WORKER_VERSION; + const { version, offline } = await ask<{ version?: number; offline?: boolean }>( + worker, + { type: "version" }, + 2000, + ); + // A worker without offline access (the setting was just turned on) saves nothing. + return (version ?? 0) >= WORKER_VERSION && offline === true; } catch { return false; // too old to know the question } diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts new file mode 100644 index 0000000..3a5f0d3 --- /dev/null +++ b/frontend/src/lib/push.ts @@ -0,0 +1,145 @@ +// Notifications about new mail on this device (Settings → Accounts → +// "Notifications"). The browser subscribes to its push service with the +// server's key; the server (app/push) sends there when new mail reaches an +// Inbox, and the service worker (public/sw.js) shows it. Per device, like +// offline access: the subscription itself is the setting. + +import { useEffect, useState, useSyncExternalStore } from "react"; + +import { apiFetch } from "./api"; + +export type PushState = + | "unsupported" // no service worker / Push API (or not HTTPS) + | "needs-install" // iPhone / iPad: only an app added to the Home Screen may notify + | "blocked" // the user said no in the browser's prompt (only the browser can undo it) + | "off" + | "on"; + +const listeners = new Set<() => void>(); +let version = 0; + +function changed() { + version++; + for (const l of listeners) l(); +} + +function isAppleMobile(): boolean { + return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); +} + +function standalone(): boolean { + return window.matchMedia?.("(display-mode: standalone)").matches || (navigator as { standalone?: boolean }).standalone === true; +} + +function supported(): boolean { + return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; +} + +async function registration(): Promise { + if (!("serviceWorker" in navigator)) return null; + const timeout = new Promise((resolve) => window.setTimeout(() => resolve(null), 10_000)); + return Promise.race([navigator.serviceWorker.ready, timeout]); +} + +async function current(): Promise { + const reg = await registration(); + return reg ? reg.pushManager.getSubscription() : null; +} + +export async function pushState(): Promise { + if (!supported()) return isAppleMobile() && !standalone() ? "needs-install" : "unsupported"; + if (Notification.permission === "denied") return "blocked"; + return (await current()) && Notification.permission === "granted" ? "on" : "off"; +} + +/** This device's notification state, kept up to date. */ +export function usePushState(): PushState | null { + const v = useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => version, + ); + const [state, setState] = useState(null); + useEffect(() => { + let live = true; + void pushState().then((s) => live && setState(s)); + return () => { + live = false; + }; + }, [v]); + return state; +} + +function b64uToBytes(value: string): Uint8Array { + const b64 = (value + "=".repeat((4 - (value.length % 4)) % 4)).replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +async function tellServer(sub: PushSubscription): Promise { + const { endpoint, keys } = sub.toJSON(); + await apiFetch("/push/subscriptions", { method: "POST", body: JSON.stringify({ endpoint, keys }) }); +} + +/** Turn notifications on (must run from a click: browsers ask permission only then). */ +export async function enablePush(): Promise { + try { + if (!supported()) return pushState(); + const permission = await Notification.requestPermission(); + if (permission !== "granted") return pushState(); + const reg = await registration(); + if (!reg) return "unsupported"; + const { public_key } = await apiFetch<{ public_key: string }>("/push/key"); + let sub = await reg.pushManager.getSubscription(); + // Subscribed with another server key (the server's key was replaced): start over. + const key = sub?.options.applicationServerKey; + if (sub && key && b64uToBytes(public_key).join() !== new Uint8Array(key).join()) { + await sub.unsubscribe(); + sub = null; + } + sub ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToBytes(public_key) }); + await tellServer(sub); + return pushState(); + } finally { + changed(); + } +} + +/** Turn notifications off on this device (also on sign-out). */ +export async function disablePush(): Promise { + try { + const sub = await current(); + if (!sub) return; + const endpoint = sub.endpoint; + await sub.unsubscribe().catch(() => {}); + // The server forgets it too; if it cannot be told now, it finds out on its + // next push (the push service answers "gone"). + await apiFetch("/push/subscriptions/remove", { method: "POST", body: JSON.stringify({ endpoint }) }).catch( + () => {}, + ); + void (navigator as { clearAppBadge?: () => Promise }).clearAppBadge?.().catch(() => {}); + } finally { + changed(); + } +} + +/** On app start: make sure the server still has this device's subscription + * (it may have been renewed by the browser, or dropped by the server). */ +export async function resyncPush(): Promise { + if (!supported() || Notification.permission !== "granted") return; + const sub = await current(); + if (sub) await tellServer(sub).catch(() => {}); +} + +/** Show the unread Inbox count on the installed app's icon. */ +export function setAppBadge(count: number): void { + const nav = navigator as { setAppBadge?: (n: number) => Promise; clearAppBadge?: () => Promise }; + void (count ? nav.setAppBadge?.(count) : nav.clearAppBadge?.())?.catch(() => {}); +} + +/** Close the notification about a conversation once it is open in the app. */ +export async function closeThreadNotification(threadId: string): Promise { + const reg = await navigator.serviceWorker?.getRegistration(); + for (const n of (await reg?.getNotifications({ tag: `thread-${threadId}` })) ?? []) n.close(); +} diff --git a/frontend/src/lib/theme.tsx b/frontend/src/lib/theme.tsx index 25c649f..de747c3 100644 --- a/frontend/src/lib/theme.tsx +++ b/frontend/src/lib/theme.tsx @@ -14,8 +14,15 @@ function systemPrefersDark(): boolean { return window.matchMedia("(prefers-color-scheme: dark)").matches; } +// The installed app's title bar / phone status bar: the app's own background +// (--app-bg), following the chosen theme rather than the system's. +const BAR_COLOR = { light: "#f3f4f6", dark: "#1b1e20" }; + function apply(resolved: "light" | "dark") { document.documentElement.classList.toggle("dark", resolved === "dark"); + for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { + meta.setAttribute("content", BAR_COLOR[resolved]); + } } export function ThemeProvider({ children }: { children: ReactNode }) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6a3537a..399c6ee 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { I18nProvider } from "./i18n"; import { isSessionGone, signedOut } from "./lib/auth"; +import "./lib/install"; // catches the install event, which can come before the app renders import { ThemeProvider } from "./lib/theme"; import "./index.css"; import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3) diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 56dacc4..e65a357 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -20,6 +20,7 @@ import { } from "../components/compose/windows"; import { DraftsList, ScheduledList } from "../components/list/DraftsList"; import { Icon } from "../components/Icon"; +import { InstallAppEntry } from "../components/InstallApp"; import { MessageList } from "../components/list/MessageList"; import { Resizer } from "../components/Resizer"; import { ThemeToggle } from "../components/ThemeToggle"; @@ -34,6 +35,7 @@ import { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; import { api, isOfflineError } from "../lib/api"; import { clearOfflineData, useOffline } from "../lib/offline"; +import { disablePush, setAppBadge } from "../lib/push"; import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled"; import { formatClock, getDateLocale, setTimeFormat } from "../lib/timeFormat"; import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; @@ -283,6 +285,11 @@ export function MailLayout() { }, [timeFormat]); const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota }); const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts }); + // The installed app's icon shows unread Inbox mail (notifications set it too). + const inboxUnread = countsQuery.data?.inbox_unread; + useEffect(() => { + if (inboxUnread !== undefined) setAppBadge(inboxUnread); + }, [inboxUnread]); // Folder/inbox views paginate server-side with an accurate grand total; search // results (a single fetched page of hits) still paginate client-side. @@ -985,6 +992,7 @@ export function MailLayout() { {!collapsed && t("nav.settings")} + {mobile && setDrawerOpen(false)} />} + ))} + + +

{t("push.settingHelp")}

+ {state === "needs-install" ? ( +

{t("push.needsInstall")}

+ ) : state === "blocked" ? ( +

{t("push.blocked")}

+ ) : state === "unsupported" ? ( +

{t("push.unsupported")}

+ ) : error ? ( +

{error}

+ ) : null} + + ); +} + /** Settings → Accounts: keep recent mail on this device for reading offline. */ function OfflineAccessSetting() { const t = useT(); @@ -563,6 +624,9 @@ function AccountsSection() { ))} +
+ +