mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: sequence PWA and push onboarding
This commit is contained in:
@@ -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<string, string>();
|
||||
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<string, unknown>;
|
||||
type MethodResult = [string, Record<string, unknown>, string];
|
||||
type MethodHandler = (
|
||||
args: MethodArgs,
|
||||
callId: string,
|
||||
) => MethodResult | Promise<MethodResult>;
|
||||
|
||||
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<MethodResult> {
|
||||
const created: Record<string, { id: string; expires: string | null; types: string[] | null }> = {};
|
||||
const notCreated: Record<string, { type: string; description: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const notUpdated: Record<string, { type: string; description: string }> = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, { type: string }> = {};
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>> | 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<string, (args: MethodArgs, callId: string) => MethodResult> = {
|
||||
const METHOD_HANDLERS: Record<string, MethodHandler> = {
|
||||
'Core/echo': handleCoreEcho,
|
||||
'Mailbox/get': handleMailboxGet,
|
||||
'Mailbox/set': handleMailboxSet,
|
||||
@@ -2009,6 +2147,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => 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));
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("PushNotificationPrompt", () => {
|
||||
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers push onboarding when the PWA install prompt was shown", async () => {
|
||||
it("defers push onboarding until the PWA install prompt is closed", async () => {
|
||||
render(<PushNotificationPrompt />);
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
@@ -165,5 +165,17 @@ describe("PushNotificationPrompt", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<PWAInstallPrompt />);
|
||||
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(<PWAInstallPrompt />);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export function PushNotificationPrompt() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionDismissedAccountId, setSessionDismissedAccountId] =
|
||||
useState<string | null>(null);
|
||||
const [pwaPromptWasShown, setPwaPromptWasShown] = useState(false);
|
||||
const [pwaPromptVisible, setPwaPromptVisible] = useState(false);
|
||||
|
||||
const accountId = client?.getAccountId() ?? null;
|
||||
const relayBaseUrl = (adminPushRelayUrl ?? "").trim()
|
||||
@@ -76,7 +76,7 @@ export function PushNotificationPrompt() {
|
||||
useEffect(() => {
|
||||
const handlePwaPromptVisibility = (event: Event) => {
|
||||
const visible = (event as CustomEvent<{ visible?: boolean }>).detail?.visible;
|
||||
if (visible) setPwaPromptWasShown(true);
|
||||
setPwaPromptVisible(visible === true);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
@@ -107,7 +107,7 @@ export function PushNotificationPrompt() {
|
||||
|| !emailNotificationsEnabled
|
||||
|| isExcludedPath(pathname)
|
||||
|| sessionDismissedAccountId === accountId
|
||||
|| pwaPromptWasShown
|
||||
|| pwaPromptVisible
|
||||
|| wasDismissed(accountId)
|
||||
|| !isWebPushSupported()
|
||||
|| Notification.permission === "denied"
|
||||
@@ -138,7 +138,7 @@ export function PushNotificationPrompt() {
|
||||
isDemoMode,
|
||||
pathname,
|
||||
policyLoaded,
|
||||
pwaPromptWasShown,
|
||||
pwaPromptVisible,
|
||||
sessionDismissedAccountId,
|
||||
]);
|
||||
|
||||
|
||||
@@ -50,12 +50,13 @@ export function PWAInstallPrompt() {
|
||||
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 = () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user