diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index fe4b57ee4..005258e7d 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -295,4 +295,26 @@ describe('nothing new to say', () => { }); }); +describe('a push naming several accounts, one server slow', () => { + it('does not make the others wait out its deadline', async () => { + const started: string[] = []; + let release = () => {}; + const slow = new Promise((resolve) => { release = resolve; }); + const fetchSlow = vi.fn(async (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + const account = url.searchParams.get('accountId') ?? ''; + started.push(account); + if (account === 'slow') await slow; + return { ok: true, json: async () => ({ unreadTotal: 1, slot: 0, email: mail(`m-${account}`) }) }; + }); + const w = loadWorker(() => null, { globals: { fetch: fetchSlow } }); + const done = w.push({ kind: 'jmap-state-change', accountId: 'slow', changed: { slow: { EmailDelivery: 's' }, fast: { EmailDelivery: 's' } } }); + await vi.waitFor(() => expect(started).toEqual(['slow', 'fast'])); + await vi.waitFor(() => expect(w.shade.has('bulwark-mail:fast')).toBe(true)); + release(); + await done; + expect(w.shade.size).toBe(2); + }); +}); + // -- end of cases -- diff --git a/lib/__tests__/sw-push-subscription-change.test.ts b/lib/__tests__/sw-push-subscription-change.test.ts index 813a8fa69..50eb98290 100644 --- a/lib/__tests__/sw-push-subscription-change.test.ts +++ b/lib/__tests__/sw-push-subscription-change.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; // in a VM with a minimal ServiceWorkerGlobalScope and fire the event. const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); -function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) { +function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown, config: { noThirdPartyTraffic?: boolean } | null = {}) { const listeners = new Map void>(); const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) }; const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription); @@ -23,8 +23,12 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registratio self, atob, caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) }, - fetch: vi.fn(async (url: string, init: { body: string }) => { - relayCalls.push({ url, body: JSON.parse(init.body) }); + fetch: vi.fn(async (url: string, init?: { body: string }) => { + if (url.endsWith('/api/config')) { + if (!config) throw new TypeError('offline'); + return { ok: true, json: async () => config }; + } + relayCalls.push({ url, body: JSON.parse(init!.body) }); return { ok: true }; }), URL, URLSearchParams, Response, Date, console, Uint8Array, @@ -69,6 +73,18 @@ describe('service worker pushsubscriptionchange', () => { expect(relayCalls).toHaveLength(2); }); + it('calls no relay while third-party traffic is off: the page moves it', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, { noThirdPartyTraffic: true }); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + + it('calls no relay when it cannot find out', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, null); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + it('calls no relay for a browser where push was never on', async () => { const { fire, relayCalls, newSubscription } = loadWorker([]); await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); diff --git a/public/sw.js b/public/sw.js index 76e1a767b..47cde4b71 100644 --- a/public/sw.js +++ b/public/sw.js @@ -603,7 +603,9 @@ async function handlePush(event) { 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]) { + // Side by side: each has its own preview deadline, and Safari wants the + // notifications within 30 s of the push. + await Promise.all((withNewMail.length > 0 ? withNewMail : [named]).map(async (accountId) => { try { await notifyAccount(accountId, emailIds, accountLabel); } catch (_) { @@ -617,7 +619,7 @@ async function handlePush(event) { data: { kind: "mail-list", accountId }, }); } - } + })); } async function notifyAccount(accountId, emailIds, accountLabel) { @@ -1000,6 +1002,18 @@ async function readRelayRegistrations() { } } +/** No-third-party mode may have come on since the page left a relay here. Unknown counts as no. */ +async function mayCallRelays() { + try { + const res = await fetch(`${BASE_PATH}/api/config`, { cache: "no-store" }); + if (!res.ok) return false; + const config = await res.json(); + return config.noThirdPartyTraffic !== true; + } catch (_) { + return false; + } +} + function applicationServerKeyOf(base64Url) { const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4); const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); @@ -1031,7 +1045,7 @@ async function handlePushSubscriptionChange(event) { } } } - if (subscription && registrations && typeof subscription.toJSON === "function") { + if (subscription && registrations && typeof subscription.toJSON === "function" && (await mayCallRelays())) { const json = subscription.toJSON(); for (const entry of Object.values(registrations.entries)) { try {