diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts new file mode 100644 index 000000000..5999eee6b --- /dev/null +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// What one new message, several at once and several accounts turn into on screen. +const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + +type Preview = Record | null; +type Shown = { title: string; body?: unknown; data?: Record; tag: string; options: Record; close: () => void }; + +function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; openWindow?: (url: string) => Promise } = {}) { + const listeners = new Map void>(); + const shade = new Map(); + const previews: URL[] = []; + const cacheStore = new Map(); + if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs)); + 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 () => extra.clients ?? [], openWindow: extra.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + const tag = String(options.tag ?? ''); + shade.set(tag, { title, body: options.body, data: options.data as Record, tag, options, close: () => shade.delete(tag) }); + }, + getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.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 (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + previews.push(url); + const body = preview(url); + return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) }; + }); + const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals }); + vm.runInContext(SW_SOURCE, context); + async function dispatch(type: string, event: Record) { + const waits: Promise[] = []; + listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); + await Promise.all(waits); + } + const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } }); + return { push, dispatch, shade, previews }; +} + +const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' }); +const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds }); + +afterEach(() => vi.useRealTimers()); + +describe('a state-change push naming several accounts', () => { + const stateChange = (changed: Record>, accountId = Object.keys(changed)[0]) => ({ + kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed, + }); + + it('notifies for each account with new mail', async () => { + const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) })); + await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']); + expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']); + expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' }); + }); + + it('leaves out an account that only changed some other way', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); + + it('still shows something for a ping without new mail in it', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ me: { Mailbox: 's1' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); +}); + +// -- end of cases -- diff --git a/public/sw.js b/public/sw.js index 62a4a7b62..14701f44e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -589,17 +589,26 @@ async function handlePush(event) { // message ids that passed it - spam filed into Junk never gets here. // - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped // as { changed: { [accountId]: {...} } }, fired for every ingested - // message including junk. The relay forwards a single account per push, - // so the first key is the one this notification is for. + // message including junk. Stalwart batches what changes between two + // pushes, so it can name the user's own account and a group's at once. const changed = payload && payload.changed && typeof payload.changed === "object" ? payload.changed : null; - const accountId = (payload && typeof payload.accountId === "string" && payload.accountId) + const named = (payload && typeof payload.accountId === "string" && payload.accountId) || (changed ? Object.keys(changed)[0] || "" : ""); const emailIds = payload && Array.isArray(payload.emailIds) ? payload.emailIds.filter((id) => typeof id === "string" && id) : []; + const withNewMail = emailIds.length > 0 || !changed + ? [] + : Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]); + for (const accountId of withNewMail.length > 0 ? withNewMail : [named]) { + await notifyAccount(accountId, emailIds, accountLabel); + } +} + +async function notifyAccount(accountId, emailIds, accountLabel) { // Never notify twice for the same message. A push can be redelivered (relay // retry, browser replay after coming back online) and, on the older // state-change path, a junk delivery wakes us while an unread message we