From 16e88e8a1bae9a46d50c0a887a984553cf278e54 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:58:21 +0200 Subject: [PATCH] fix: end every push with a notification so Safari keeps the subscription #1322 --- lib/__tests__/sw-push-silent.test.ts | 113 +++++++++++++++++++++++++++ public/sw.js | 66 ++++++++++++++-- 2 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 lib/__tests__/sw-push-silent.test.ts diff --git a/lib/__tests__/sw-push-silent.test.ts b/lib/__tests__/sw-push-silent.test.ts new file mode 100644 index 000000000..95b5a4220 --- /dev/null +++ b/lib/__tests__/sw-push-silent.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +// #1322: Safari drops the push subscription after three pushes that end +// without showNotification, so every path through the push handler has to +// show something, without a sound when there is nothing new. +const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + +type Shown = { title: string; body?: unknown; data?: unknown; options: Record; close: () => void }; + +type Preview = { unreadTotal: number; email: Record | null }; + +function loadWorker(preview: (url: string, init: { signal?: AbortSignal }) => Promise) { + const listeners = new Map void>(); + const shade = new Map(); + const calls: Array<{ title: string; options: Record }> = []; + const cacheStore = new Map(); + + const self = { + location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, + addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), + skipWaiting: () => {}, + clients: { claim: () => {}, matchAll: async () => [] }, + registration: { + showNotification: async (title: string, options: Record) => { + const tag = String(options.tag ?? ''); + calls.push({ title, options }); + shade.set(tag, { title, body: options.body, data: options.data, options, close: () => shade.delete(tag) }); + }, + getNotifications: async ({ tag }: { tag: string }) => (shade.has(tag) ? [shade.get(tag)!] : []), + }, + }; + const caches = { + open: async () => ({ + match: async (key: string) => { + const value = cacheStore.get(key); + return value === undefined ? undefined : { json: async () => JSON.parse(value) }; + }, + put: async (key: string, res: { text: () => Promise }) => { + cacheStore.set(key, await res.text()); + }, + }), + }; + const fetchMock = vi.fn(async (url: string, init: { signal?: AbortSignal }) => { + const body = await preview(url, init); + return { ok: true, json: async () => body }; + }); + + const context = vm.createContext({ + self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, + AbortController, setTimeout, clearTimeout, + }); + vm.runInContext(SW_SOURCE, context); + + async function push(payload: unknown) { + const waits: Promise[] = []; + listeners.get('push')?.({ + data: { json: () => payload }, + waitUntil: (p: Promise) => waits.push(p), + }); + await Promise.all(waits); + } + return { push, shade, calls }; +} + +const mail = (id: string) => ({ id, threadId: 't-' + id, from: [{ name: 'Alice' }], subject: 'Hello', preview: '' }); +const delivery = (id: string) => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: [id] }); + +describe('service worker never ends a push without a notification (#1322)', () => { + afterEach(() => vi.useRealTimers()); + + it('re-shows the notification silently when a push is redelivered', async () => { + const { push, shade, calls } = loadWorker(async () => ({ unreadTotal: 1, email: mail('e1') })); + await push(delivery('e1')); + expect(calls).toHaveLength(1); + + await push(delivery('e1')); + expect(calls).toHaveLength(2); + expect(calls[1].title).toBe('Alice'); + expect(calls[1].options).toMatchObject({ tag: 'bulwark-mail:a', body: 'Hello', renotify: false, silent: true }); + expect(shade.get('bulwark-mail:a')?.title).toBe('Alice'); + }); + + it('shows and removes a notification when nothing is unread and none is on screen', async () => { + const { push, shade, calls } = loadWorker(async () => ({ unreadTotal: 0, email: null })); + await push({ kind: 'jmap-state-change', changed: { a: {} } }); + expect(calls).toHaveLength(1); + expect(calls[0].options).toMatchObject({ renotify: false, silent: true }); + expect(shade.size).toBe(0); + }); + + it('re-shows silently when the newest unread mail was already announced', async () => { + const { push, calls } = loadWorker(async () => ({ unreadTotal: 1, email: mail('e1') })); + await push(delivery('e1')); + await push({ kind: 'jmap-state-change', changed: { a: {} } }); + expect(calls).toHaveLength(2); + expect(calls[1].options).toMatchObject({ renotify: false, silent: true }); + }); + + it('gives up on a slow preview and shows the generic notification in time', async () => { + vi.useFakeTimers(); + const { push, calls } = loadWorker((_url, init) => new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + })); + const done = push(delivery('e1')); + await vi.advanceTimersByTimeAsync(10_000); + await done; + expect(calls).toHaveLength(1); + expect(calls[0].title).toBe('New mail'); + }); +}); diff --git a/public/sw.js b/public/sw.js index 01bb8bdca..00f9cc8f3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -126,7 +126,9 @@ async function handlePush(event) { // re-buzz for that old message. const notified = await readNotifiedIds(accountId); const freshIds = emailIds.filter((id) => !notified.includes(id)); + const groupTag = "bulwark-mail:" + (accountId || "default"); if (emailIds.length > 0 && freshIds.length === 0) { + await showWithoutAlert(groupTag); return; } @@ -143,16 +145,26 @@ async function handlePush(event) { if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); const qs = query.toString(); const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`; - const res = await fetch(previewUrl, { - credentials: "include", - cache: "no-store", - }); - if (res.ok) { - preview = await res.json(); - previewOk = true; + // A notification shown more than 30 s after the push counts as a silent + // push in Safari (#1322), so a slow preview gives way to the generic one. + const controller = typeof AbortController === "function" ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), PREVIEW_DEADLINE_MS) : null; + try { + const res = await fetch(previewUrl, { + credentials: "include", + cache: "no-store", + ...(controller ? { signal: controller.signal } : {}), + }); + if (res.ok) { + preview = await res.json(); + previewOk = true; + } + } finally { + if (timer) clearTimeout(timer); } } catch (_) { preview = null; + previewOk = false; } const email = preview && preview.email ? preview.email : null; @@ -170,6 +182,7 @@ async function handlePush(event) { // failed (network/auth/server down) we cannot tell, so fall through to the // generic "New mail" toast rather than miss a real delivery. if (previewOk && !email && unreadTotal === 0) { + await showWithoutAlert(groupTag); return; } @@ -177,6 +190,7 @@ async function handlePush(event) { // message is one we already announced, so whatever woke us was not new // Inbox mail - typically a delivery into Junk or a Sieve-filed folder. if (emailIds.length === 0 && previewOk && email && notified.includes(email.id)) { + await showWithoutAlert(groupTag); return; } @@ -186,7 +200,6 @@ async function handlePush(event) { // used to stack 50 toasts). The newest message is the headline and a // Gmail-style "+N more" line carries the rest, counted from the account's // unread total. - const groupTag = "bulwark-mail:" + (accountId || "default"); let title; let body; let data = { kind: "mail-list", accountId, slot }; @@ -234,6 +247,43 @@ async function handlePush(event) { } } +// How long the preview may take before the generic notification is shown. +const PREVIEW_DEADLINE_MS = 10_000; + +/** + * Every push has to end in showNotification. Safari counts a push without one + * as silent and drops the subscription after three (WebKit maxSilentPushCount); + * Firefox and Chrome spend a quota or show a notice of their own. A push with + * nothing new to say (a redelivery, mail already read or already announced) + * re-shows the account's notification as it is, without a sound. With none on + * screen, it shows one and takes it away again at once (#1322). + */ +async function showWithoutAlert(groupTag) { + const options = { + tag: groupTag, + icon: `${BASE_PATH}/api/pwa-icon/192`, + badge: `${BASE_PATH}/api/pwa-icon/192`, + renotify: false, + silent: true, + }; + try { + const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; + if (existing) { + await self.registration.showNotification(existing.title, { + ...options, + body: existing.body, + data: existing.data, + }); + return; + } + await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } }); + const shown = await self.registration.getNotifications({ tag: groupTag }); + for (const notification of shown) notification.close(); + } catch (_) { + // Nothing more a worker can do. + } +} + // One message often lands in several of the user's accounts at once - a list, // a forward, the same newsletter on two logins - and each account has its own // notification. Only the first alert of a burst makes a sound; other accounts'