feat: web push notifications for PWA #233
This commit is contained in:
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||
|
||||
// PushSubscription endpoints have no meaning in demo mode - the demo client
|
||||
// never makes real network calls so there's nothing for the relay to push to.
|
||||
async listPushSubscriptions() { return []; }
|
||||
async createPushSubscription(): Promise<string> {
|
||||
throw new Error('Push subscriptions are not available in demo mode');
|
||||
}
|
||||
async verifyPushSubscription(): Promise<void> {
|
||||
throw new Error('Push subscriptions are not available in demo mode');
|
||||
}
|
||||
async updatePushSubscription(): Promise<boolean> { return false; }
|
||||
async destroyPushSubscription(): Promise<void> { /* no-op */ }
|
||||
|
||||
// ── Quota ─────────────────────────────────────────────────────
|
||||
|
||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -51,6 +51,20 @@ export interface IJMAPClient {
|
||||
getLastStates(): AccountStates;
|
||||
setLastStates(states: AccountStates): void;
|
||||
|
||||
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
|
||||
// Browser-driven Web Push setup: register a relay URL the JMAP server can
|
||||
// forward StateChange events to. Mobile uses the same primitives.
|
||||
listPushSubscriptions(): Promise<PushSubscription[]>;
|
||||
createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
expires?: string;
|
||||
}): Promise<string>;
|
||||
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
|
||||
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
|
||||
destroyPushSubscription(id: string): Promise<void>;
|
||||
|
||||
// ── Quota ─────────────────────────────────────────────────────
|
||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||
|
||||
|
||||
+77
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -5313,4 +5313,80 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
|
||||
// Used by the PWA Web Push integration. The mobile app does the same dance
|
||||
// through its own JMAP client - keep these in sync.
|
||||
|
||||
async listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/get', { ids: null }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
|
||||
}
|
||||
|
||||
async createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
expires?: string;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { create: { new: created } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
|
||||
const id = result?.created?.new?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
|
||||
if (notUpdated) {
|
||||
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns false when the server rejects the update (e.g. the subscription
|
||||
// was already destroyed) - the caller treats that as a signal to recreate.
|
||||
async updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
): Promise<boolean> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { update: { [id]: patch } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
|
||||
if (r?.notUpdated?.[id]) return false;
|
||||
return r?.updated?.[id] !== undefined;
|
||||
}
|
||||
|
||||
async destroyPushSubscription(id: string): Promise<void> {
|
||||
await this.request(
|
||||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
// Browser-side Web Push setup. Mirrors the React Native flow in
|
||||
// repos/react-native/src/lib/push-notifications.ts so the relay sees the same
|
||||
// shape from both clients - the only differences are which native API
|
||||
// produces the push token (PushManager.subscribe here, FCM there) and which
|
||||
// register endpoint we hit on the relay.
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
||||
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
|
||||
|
||||
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
|
||||
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
|
||||
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
||||
export const DEFAULT_RELAY_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
|
||||
|
||||
// Match the mobile app's lifetime hint. The JMAP server may clamp this down.
|
||||
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
export interface EnableWebPushParams {
|
||||
client: IJMAPClient;
|
||||
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
|
||||
relayBaseUrl?: string;
|
||||
// Free-form label the relay shows in /metrics; never returned in pushes.
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
export interface EnableWebPushResult {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export class WebPushUnsupportedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WebPushUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebPushSupported(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return (
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
function buildRelayUrl(base: string, suffix: string): string {
|
||||
return base.replace(/\/+$/, '') + suffix;
|
||||
}
|
||||
|
||||
function expiresFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function randomDeviceClientId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getOrCreateDeviceClientId(): string {
|
||||
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const next = randomDeviceClientId();
|
||||
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// PushManager.subscribe wants the VAPID public key as a BufferSource.
|
||||
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
|
||||
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
|
||||
function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const buffer = new ArrayBuffer(raw.length);
|
||||
const out = new Uint8Array(buffer);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPushKey(
|
||||
sub: PushSubscription,
|
||||
name: 'p256dh' | 'auth',
|
||||
): string {
|
||||
const raw = sub.getKey(name);
|
||||
if (!raw) throw new Error(`PushSubscription is missing the ${name} key`);
|
||||
// Browsers want application/json over the wire so encode as base64url.
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(raw);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) {
|
||||
throw new Error('The push relay does not have Web Push configured');
|
||||
}
|
||||
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { publicKey?: string };
|
||||
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
|
||||
return body.publicKey;
|
||||
}
|
||||
|
||||
async function ensurePermission(): Promise<void> {
|
||||
if (Notification.permission === 'granted') return;
|
||||
if (Notification.permission === 'denied') {
|
||||
throw new Error('Notifications are blocked - allow them in browser settings to continue');
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
if (result !== 'granted') {
|
||||
throw new Error('Notification permission was not granted');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
// The webmail's PWA already registers /sw.js for installability. If it
|
||||
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
||||
// push handler is in place.
|
||||
let registration = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!registration) {
|
||||
registration = await navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
await navigator.serviceWorker.ready;
|
||||
return registration;
|
||||
}
|
||||
|
||||
async function registerWithRelay(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
// Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's
|
||||
// no-undef rule (which doesn't know about DOM type-only globals) is happy.
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
const { endpoint, keys } = params.subscription;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
throw new Error('Browser returned an incomplete PushSubscription');
|
||||
}
|
||||
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Relay register failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<string> {
|
||||
const timeoutAt = Date.now() + 20_000;
|
||||
let delay = 400;
|
||||
while (Date.now() < timeoutAt) {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { verificationCode?: string | null };
|
||||
if (body.verificationCode) return body.verificationCode;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 1.5, 2000);
|
||||
}
|
||||
throw new Error('Timed out waiting for PushVerification from the JMAP server');
|
||||
}
|
||||
|
||||
async function refreshSubscriptionExpires(
|
||||
client: IJMAPClient,
|
||||
sub: { id: string; expires: string | null },
|
||||
): Promise<boolean> {
|
||||
if (sub.expires) {
|
||||
const remainingMs = new Date(sub.expires).getTime() - Date.now();
|
||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true;
|
||||
}
|
||||
try {
|
||||
return await client.updatePushSubscription(sub.id, {
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableWebPush(
|
||||
params: EnableWebPushParams,
|
||||
): Promise<EnableWebPushResult> {
|
||||
if (!isWebPushSupported()) {
|
||||
throw new WebPushUnsupportedError(
|
||||
'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.',
|
||||
);
|
||||
}
|
||||
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
|
||||
|
||||
await ensurePermission();
|
||||
const registration = await ensureServiceWorker();
|
||||
|
||||
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
|
||||
|
||||
// Reuse an existing browser PushSubscription when possible - resubscribing
|
||||
// with the same VAPID key produces the same endpoint, but the call still
|
||||
// costs a network round-trip the user can feel.
|
||||
let pushSubscription = await registration.pushManager.getSubscription();
|
||||
if (pushSubscription) {
|
||||
const keyMatches = pushSubscription.options?.applicationServerKey;
|
||||
if (!keyMatches) {
|
||||
await pushSubscription.unsubscribe();
|
||||
pushSubscription = null;
|
||||
}
|
||||
}
|
||||
if (!pushSubscription) {
|
||||
pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
}
|
||||
|
||||
const deviceClientId = getOrCreateDeviceClientId();
|
||||
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
subscription: {
|
||||
endpoint: pushSubscription.endpoint,
|
||||
keys: {
|
||||
p256dh: readPushKey(pushSubscription, 'p256dh'),
|
||||
auth: readPushKey(pushSubscription, 'auth'),
|
||||
},
|
||||
},
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
|
||||
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
||||
// refreshing the expiry so it doesn't time out between sessions.
|
||||
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
const existing = await params.client.listPushSubscriptions().catch(() => []);
|
||||
const match = existing.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(params.client, match);
|
||||
if (refreshed) return { subscriptionId: storedServerId };
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
|
||||
const serverAssignedId = await params.client.createPushSubscription({
|
||||
deviceClientId,
|
||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
||||
types: [...PUSH_TYPES],
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
|
||||
|
||||
return { subscriptionId: serverAssignedId };
|
||||
}
|
||||
|
||||
export interface DisableWebPushParams {
|
||||
client: IJMAPClient;
|
||||
relayBaseUrl?: string;
|
||||
}
|
||||
|
||||
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
|
||||
// the browser PushSubscription. Any single failure is swallowed so the user
|
||||
// always ends up in a "disabled" state locally.
|
||||
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
|
||||
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
if (deviceClientId && relayBaseUrl) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
||||
const sub = await registration?.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isWebPushEnabled(): Promise<boolean> {
|
||||
if (!isWebPushSupported()) return false;
|
||||
if (Notification.permission !== 'granted') return false;
|
||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!registration) return false;
|
||||
const sub = await registration.pushManager.getSubscription();
|
||||
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user