feat: re-sync existing push registrations on app start

This commit is contained in:
Linus Rath
2026-08-26 21:56:58 +02:00
parent a1ed482a6e
commit 4bf3a3f8bb
4 changed files with 147 additions and 0 deletions
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
supported: true,
enableWebPush: vi.fn(),
isWebPushEnabled: vi.fn(),
resyncWebPush: vi.fn(),
authState: {
client: { getAccountId: () => "account-1" } as {
getAccountId: () => string;
@@ -53,6 +54,7 @@ vi.mock("@/lib/web-push", () => ({
enableWebPush: mocks.enableWebPush,
isWebPushSupported: () => mocks.supported,
isWebPushEnabled: mocks.isWebPushEnabled,
resyncWebPush: mocks.resyncWebPush,
}));
function setNotificationPermission(permission: NotificationPermission) {
@@ -92,6 +94,7 @@ describe("PushNotificationPrompt", () => {
mocks.policyState.policy.pushRelayUrlLocked = false;
mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" });
mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled);
mocks.resyncWebPush.mockReset().mockResolvedValue(false);
setNotificationPermission("default");
});
@@ -170,6 +173,29 @@ describe("PushNotificationPrompt", () => {
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("re-syncs an existing registration in the background on mount", async () => {
mocks.enabled = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
expect(mocks.resyncWebPush).toHaveBeenCalledWith(
expect.objectContaining({
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
}),
);
// Only the explicit Enable button may start the interactive flow.
expect(mocks.enableWebPush).not.toHaveBeenCalled();
});
it("does not touch push in demo mode", async () => {
mocks.authState.isDemoMode = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
});
it("does not render when browser notifications are blocked", async () => {
setNotificationPermission("denied");
render(<PushNotificationPrompt />);
+16
View File
@@ -11,6 +11,7 @@ import {
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
resyncWebPush,
} from "@/lib/web-push";
import { resolveActiveRelayUrl } from "@/lib/push-relays";
import {
@@ -109,6 +110,21 @@ export function PushNotificationPrompt() {
};
}, []);
// Accounts that already have push on get their registration touched up in
// the background once per page load: expiry refreshed and the server-side
// delivery filter installed or repaired (older registrations predate it and
// would keep waking the device for spam). Deliberately independent of the
// prompt gating below - dismissing the onboarding prompt must not leave a
// live registration stale.
useEffect(() => {
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
void resyncWebPush({
client,
relayBaseUrl,
accountLabel: username ?? undefined,
});
}, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
+60
View File
@@ -5,6 +5,8 @@ import {
disableWebPush,
enableWebPush,
listPushDevices,
resetWebPushResyncState,
resyncWebPush,
revokePushDevice,
} from '@/lib/web-push';
@@ -153,6 +155,7 @@ function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
beforeEach(() => {
localStorage.clear();
resetWebPushResyncState();
installPushBrowser();
});
@@ -201,6 +204,63 @@ describe('enableWebPush', () => {
});
});
// Registrations made before the client learned about the delivery filter are
// repaired in the background on app start - nobody should have to find the
// settings toggle to stop the spam pushes.
describe('resyncWebPush', () => {
it('re-syncs an enabled registration and installs the missing filter', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(client.updatePushSubscription).toHaveBeenCalledWith(
'push-old',
expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }),
);
});
it('does nothing when push is not enabled for the account', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false);
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
it('runs at most once per account per page load', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false);
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1);
});
it('swallows failures instead of surfacing them to the app', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
client.listPushSubscriptions = vi.fn(async () => {
throw new Error('server down');
});
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('relay down');
}));
await expect(resyncWebPush({ client, relayBaseUrl: RELAY })).resolves.toBe(false);
});
});
// Spam filed straight into Junk used to wake the device: `EmailDelivery`
// fires for every ingested message. Servers that implement
// draft-ietf-jmap-emailpush evaluate a per-account filter before pushing, so
+45
View File
@@ -646,3 +646,48 @@ export async function isWebPushEnabled(accountId: string): Promise<boolean> {
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null;
}
// Accounts already re-synced during this page load. One pass per account is
// plenty: the subscription only drifts between sessions (expiry, a client
// update that changed what we subscribe to, a recreated Junk mailbox).
const resyncedAccountIds = new Set<string>();
export interface ResyncWebPushParams {
client: IJMAPClient;
relayBaseUrl?: string;
accountLabel?: string;
}
/**
* Bring an already-enabled push registration up to date without any user
* action: refresh its expiry and install/repair the delivery filter. Nothing
* here can prompt - it only runs when push is already on for the account -
* and every failure is swallowed because the app must not care whether the
* background touch-up worked. Returns true when a re-sync actually ran.
*/
export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolean> {
let accountId: string;
try {
accountId = params.client.getAccountId();
} catch {
return false;
}
if (!accountId || resyncedAccountIds.has(accountId)) return false;
try {
if (!(await isWebPushEnabled(accountId))) return false;
resyncedAccountIds.add(accountId);
await enableWebPush({
client: params.client,
relayBaseUrl: params.relayBaseUrl,
accountLabel: params.accountLabel,
});
return true;
} catch {
return false;
}
}
// Test hook: forget which accounts were re-synced during this page load.
export function resetWebPushResyncState(): void {
resyncedAccountIds.clear();
}