Merge pull request #1141 from lucamzanon/feat/push-quiet-window

feat(push): ring once for a burst of mail across accounts
This commit is contained in:
Linus Rath
2026-10-01 11:02:33 +02:00
2 changed files with 127 additions and 1 deletions
@@ -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<string, unknown> };
function loadWorker() {
const listeners = new Map<string, (event: unknown) => void>();
const shade = new Map<string, Shown>();
const cacheStore = new Map<string, string>();
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<string, unknown>) => {
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<string> }) => {
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<unknown>[] = [];
listeners.get('push')?.({
data: { json: () => payload },
waitUntil: (p: Promise<unknown>) => 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 });
});
});
+37 -1
View File
@@ -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