diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx
index 701882002..d09e7d83b 100644
--- a/components/__tests__/push-notification-prompt.test.tsx
+++ b/components/__tests__/push-notification-prompt.test.tsx
@@ -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();
+ 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();
+ await advancePromptDelay();
+
+ expect(mocks.resyncWebPush).not.toHaveBeenCalled();
+ });
+
it("does not render when browser notifications are blocked", async () => {
setNotificationPermission("denied");
render();
diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx
index 8e5f2af6c..e2de6e1f7 100644
--- a/components/push-notification-prompt.tsx
+++ b/components/push-notification-prompt.tsx
@@ -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 | null = null;
diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts
index adfa7c96d..369266b2a 100644
--- a/lib/__tests__/web-push.test.ts
+++ b/lib/__tests__/web-push.test.ts
@@ -5,6 +5,8 @@ import {
disableWebPush,
enableWebPush,
listPushDevices,
+ resetWebPushResyncState,
+ resyncWebPush,
revokePushDevice,
} from '@/lib/web-push';
@@ -153,6 +155,7 @@ function installFetch(activeByDevice: Record) {
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
diff --git a/lib/web-push.ts b/lib/web-push.ts
index 4c30c2745..48e48ebd5 100644
--- a/lib/web-push.ts
+++ b/lib/web-push.ts
@@ -646,3 +646,48 @@ export async function isWebPushEnabled(accountId: string): Promise {
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();
+
+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 {
+ 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();
+}