diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index e91cab219..7e87ba450 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { ImpersonationReconciler } from "@/components/impersonation/impersonatio import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; +import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { locales } from "@/i18n/routing"; export default async function LocaleLayout({ @@ -45,6 +46,7 @@ export default async function LocaleLayout({ + diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 423fb0e8f..83897d9aa 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -12,6 +12,18 @@ import { NextRequest, NextResponse } from 'next/server'; const ACCOUNT_ID = 'dev-account-001'; const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = []; const emailCreationIds = new Map(); +const DEFAULT_PUSH_RELAY_ORIGIN = 'https://notifications.relay.bulwarkmail.org'; + +interface MockPushSubscription { + id: string; + deviceClientId: string; + expires: string | null; + types: string[] | null; + expectedVerificationCode: string; + verified: boolean; +} + +const pushSubscriptions: MockPushSubscription[] = []; // --------------------------------------------------------------------------- // Mailboxes @@ -1501,6 +1513,10 @@ function buildThreads() { type MethodArgs = Record; type MethodResult = [string, Record, string]; +type MethodHandler = ( + args: MethodArgs, + callId: string, +) => MethodResult | Promise; function handleCoreEcho(args: MethodArgs, callId: string): MethodResult { return ['Core/echo', args, callId]; @@ -1934,12 +1950,134 @@ function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult { return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; } +function handlePushSubscriptionGet(args: MethodArgs, callId: string): MethodResult { + const ids = Array.isArray(args.ids) ? args.ids as string[] : null; + const selected = ids + ? pushSubscriptions.filter((subscription) => ids.includes(subscription.id)) + : pushSubscriptions; + const foundIds = new Set(selected.map((subscription) => subscription.id)); + + return ['PushSubscription/get', { + state: nextState(), + list: selected.map(({ id, deviceClientId, expires, types }) => ({ + id, + deviceClientId, + expires, + types, + })), + notFound: ids?.filter((id) => !foundIds.has(id)) ?? [], + }, callId]; +} + +async function handlePushSubscriptionSet(args: MethodArgs, callId: string): Promise { + const created: Record = {}; + const notCreated: Record = {}; + const updated: Record = {}; + const notUpdated: Record = {}; + const destroyed: string[] = []; + const notDestroyed: Record = {}; + + const create = args.create as Record> | undefined; + if (create) { + for (const [creationId, data] of Object.entries(create)) { + try { + const url = new URL(String(data.url ?? '')); + const allowedOrigin = new URL( + process.env.NEXT_PUBLIC_PUSH_RELAY_URL || DEFAULT_PUSH_RELAY_ORIGIN, + ).origin; + if (url.origin !== allowedOrigin || !url.pathname.startsWith('/api/push/jmap/')) { + throw new Error('The dev mock only sends verification to the configured push relay'); + } + + const id = `push-${crypto.randomUUID()}`; + const verificationCode = crypto.randomUUID().replace(/-/g, ''); + const subscription: MockPushSubscription = { + id, + deviceClientId: String(data.deviceClientId ?? ''), + expires: typeof data.expires === 'string' ? data.expires : null, + types: Array.isArray(data.types) ? data.types as string[] : null, + expectedVerificationCode: verificationCode, + verified: false, + }; + + const verificationResponse = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + '@type': 'PushVerification', + pushSubscriptionId: id, + verificationCode, + }), + }); + if (!verificationResponse.ok) { + throw new Error(`Push relay rejected verification (${verificationResponse.status})`); + } + + pushSubscriptions.push(subscription); + created[creationId] = { + id, + expires: subscription.expires, + types: subscription.types, + }; + } catch (error) { + notCreated[creationId] = { + type: 'invalidProperties', + description: error instanceof Error ? error.message : 'Invalid push subscription', + }; + } + } + } + + const update = args.update as Record> | undefined; + if (update) { + for (const [id, patch] of Object.entries(update)) { + const subscription = pushSubscriptions.find((candidate) => candidate.id === id); + if (!subscription) { + notUpdated[id] = { type: 'notFound', description: 'Push subscription not found' }; + continue; + } + if ( + typeof patch.verificationCode === 'string' + && patch.verificationCode !== subscription.expectedVerificationCode + ) { + notUpdated[id] = { type: 'invalidProperties', description: 'Invalid verification code' }; + continue; + } + if (typeof patch.verificationCode === 'string') subscription.verified = true; + if (typeof patch.expires === 'string') subscription.expires = patch.expires; + if (Array.isArray(patch.types)) subscription.types = patch.types as string[]; + updated[id] = null; + } + } + + if (Array.isArray(args.destroy)) { + for (const id of args.destroy as string[]) { + const index = pushSubscriptions.findIndex((subscription) => subscription.id === id); + if (index === -1) { + notDestroyed[id] = { type: 'notFound' }; + continue; + } + pushSubscriptions.splice(index, 1); + destroyed.push(id); + } + } + + return ['PushSubscription/set', { + created, + notCreated, + updated, + notUpdated, + destroyed, + notDestroyed, + }, callId]; +} + // Catch-all for unknown methods function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult { return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId]; } -const METHOD_HANDLERS: Record MethodResult> = { +const METHOD_HANDLERS: Record = { 'Core/echo': handleCoreEcho, 'Mailbox/get': handleMailboxGet, 'Mailbox/set': handleMailboxSet, @@ -2009,6 +2147,8 @@ const METHOD_HANDLERS: Record Meth 'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], 'SieveScript/get': handleSieveScriptGet, 'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'PushSubscription/get': handlePushSubscriptionGet, + 'PushSubscription/set': handlePushSubscriptionSet, }; // --------------------------------------------------------------------------- @@ -2270,7 +2410,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (tooLarge) { responses.push(tooLarge); } else if (handler) { - const result = handler(args, callId); + const result = await handler(args, callId); responses.push(result); } else { responses.push(handleUnknown(method, args, callId)); diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx new file mode 100644 index 000000000..e05110ffb --- /dev/null +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -0,0 +1,205 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PUSH_NOTIFICATION_PROMPT_DELAY_MS, PushNotificationPrompt } from "../push-notification-prompt"; +import { PWA_INSTALL_PROMPT_VISIBILITY_EVENT } from "../pwa-install-prompt"; + +const mocks = vi.hoisted(() => ({ + pathname: "/en", + enabled: false, + supported: true, + enableWebPush: vi.fn(), + isWebPushEnabled: vi.fn(), + authState: { + client: { getAccountId: () => "account-1" } as { + getAccountId: () => string; + }, + username: "alice@example.com", + isAuthenticated: true, + isDemoMode: false, + }, + settingsState: { emailNotificationsEnabled: true }, + policyState: { + loaded: true, + policy: { pushRelayUrl: "https://notifications.relay.bulwarkmail.org" }, + }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => mocks.pathname, +})); + +vi.mock("@/stores/auth-store", () => ({ + useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => + selector(mocks.authState), +})); + +vi.mock("@/stores/settings-store", () => ({ + useSettingsStore: ( + selector: (state: typeof mocks.settingsState) => unknown, + ) => selector(mocks.settingsState), +})); + +vi.mock("@/stores/policy-store", () => ({ + usePolicyStore: (selector: (state: typeof mocks.policyState) => unknown) => + selector(mocks.policyState), +})); + +vi.mock("@/lib/web-push", () => ({ + DEFAULT_RELAY_BASE_URL: "https://notifications.relay.bulwarkmail.org", + enableWebPush: mocks.enableWebPush, + isWebPushSupported: () => mocks.supported, + isWebPushEnabled: mocks.isWebPushEnabled, +})); + +function setNotificationPermission(permission: NotificationPermission) { + Object.defineProperty(window, "Notification", { + configurable: true, + value: { permission }, + }); + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: { permission }, + }); +} + +async function advancePromptDelay() { + await act(async () => { + await vi.advanceTimersByTimeAsync(PUSH_NOTIFICATION_PROMPT_DELAY_MS); + }); +} + +describe("PushNotificationPrompt", () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + mocks.pathname = "/en"; + mocks.supported = true; + mocks.enabled = false; + mocks.authState.client = { getAccountId: () => "account-1" }; + mocks.authState.username = "alice@example.com"; + mocks.authState.isAuthenticated = true; + mocks.authState.isDemoMode = false; + mocks.settingsState.emailNotificationsEnabled = true; + mocks.policyState.loaded = true; + mocks.policyState.policy.pushRelayUrl = + "https://notifications.relay.bulwarkmail.org"; + mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" }); + mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled); + setNotificationPermission("default"); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("offers onboarding when the current account has no push subscription", async () => { + render(); + await advancePromptDelay(); + + expect(screen.getByText("title")).toBeInTheDocument(); + expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1"); + }); + + it("enables push through the existing web-push flow", async () => { + render(); + await advancePromptDelay(); + + await act(async () => { + fireEvent.click(screen.getByText("enable")); + await Promise.resolve(); + }); + + expect(mocks.enableWebPush).toHaveBeenCalledWith({ + client: mocks.authState.client, + relayBaseUrl: "https://notifications.relay.bulwarkmail.org", + accountLabel: "alice@example.com", + }); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("does not render when push is already enabled", async () => { + mocks.enabled = true; + render(); + await advancePromptDelay(); + + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("does not render when browser notifications are blocked", async () => { + setNotificationPermission("denied"); + render(); + await advancePromptDelay(); + + expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("does not render on the settings route", async () => { + mocks.pathname = "/en/settings"; + render(); + await advancePromptDelay(); + + expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); + }); + + it("persists the per-account don't-remind choice", async () => { + const { unmount } = render(); + await advancePromptDelay(); + fireEvent.click(screen.getByText("dont_remind")); + + expect( + localStorage.getItem("bulwark.push.onboarding.dismissed.v1.account-1"), + ).toBe("1"); + + unmount(); + mocks.isWebPushEnabled.mockClear(); + render(); + await advancePromptDelay(); + expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); + }); + + it("keeps the session dismissal of every account that was dismissed", async () => { + const { rerender } = render(); + await advancePromptDelay(); + fireEvent.click(screen.getByText("not_now")); + + mocks.authState.client = { getAccountId: () => "account-2" }; + rerender(); + await advancePromptDelay(); + expect(screen.getByText("title")).toBeInTheDocument(); + fireEvent.click(screen.getByText("not_now")); + + mocks.authState.client = { getAccountId: () => "account-1" }; + rerender(); + await advancePromptDelay(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("defers push onboarding until the PWA install prompt is closed", async () => { + render(); + act(() => { + window.dispatchEvent( + new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, { + detail: { visible: true }, + }), + ); + }); + await advancePromptDelay(); + + expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + + act(() => { + window.dispatchEvent( + new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, { + detail: { visible: false }, + }), + ); + }); + await advancePromptDelay(); + + expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1"); + expect(screen.getByText("title")).toBeInTheDocument(); + }); +}); diff --git a/components/__tests__/pwa-install-prompt.test.tsx b/components/__tests__/pwa-install-prompt.test.tsx new file mode 100644 index 000000000..96d57f40d --- /dev/null +++ b/components/__tests__/pwa-install-prompt.test.tsx @@ -0,0 +1,69 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PWA_INSTALL_PROMPT_VISIBILITY_EVENT, + PWAInstallPrompt, +} from "../pwa-install-prompt"; + +vi.mock("@/hooks/use-config", () => ({ + useConfig: () => ({ + appName: "Bulwark", + faviconUrl: "", + appLogoLightUrl: "", + appLogoDarkUrl: "", + }), +})); + +describe("PWAInstallPrompt", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("closes after the browser install prompt is dismissed", async () => { + const prompt = vi.fn().mockResolvedValue(undefined); + const event = new Event("beforeinstallprompt"); + Object.assign(event, { + prompt, + userChoice: Promise.resolve({ outcome: "dismissed" }), + }); + + render(); + act(() => window.dispatchEvent(event)); + expect(screen.getByText("title")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText("install")); + await Promise.resolve(); + }); + + expect(prompt).toHaveBeenCalledOnce(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("announces that it is hidden after Not now", () => { + const visibility: boolean[] = []; + const listener = (event: Event) => { + visibility.push( + (event as CustomEvent<{ visible?: boolean }>).detail?.visible === true, + ); + }; + window.addEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener); + + try { + const event = new Event("beforeinstallprompt"); + Object.assign(event, { + prompt: vi.fn(), + userChoice: Promise.resolve({ outcome: "dismissed" }), + }); + + render(); + act(() => window.dispatchEvent(event)); + fireEvent.click(screen.getByText("not_now")); + + expect(visibility).toContain(true); + expect(visibility.at(-1)).toBe(false); + } finally { + window.removeEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener); + } + }); +}); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx new file mode 100644 index 000000000..812c0cf4d --- /dev/null +++ b/components/push-notification-prompt.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Bell, Loader2, X } from "lucide-react"; +import { useAuthStore } from "@/stores/auth-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { + DEFAULT_RELAY_BASE_URL, + enableWebPush, + isWebPushEnabled, + isWebPushSupported, +} from "@/lib/web-push"; +import { + isPWAInstallPromptVisible, + PWA_INSTALL_PROMPT_VISIBILITY_EVENT, +} from "@/components/pwa-install-prompt"; + +const DISMISSED_KEY_PREFIX = "bulwark.push.onboarding.dismissed.v1."; +export const PUSH_NOTIFICATION_PROMPT_DELAY_MS = 1_000; + +function dismissedKey(accountId: string): string { + return `${DISMISSED_KEY_PREFIX}${accountId}`; +} + +function isExcludedPath(pathname: string): boolean { + return /(^|\/)settings(?:\/|$)/.test(pathname) + || /(^|\/)setup(?:\/|$)/.test(pathname) + || /(^|\/)login(?:\/|$)/.test(pathname); +} + +function wasDismissed(accountId: string): boolean { + try { + return localStorage.getItem(dismissedKey(accountId)) === "1"; + } catch { + return false; + } +} + +function dismissPermanently(accountId: string): void { + try { + localStorage.setItem(dismissedKey(accountId), "1"); + } catch { + // Storage may be unavailable in hardened/private browser contexts. The + // prompt still closes for the current session. + } +} + +export function PushNotificationPrompt() { + const pathname = usePathname(); + const tPush = useTranslations("settings.notifications.push"); + const tInstall = useTranslations("pwa_install"); + + const client = useAuthStore((state) => state.client); + const username = useAuthStore((state) => state.username); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isDemoMode = useAuthStore((state) => state.isDemoMode); + const emailNotificationsEnabled = useSettingsStore( + (state) => state.emailNotificationsEnabled, + ); + const policyLoaded = usePolicyStore((state) => state.loaded); + const adminPushRelayUrl = usePolicyStore( + (state) => state.policy.pushRelayUrl, + ); + + const [showPrompt, setShowPrompt] = useState(false); + const [isEnabling, setIsEnabling] = useState(false); + const [error, setError] = useState(null); + // Session-only dismissal is per account: dismissing on one account must not + // re-offer the prompt on an account dismissed earlier in the same session. + const [sessionDismissedAccountIds, setSessionDismissedAccountIds] = useState< + ReadonlySet + >(() => new Set()); + const [pwaPromptVisible, setPwaPromptVisible] = useState( + isPWAInstallPromptVisible, + ); + + const accountId = client?.getAccountId() ?? null; + const relayBaseUrl = (adminPushRelayUrl ?? "").trim() + || DEFAULT_RELAY_BASE_URL; + + const dismissForSession = useCallback((id: string) => { + setSessionDismissedAccountIds((previous) => { + if (previous.has(id)) return previous; + const next = new Set(previous); + next.add(id); + return next; + }); + }, []); + + // A layout effect subscribes before the browser can paint, so a visibility + // event dispatched by the PWA prompt during the same commit is not missed. + useLayoutEffect(() => { + const handlePwaPromptVisibility = (event: Event) => { + const visible = (event as CustomEvent<{ visible?: boolean }>).detail?.visible; + setPwaPromptVisible(visible === true); + }; + + window.addEventListener( + PWA_INSTALL_PROMPT_VISIBILITY_EVENT, + handlePwaPromptVisibility, + ); + setPwaPromptVisible(isPWAInstallPromptVisible()); + return () => { + window.removeEventListener( + PWA_INSTALL_PROMPT_VISIBILITY_EVENT, + handlePwaPromptVisibility, + ); + }; + }, []); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + + setShowPrompt(false); + setError(null); + + if ( + !policyLoaded + || !isAuthenticated + || !client + || !accountId + || isDemoMode + || !emailNotificationsEnabled + || isExcludedPath(pathname) + || sessionDismissedAccountIds.has(accountId) + || pwaPromptVisible + || wasDismissed(accountId) + || !isWebPushSupported() + || Notification.permission === "denied" + ) { + return; + } + + timer = setTimeout(() => { + void isWebPushEnabled(accountId) + .then((enabled) => { + if (!cancelled && !enabled) setShowPrompt(true); + }) + .catch(() => { + // Failure to inspect the browser subscription should not trigger an + // onboarding prompt whose current state we cannot determine. + }); + }, PUSH_NOTIFICATION_PROMPT_DELAY_MS); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [ + accountId, + client, + emailNotificationsEnabled, + isAuthenticated, + isDemoMode, + pathname, + policyLoaded, + pwaPromptVisible, + sessionDismissedAccountIds, + ]); + + const handleEnable = async () => { + if (!client) return; + setIsEnabling(true); + setError(null); + try { + await enableWebPush({ + client, + relayBaseUrl, + accountLabel: username ?? undefined, + }); + setShowPrompt(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to enable push"); + } finally { + setIsEnabling(false); + } + }; + + const handleDismiss = () => { + if (accountId) dismissForSession(accountId); + setShowPrompt(false); + }; + + const handleDismissForever = () => { + if (accountId) { + dismissPermanently(accountId); + dismissForSession(accountId); + } + setShowPrompt(false); + }; + + if (!showPrompt) return null; + + return ( +
+
+
+ +
+

+ {tPush("title")} +

+

+ {tPush("description")} +

+
+
+ +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+ +
+
+ ); +} diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 4cc95f953..f663f5083 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -12,6 +12,16 @@ interface BeforeInstallPromptEvent extends Event { } const DISMISSED_KEY = "pwa-install-dismissed"; +export const PWA_INSTALL_PROMPT_VISIBILITY_EVENT = + "bulwark:pwa-install-prompt-visibility"; + +// Mirrors the last dispatched visibility so a listener that mounts after the +// event was fired can still read the current state instead of assuming hidden. +let promptVisible = false; + +export function isPWAInstallPromptVisible(): boolean { + return promptVisible; +} export function PWAInstallPrompt() { const [deferredPrompt, setDeferredPrompt] = @@ -36,16 +46,29 @@ export function PWAInstallPrompt() { }; }, []); + useEffect(() => { + promptVisible = showPrompt; + window.dispatchEvent( + new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, { + detail: { visible: showPrompt }, + }), + ); + return () => { + if (showPrompt) promptVisible = false; + }; + }, [showPrompt]); + const handleInstall = async () => { if (!deferredPrompt) return; deferredPrompt.prompt(); - const { outcome } = await deferredPrompt.userChoice; + await deferredPrompt.userChoice; - if (outcome === "accepted") { - setDeferredPrompt(null); - setShowPrompt(false); - } + // A beforeinstallprompt event can only be consumed once. Close the card + // whether the browser prompt was accepted or dismissed so the next + // onboarding step can continue in the same session. + setDeferredPrompt(null); + setShowPrompt(false); }; const handleDismiss = () => { diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 0de022dd9..9623fee76 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -199,6 +199,81 @@ describe('dev-jmap mock server', () => { }); }); + describe('POST /api - PushSubscription lifecycle', () => { + it('creates, verifies, lists, and destroys a push subscription', async () => { + const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 202 }), + ); + + try { + const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { + create: { + new: { + deviceClientId: 'browser-device-1', + url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1', + types: ['Email'], + expires: '2030-01-01T00:00:00.000Z', + }, + }, + }, 'c0']], + }), + }); + const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) }); + const createData = await createRes.json(); + const id = createData.methodResponses[0][1].created.new.id as string; + const verificationBody = JSON.parse( + String(relayFetch.mock.calls[0]?.[1]?.body), + ) as { verificationCode: string }; + + expect(id).toMatch(/^push-/); + expect(relayFetch).toHaveBeenCalledOnce(); + + const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { + update: { [id]: { verificationCode: verificationBody.verificationCode } }, + }, 'v0']], + }), + }); + const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) }); + const verifyData = await verifyRes.json(); + expect(verifyData.methodResponses[0][1].updated[id]).toBeNull(); + + const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']], + }), + }); + const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) }); + const getData = await getRes.json(); + expect(getData.methodResponses[0][1].list).toEqual([ + expect.objectContaining({ id, deviceClientId: 'browser-device-1' }), + ]); + + const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']], + }), + }); + const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) }); + const destroyData = await destroyRes.json(); + expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]); + } finally { + relayFetch.mockRestore(); + } + }); + }); + describe('POST /api - back-references', () => { it('should resolve #ids from Email/query result', async () => { const req = makeRequest('http://localhost:3000/api/dev-jmap/api', { diff --git a/locales/ar/common.json b/locales/ar/common.json index f01446730..134304028 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1068,6 +1068,7 @@ "disable": "تعطيل", "confirm_disable_title": "تعطيل الإشعارات في الخلفية؟", "confirm_disable_message": "سيتوقف هذا الجهاز عن تلقي التنبيهات عند إغلاق الموقع.", + "dismiss_aria": "تجاهل مطالبة الإشعارات", "ios_hint": "على iOS، ثبّت الموقع على الشاشة الرئيسية أولًا - يقدّم Safari خدمة Web Push فقط لتطبيقات PWA المثبّتة." }, "sound_selection": { diff --git a/locales/ca/common.json b/locales/ca/common.json index b2415c452..50ac94e00 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1068,6 +1068,7 @@ "disable": "Desactiva", "confirm_disable_title": "Voleu desactivar les notificacions en segon pla?", "confirm_disable_message": "Aquest dispositiu deixarà de rebre avisos quan el lloc estigui tancat.", + "dismiss_aria": "Descarta l'avís de notificacions", "ios_hint": "A l'iOS, primer instal·leu el lloc a la pantalla d'inici - el Safari només entrega Web Push a les PWA instal·lades." }, "sound_selection": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 79aec0196..2e1e6110e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1078,6 +1078,7 @@ "description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.", "disable": "Zakázat", "enable": "Povolit", + "dismiss_aria": "Zavřít výzvu k oznámením", "ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku – Safari doručuje Web Push pouze nainstalovaným PWA.", "reenable": "Znovu zaregistrovat", "relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.", diff --git a/locales/da/common.json b/locales/da/common.json index 04e634e60..0654ac902 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1068,6 +1068,7 @@ "disable": "Deaktivér", "confirm_disable_title": "Deaktivér baggrundsnotifikationer?", "confirm_disable_message": "Denne enhed stopper med at modtage alarmer, når sitet er lukket.", + "dismiss_aria": "Afvis notifikationsprompt", "ios_hint": "På iOS skal du installere sitet på din startskærm først - Safari leverer kun Web Push til installerede PWA'er." }, "sound_selection": { diff --git a/locales/de/common.json b/locales/de/common.json index eefe0e83d..d3063628b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1078,6 +1078,7 @@ "description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.", "disable": "Deaktivieren", "enable": "Aktivieren", + "dismiss_aria": "Benachrichtigungshinweis schließen", "ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm – Safari liefert Web Push nur an installierte PWAs.", "reenable": "Neu registrieren", "relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.", diff --git a/locales/en/common.json b/locales/en/common.json index 6b754b9d7..818714d32 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1067,6 +1067,7 @@ "disable": "Disable", "confirm_disable_title": "Disable background notifications?", "confirm_disable_message": "This device will stop receiving alerts when the site is closed.", + "dismiss_aria": "Dismiss notification prompt", "ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs." }, "sound_selection": { diff --git a/locales/es/common.json b/locales/es/common.json index 812972862..35a76c5b4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1078,6 +1078,7 @@ "description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.", "disable": "Desactivar", "enable": "Activar", + "dismiss_aria": "Cerrar aviso de notificaciones", "ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.", "reenable": "Volver a registrar", "relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.", diff --git a/locales/fa/common.json b/locales/fa/common.json index c46679470..a1d2e4bbf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1068,6 +1068,7 @@ "disable": "غیرفعال کردن", "confirm_disable_title": "غیرفعال کردن اعلان‌های پس‌زمینه؟", "confirm_disable_message": "این دستگاه دیگر هشدار دریافت نمی‌کند", + "dismiss_aria": "رد کردن پیشنهاد اعلان‌ها", "ios_hint": "در iOS ابتدا سایت را به صفحه اصلی اضافه کنید" }, "sound_selection": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 97da02401..338c0690c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1078,6 +1078,7 @@ "description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.", "disable": "Désactiver", "enable": "Activer", + "dismiss_aria": "Fermer l'invite de notifications", "ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil – Safari ne livre Web Push qu'aux PWA installées.", "reenable": "Réenregistrer", "relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.", diff --git a/locales/he/common.json b/locales/he/common.json index 4eea36799..63ff27cc3 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1054,6 +1054,7 @@ "disable": "השבת", "confirm_disable_title": "השבת התראות בדפדפן?", "confirm_disable_message": "התקן זה יפסיק לקבל התראות כאשר האתר סגור.", + "dismiss_aria": "בטל הודעת התראות", "ios_hint": "ב־iOS, התקן קודם כל את האתר בהיתוך שלך - Safari מספק Web Push רק לPWA מותקנות." } }, diff --git a/locales/hu/common.json b/locales/hu/common.json index dffbb1c38..c143bc085 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1068,6 +1068,7 @@ "disable": "Letiltás", "confirm_disable_title": "Háttérértesítések letiltása?", "confirm_disable_message": "Ez az eszköz nem fog figyelmeztetéseket kapni, amikor az oldal be van zárva.", + "dismiss_aria": "Értesítési ablak elutasítása", "ios_hint": "iOS-en először telepítsd az oldalt a kezdőképernyőre - a Safari csak telepített PWA-knak kézbesít Web Push értesítéseket." }, "sound_selection": { diff --git a/locales/it/common.json b/locales/it/common.json index d397a69bd..6309480d8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1078,6 +1078,7 @@ "description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.", "disable": "Disabilita", "enable": "Abilita", + "dismiss_aria": "Chiudi avviso di notifiche", "ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.", "reenable": "Registra di nuovo", "relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 648efffee..0c5890317 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1078,6 +1078,7 @@ "description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。", "disable": "無効化", "enable": "有効化", + "dismiss_aria": "通知プロンプトを閉じる", "ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。", "reenable": "再登録", "relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 38bce8b71..91a642226 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1078,6 +1078,7 @@ "description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.", "disable": "비활성화", "enable": "활성화", + "dismiss_aria": "알림 프롬프트 닫기", "ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.", "reenable": "다시 등록", "relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 1e3f4a86c..650607b80 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1078,6 +1078,7 @@ "description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.", "disable": "Atspējot", "enable": "Iespējot", + "dismiss_aria": "Aizvērt paziņojumu uzaicinājumu", "ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā – Safari piegādā Web Push tikai instalētajām PWA.", "reenable": "Reģistrēt vēlreiz", "relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 59a79db2e..90aee238d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1078,6 +1078,7 @@ "description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.", "disable": "Uitschakelen", "enable": "Inschakelen", + "dismiss_aria": "Meldingsvenster sluiten", "ios_hint": "Installeer de site op iOS eerst op het beginscherm – Safari levert Web Push alleen aan geïnstalleerde PWA's.", "reenable": "Opnieuw registreren", "relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.", diff --git a/locales/pl/common.json b/locales/pl/common.json index faaca28db..04cde559b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1078,6 +1078,7 @@ "description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.", "disable": "Wyłącz", "enable": "Włącz", + "dismiss_aria": "Zamknij monit powiadomień", "ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym – Safari dostarcza Web Push tylko zainstalowanym PWA.", "reenable": "Zarejestruj ponownie", "relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.", diff --git a/locales/pt/common.json b/locales/pt/common.json index ca4ab329b..9059d7ef8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1078,6 +1078,7 @@ "description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.", "disable": "Desativar", "enable": "Ativar", + "dismiss_aria": "Dispensar aviso de notificações", "ios_hint": "No iOS, instale o site primeiro na tela inicial – o Safari só entrega Web Push para PWAs instalados.", "reenable": "Registrar novamente", "relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7da59a55f..488fe569f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1068,6 +1068,7 @@ "disable": "Dezactivare", "confirm_disable_title": "Dezactivați notificările din fundal?", "confirm_disable_message": "Acest dispozitiv nu va mai primi alerte atunci când site-ul este închis.", + "dismiss_aria": "Ignorați solicitarea de notificări", "ios_hint": "Pe iOS, instalați mai întâi site-ul pe ecranul de start – Safari trimite notificări Web Push numai către aplicațiile web progresive (PWA) instalate." }, "sound_selection": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 6b7d6440d..6788f7ff4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1078,6 +1078,7 @@ "description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.", "disable": "Отключить", "enable": "Включить", + "dismiss_aria": "Закрыть запрос на уведомления", "ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.", "reenable": "Перерегистрировать", "relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.", diff --git a/locales/sk/common.json b/locales/sk/common.json index d17133639..a50f1e81a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1092,6 +1092,7 @@ "disable": "Zakázať", "confirm_disable_title": "Zakázať oznámenia na pozadí?", "confirm_disable_message": "Toto zariadenie prestane prijímať upozornenia, keď je stránka zatvorená.", + "dismiss_aria": "Zavrieť výzvu na oznámenia", "ios_hint": "Na iOS najprv nainštalujte stránku na domovskú obrazovku – Safari doručuje Web Push iba nainštalovaným PWA." } }, diff --git a/locales/tr/common.json b/locales/tr/common.json index 5764b4279..f90be6c3c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1078,6 +1078,7 @@ "description": "Bu site kapalıyken yeni postalar için sistem bildirimleri alın. Bulwark push röle aracılığıyla iletilir; röle posta içeriğini asla görmez.", "disable": "Devre dışı bırak", "enable": "Etkinleştir", + "dismiss_aria": "Bildirim istemini kapat", "ios_hint": "iOS'ta önce siteyi ana ekranınıza yükleyin – Safari Web Push'u yalnızca yüklü PWA'lara teslim eder.", "reenable": "Yeniden kaydet", "relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 488a43e3e..04fe91c6b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1078,6 +1078,7 @@ "description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.", "disable": "Вимкнути", "enable": "Увімкнути", + "dismiss_aria": "Закрити запит на сповіщення", "ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.", "reenable": "Перереєструвати", "relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.", diff --git a/locales/zh/common.json b/locales/zh/common.json index 288db067d..8194371f6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1078,6 +1078,7 @@ "description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。", "disable": "禁用", "enable": "启用", + "dismiss_aria": "关闭通知提示", "ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。", "reenable": "重新注册", "relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",