From cd39805566b37d61d6937309ff13931de708cc32 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 1 Oct 2026 04:40:08 +0200 Subject: [PATCH] feat(push): ring once for a burst of mail across accounts Each account has its own notification, so one message reaching several of the user's accounts rang once per account. Only the first alert in a 30 s window makes a sound now; the others still appear or update, silently. The window runs from the last audible alert, so a steady trickle still rings every so often. --- lib/__tests__/sw-push-quiet-window.test.ts | 90 ++++++++++++++++++++++ public/sw.js | 38 ++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/sw-push-quiet-window.test.ts diff --git a/lib/__tests__/sw-push-quiet-window.test.ts b/lib/__tests__/sw-push-quiet-window.test.ts new file mode 100644 index 000000000..be9a7ee5e --- /dev/null +++ b/lib/__tests__/sw-push-quiet-window.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +// public/sw.js is served raw, never bundled, so it cannot be imported. Run it +// in a VM with a minimal ServiceWorkerGlobalScope and drive the push listener. +const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + +type Shown = { tag: string; options: Record }; + +function loadWorker() { + const listeners = new Map void>(); + const shade = new Map(); + 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 ?? ''); + shade.set(tag, { tag, options }); + }, + getNotifications: async () => [], + }, + }; + 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()); + }, + }), + }; + // One unread message per account, whichever account is asked about. + const fetchMock = vi.fn(async (url: string) => { + const id = new URL(url, 'https://mail.example.com').searchParams.get('emailId') ?? 'm'; + return { + ok: true, + json: async () => ({ + unreadTotal: 1, + email: { id, threadId: 't-' + id, from: [{ name: 'Alice' }], subject: 'Hello', preview: '' }, + }), + }; + }); + + const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console }); + 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 }; +} + +const delivery = (accountId: string, id: string) => ({ kind: 'jmap-email-push', accountId, emailIds: [id] }); + +describe('service worker quiet window', () => { + afterEach(() => vi.useRealTimers()); + + it('rings once for a burst across accounts, and again once the window has passed', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-30T10:00:00Z')); + const { push, shade } = loadWorker(); + + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ renotify: true, silent: false }); + + vi.setSystemTime(new Date('2026-09-30T10:00:05Z')); + await push(delivery('b', 'e2')); + // Shown, but without a second sound. + expect(shade.get('bulwark-mail:b')!.options).toMatchObject({ renotify: false, silent: true }); + + // Silent alerts do not extend the window: 31 s after the audible one it rings again. + vi.setSystemTime(new Date('2026-09-30T10:00:31Z')); + await push(delivery('b', 'e3')); + expect(shade.get('bulwark-mail:b')!.options).toMatchObject({ renotify: true, silent: false }); + }); +}); diff --git a/public/sw.js b/public/sw.js index 7d31d609f..bbf13ffc9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -206,6 +206,7 @@ async function handlePush(event) { body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; } + const quiet = await withinQuietWindow(); await self.registration.showNotification(title, { body, // Shared per-account tag: each new push replaces the account's single @@ -216,7 +217,10 @@ async function handlePush(event) { icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, data, - renotify: true, + // Inside the quiet window the notification still appears or updates, + // without a sound. + renotify: !quiet, + silent: quiet, }); const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); @@ -225,6 +229,38 @@ async function handlePush(event) { } } +// 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; the ones that +// follow within the window show up silently. The window runs from the last +// audible alert and is not extended by the silent ones, so a steady trickle +// still rings every so often. +const QUIET_WINDOW_MS = 30_000; + +function lastAlertKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/last-alert`; +} + +/** True when an audible alert went off less than QUIET_WINDOW_MS ago; otherwise records this one. */ +async function withinQuietWindow() { + const now = Date.now(); + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(lastAlertKey()); + const last = res ? Number((await res.json()).at) : 0; + if (last && now - last >= 0 && now - last < QUIET_WINDOW_MS) return true; + await cache.put( + lastAlertKey(), + new Response(JSON.stringify({ at: now }), { + headers: { "Content-Type": "application/json" }, + }), + ); + } catch (_) { + // Without the store every notification alerts, as before. + } + return false; +} + // Per-account list of message ids we have already shown a notification for. // Service workers have no localStorage; the Cache API is the cheapest durable // store that survives the worker being killed between pushes. Failures are