feat: web push notifications for PWA #233
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/push/preview
|
||||||
|
*
|
||||||
|
* Called from the service worker when a Web Push wake-up arrives. Fetches the
|
||||||
|
* latest unread email so the SW can build an enriched system notification
|
||||||
|
* (sender, subject, avatar) without ever exposing JMAP credentials to the
|
||||||
|
* SW context.
|
||||||
|
*
|
||||||
|
* The relay's push payload is intentionally minimal (just a state-change
|
||||||
|
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
|
||||||
|
* a generic "New mail" string.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||||
|
headers: { Authorization: creds.authHeader },
|
||||||
|
});
|
||||||
|
if (!sessionRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const session = (await sessionRes.json()) as {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const apiUrl = session.apiUrl;
|
||||||
|
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||||
|
if (!apiUrl || !accountId) {
|
||||||
|
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the inbox, then pull the most recent unread message in it. We use
|
||||||
|
// a single batched JMAP request with back-references so this round-trip
|
||||||
|
// is one POST regardless of how many messages exist.
|
||||||
|
const requestBody = {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'Mailbox/query',
|
||||||
|
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||||
|
'mb',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
filter: {
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [
|
||||||
|
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
|
||||||
|
{ notKeyword: '$seen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 1,
|
||||||
|
calculateTotal: true,
|
||||||
|
},
|
||||||
|
'eq',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
|
||||||
|
},
|
||||||
|
'eg',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jmapRes = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
if (!jmapRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const data = (await jmapRes.json()) as {
|
||||||
|
methodResponses: [string, Record<string, unknown>, string][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailLite = {
|
||||||
|
id: string;
|
||||||
|
threadId: string;
|
||||||
|
from?: { name?: string | null; email?: string }[] | null;
|
||||||
|
subject?: string | null;
|
||||||
|
preview?: string | null;
|
||||||
|
receivedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let email: EmailLite | null = null;
|
||||||
|
let unreadTotal = 0;
|
||||||
|
for (const [method, body] of data.methodResponses) {
|
||||||
|
if (method === 'Email/query') {
|
||||||
|
unreadTotal = ((body as { total?: number }).total) ?? 0;
|
||||||
|
}
|
||||||
|
if (method === 'Email/get') {
|
||||||
|
const list = (body as { list?: EmailLite[] }).list ?? [];
|
||||||
|
email = list[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
email,
|
||||||
|
unreadTotal,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
// SW already gates on its own logic - don't let push events get
|
||||||
|
// cached and served stale.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('push preview failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Volume2 } from 'lucide-react';
|
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||||
|
import {
|
||||||
|
DEFAULT_RELAY_BASE_URL,
|
||||||
|
WebPushUnsupportedError,
|
||||||
|
disableWebPush,
|
||||||
|
enableWebPush,
|
||||||
|
isWebPushEnabled,
|
||||||
|
isWebPushSupported,
|
||||||
|
} from '@/lib/web-push';
|
||||||
|
|
||||||
|
type PushStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'busy' }
|
||||||
|
| { kind: 'enabled' }
|
||||||
|
| { kind: 'unsupported' }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
const t = useTranslations('settings.notifications');
|
const t = useTranslations('settings.notifications');
|
||||||
@@ -21,6 +40,77 @@ export function NotificationSettings() {
|
|||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const username = useAuthStore((s) => s.username);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
|
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
||||||
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||||
|
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||||
|
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
void (async () => {
|
||||||
|
const enabled = await isWebPushEnabled();
|
||||||
|
if (enabled) setPushStatus({ kind: 'enabled' });
|
||||||
|
})();
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
|
||||||
|
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
|
||||||
|
const busy = pushStatus.kind === 'busy';
|
||||||
|
|
||||||
|
const handleEnablePush = async () => {
|
||||||
|
if (!client) {
|
||||||
|
setPushStatus({ kind: 'error', message: 'Sign in first' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidRelay) {
|
||||||
|
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPushStatus({ kind: 'busy' });
|
||||||
|
try {
|
||||||
|
await enableWebPush({
|
||||||
|
client,
|
||||||
|
relayBaseUrl: trimmedRelay,
|
||||||
|
accountLabel: username ?? undefined,
|
||||||
|
});
|
||||||
|
setPushStatus({ kind: 'enabled' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof WebPushUnsupportedError) {
|
||||||
|
setPushStatus({ kind: 'unsupported' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPushStatus({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Failed to enable push',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisablePush = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t('push.confirm_disable_title'),
|
||||||
|
message: t('push.confirm_disable_message'),
|
||||||
|
confirmText: t('push.disable'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
setPushStatus({ kind: 'busy' });
|
||||||
|
try {
|
||||||
|
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
|
||||||
|
setPushStatus({ kind: 'idle' });
|
||||||
|
} catch (err) {
|
||||||
|
setPushStatus({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Failed to disable push',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
|
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
|
||||||
value: s.id,
|
value: s.id,
|
||||||
@@ -29,6 +119,46 @@ export function NotificationSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
||||||
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label className="text-sm font-medium" htmlFor="push-relay-url">
|
||||||
|
{t('push.relay_label')}
|
||||||
|
</label>
|
||||||
|
<PushStatusBadge status={pushStatus} t={t} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
|
||||||
|
<input
|
||||||
|
id="push-relay-url"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={relayUrl}
|
||||||
|
onChange={(e) => setRelayUrl(e.target.value)}
|
||||||
|
placeholder={t('push.relay_placeholder')}
|
||||||
|
disabled={busy || pushStatus.kind === 'unsupported'}
|
||||||
|
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleEnablePush}
|
||||||
|
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
|
||||||
|
>
|
||||||
|
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
|
||||||
|
</Button>
|
||||||
|
{pushStatus.kind === 'enabled' && (
|
||||||
|
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
|
||||||
|
{t('push.disable')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pushStatus.kind === 'unsupported' && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
|
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('sound_selection.choose')}
|
label={t('sound_selection.choose')}
|
||||||
@@ -118,6 +248,45 @@ export function NotificationSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PushStatusBadge({
|
||||||
|
status,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
status: PushStatus;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
if (status.kind === 'enabled') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
{t('push.status_active')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.kind === 'busy') {
|
||||||
|
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
|
||||||
|
}
|
||||||
|
if (status.kind === 'unsupported') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('push.status_unsupported')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.kind === 'error') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{status.message}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||||
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
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 ─────────────────────────────────────────────────────
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
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";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +51,20 @@ export interface IJMAPClient {
|
|||||||
getLastStates(): AccountStates;
|
getLastStates(): AccountStates;
|
||||||
setLastStates(states: AccountStates): void;
|
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 ─────────────────────────────────────────────────────
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
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 { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
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;
|
||||||
|
}
|
||||||
@@ -783,6 +783,23 @@
|
|||||||
"swift": "Swift Gesture",
|
"swift": "Swift Gesture",
|
||||||
"relax": "Relax"
|
"relax": "Relax"
|
||||||
},
|
},
|
||||||
|
"push": {
|
||||||
|
"title": "Background Notifications",
|
||||||
|
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||||
|
"relay_label": "Push relay",
|
||||||
|
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
|
||||||
|
"relay_placeholder": "https://notifications.relay.example.com",
|
||||||
|
"status_active": "Active on this device",
|
||||||
|
"status_inactive": "Not enabled on this device",
|
||||||
|
"status_unsupported": "This browser does not support Web Push",
|
||||||
|
"status_busy": "Working…",
|
||||||
|
"enable": "Enable",
|
||||||
|
"reenable": "Re-register",
|
||||||
|
"disable": "Disable",
|
||||||
|
"confirm_disable_title": "Disable background notifications?",
|
||||||
|
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
|
||||||
|
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
|
||||||
|
},
|
||||||
"sound_selection": {
|
"sound_selection": {
|
||||||
"title": "Notification Sound",
|
"title": "Notification Sound",
|
||||||
"description": "Choose which sound to play for notifications",
|
"description": "Choose which sound to play for notifications",
|
||||||
|
|||||||
+121
-4
@@ -1,8 +1,14 @@
|
|||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
// Minimal service worker – satisfies the PWA installability requirement
|
// Bulwark service worker.
|
||||||
// without caching any assets. All requests fall through to the network,
|
//
|
||||||
// so there is no risk of serving stale chunks after a deployment.
|
// This SW does two jobs:
|
||||||
|
// 1. Satisfy the PWA installability requirement (network-only fetch handler,
|
||||||
|
// no caching - so we never serve stale chunks after a deployment).
|
||||||
|
// 2. Receive Web Push wake-up pings from the relay and turn them into
|
||||||
|
// enriched system notifications. Mirrors the React Native FCM headless
|
||||||
|
// task: relay sends only a state-change ping, the client fetches the
|
||||||
|
// newest unread email itself so the relay never sees mail content.
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
@@ -12,5 +18,116 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(self.clients.claim());
|
event.waitUntil(self.clients.claim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Network-only fetch handler – no caching.
|
|
||||||
self.addEventListener("fetch", () => {});
|
self.addEventListener("fetch", () => {});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
event.waitUntil(handlePush(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(handleNotificationClick(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handlePush(event) {
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = event.data ? event.data.json() : null;
|
||||||
|
} catch (_) {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountLabel = (payload && typeof payload.accountLabel === "string")
|
||||||
|
? payload.accountLabel
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Best effort: ask the webmail to look up the latest unread email so we can
|
||||||
|
// build a useful notification. If the request fails (offline, session
|
||||||
|
// expired, server down) we fall back to a generic "New mail" so the user
|
||||||
|
// still sees something.
|
||||||
|
let preview = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/push/preview", {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
preview = await res.json();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
preview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = preview && preview.email ? preview.email : null;
|
||||||
|
const unreadTotal = preview && typeof preview.unreadTotal === "number"
|
||||||
|
? preview.unreadTotal
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let title;
|
||||||
|
let body;
|
||||||
|
let tag = "bulwark-mail";
|
||||||
|
let data = { kind: "mail-list" };
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const sender = email.from && email.from[0];
|
||||||
|
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
|
||||||
|
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
|
||||||
|
body = email.subject || email.preview || "(no subject)";
|
||||||
|
tag = "bulwark-mail:" + email.id;
|
||||||
|
data = {
|
||||||
|
kind: "email",
|
||||||
|
emailId: email.id,
|
||||||
|
threadId: email.threadId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
||||||
|
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
tag,
|
||||||
|
icon: "/icon-192x192.png",
|
||||||
|
badge: "/icon-192x192.png",
|
||||||
|
data,
|
||||||
|
renotify: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotificationClick(event) {
|
||||||
|
const data = event.notification.data || {};
|
||||||
|
const targetUrl = buildClickUrl(data);
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
includeUncontrolled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const client of allClients) {
|
||||||
|
// Reuse an existing tab whenever possible - users on desktop browsers
|
||||||
|
// get annoyed when each notification opens a fresh window.
|
||||||
|
if ("focus" in client) {
|
||||||
|
try {
|
||||||
|
if ("navigate" in client && targetUrl) {
|
||||||
|
await client.navigate(targetUrl);
|
||||||
|
}
|
||||||
|
return client.focus();
|
||||||
|
} catch (_) {
|
||||||
|
// navigate() can reject for cross-origin or detached clients - fall
|
||||||
|
// through and open a new window below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl || "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClickUrl(data) {
|
||||||
|
if (!data) return "/";
|
||||||
|
if (data.kind === "email" && data.emailId) {
|
||||||
|
return `/?email=${encodeURIComponent(data.emailId)}`;
|
||||||
|
}
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user