mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user