import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { AccountInfo, EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types'; import { jmapError, jmapErrorType, jmapSetError } from '@/lib/jmap/jmap-error'; import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import { buildEmailPushConfig, disableWebPush, enableWebPush, isWebPushEnabled, listPushDevices, mailAccountsWithoutPush, enableWebPushForAccounts, resetWebPushResyncState, resyncWebPush, revokePushDevice, waitForWebPushResync, } from '@/lib/web-push'; const RELAY = 'https://relay.example'; const ACCOUNT_ID = 'acct-1'; const THIS_DEVICE = 'aaaaaaaabbbbbbbbccccccccdddddddd'; const OTHER_DEVICE = '11111111222222223333333344444444'; const DEVICE_KEY = `bulwark.push.deviceClientId.v1.${ACCOUNT_ID}`; const SUB_KEY = `bulwark.push.subscriptionId.v1.${ACCOUNT_ID}`; const REFUSED_KEY = `bulwark.push.emailPushRefused.v1.${ACCOUNT_ID}`; function sub( id: string, deviceClientId: string, overrides: Partial = {}, ): JmapPushSubscription { return { id, deviceClientId, url: `${RELAY}/api/push/jmap/${deviceClientId}`, keys: null, expires: new Date(Date.now() + 80 * 24 * 60 * 60 * 1000).toISOString(), types: ['EmailDelivery'], ...overrides, }; } interface FakeClient extends Partial { destroyed: string[]; } const EMAIL_PUSH_CAP = 'urn:ietf:params:jmap:emailpush'; const MAIL_CAP = 'urn:ietf:params:jmap:mail'; const JUNK_ID = 'mb-junk'; // A group the user belongs to: Stalwart counts its members as owners, so the // subscription fans out to it and it belongs in the emailPush map. const GROUP_ACCOUNT_ID = 'acct-group'; const GROUP_JUNK_ID = 'mb-group-junk'; // Another user's account that shared a folder with this user by ACL: never // in the fan-out, and refused in the emailPush map. const ACL_ACCOUNT_ID = 'acct-acl'; // The delivery filter enableWebPush is expected to install for ACCOUNT_ID // given the mailboxes makeClient hands out. const EXPECTED_EMAIL_PUSH: Record = { [ACCOUNT_ID]: { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID] }], }, properties: ['id', 'threadId'], urgency: 'high', }, [GROUP_ACCOUNT_ID]: { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [GROUP_JUNK_ID] }], }, properties: ['id', 'threadId'], urgency: 'high', }, }; // The create flags Stalwart sets in accountCapabilities (to_account_capabilities // in jmap-proto request/capability.rs): true on the user's own account and // their groups, false on accounts shared with them by ACL. 0.16 and 1.0 alike. function collectionCaps(owned: boolean): Record { return { 'urn:ietf:params:jmap:calendars': { maxCalendarsPerEvent: null, mayCreateCalendar: owned }, 'urn:ietf:params:jmap:contacts': { maxAddressBooksPerCard: null, mayCreateAddressBook: owned }, 'urn:ietf:params:jmap:filenode': { mayCreateTopLevelFileNode: owned }, }; } // Stalwart names an account after its principal's address. const ACCOUNT_NAME = 'me@example.com'; const GROUP_NAME = 'team@example.com'; const ACL_NAME = 'colleague@example.com'; const NAMES: Record = { [ACCOUNT_ID]: ACCOUNT_NAME, [GROUP_ACCOUNT_ID]: GROUP_NAME, [ACL_ACCOUNT_ID]: ACL_NAME }; function account(id: string, isPersonal: boolean, isReadOnly: boolean, caps: Record): AccountInfo { return { name: NAMES[id], isPersonal, isReadOnly, accountCapabilities: caps }; } // Stalwart 0.16.16 to 0.16.23 (jmap/src/api/session.rs): every secondary // account lists emailpush, ACL shares included, and all of them are // isPersonal:false, isReadOnly:false. const SESSION_0_16: Record = { [ACCOUNT_ID]: account(ACCOUNT_ID, true, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }), [GROUP_ACCOUNT_ID]: account(GROUP_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }), [ACL_ACCOUNT_ID]: account(ACL_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(false) }), }; // Stalwart 0.16.6 to 0.16.15: no emailpush anywhere. const SESSION_0_16_15: Record = { [ACCOUNT_ID]: account(ACCOUNT_ID, true, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }), [GROUP_ACCOUNT_ID]: account(GROUP_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }), [ACL_ACCOUNT_ID]: account(ACL_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, ...collectionCaps(false) }), }; // Stalwart 1.0: accounts the user isn't a member of lose submission, // vacationresponse, sieve, quota and emailpush; isReadOnly is computed (true // for a read-only share); every account lists principals:owner. const owner = (principalId: string) => ({ 'urn:ietf:params:jmap:principals:owner': { accountIdForPrincipal: ACCOUNT_ID, principalId }, }); const SESSION_1_0: Record = { [ACCOUNT_ID]: account(ACCOUNT_ID, true, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(ACCOUNT_ID), }), [GROUP_ACCOUNT_ID]: account(GROUP_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(GROUP_ACCOUNT_ID), }), [ACL_ACCOUNT_ID]: account(ACL_ACCOUNT_ID, false, true, { [MAIL_CAP]: {}, ...collectionCaps(false), ...owner(ACL_ACCOUNT_ID) }), }; // A group member whose role has no calendars, contacts or files: the group's // account carries no create flag to tell it from an ACL share. const MAIL_ONLY_GROUP_0_16 = { ...SESSION_0_16, [GROUP_ACCOUNT_ID]: account(GROUP_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {} }) }; const MAIL_ONLY_GROUP_1_0 = { ...SESSION_1_0, [GROUP_ACCOUNT_ID]: account(GROUP_ACCOUNT_ID, false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...owner(GROUP_ACCOUNT_ID) }), }; // Stalwart's refusal of an emailPush map naming a non-member (push/set.rs // parse_email_push), as client.ts throws it for notCreated.new / notUpdated. const FORBIDDEN_MAP = { type: 'forbidden', description: 'No access to one of the accounts in the emailPush map.' }; const OVER_QUOTA = { type: 'overQuota', description: 'There are too many subscriptions, please delete some before adding a new one.', }; const refusedMap = () => jmapSetError(`PushSubscription/set failed: ${FORBIDDEN_MAP.description}`, FORBIDDEN_MAP); function makeClient( subs: JmapPushSubscription[], options: { emailPushCapability?: boolean; // The session's accounts; the primary and the group by default. accounts?: Record; // Accounts the server accepts in an emailPush map: Stalwart's is_member, // the user's own account and their groups. members?: string[]; // The addresses of the user's identities. Stalwart 0.16 gives a group's // members one with the group's address. identities?: string[]; } = {}, ): FakeClient & IJMAPClient { const destroyed: string[] = []; const accounts = options.accounts ?? { [ACCOUNT_ID]: SESSION_0_16[ACCOUNT_ID], [GROUP_ACCOUNT_ID]: SESSION_0_16[GROUP_ACCOUNT_ID], }; const members = options.members ?? [ACCOUNT_ID, GROUP_ACCOUNT_ID]; const identities = options.identities ?? [ACCOUNT_NAME, GROUP_NAME]; const accept = (emailPush?: Record | null) => { if (emailPush && Object.keys(emailPush).some((id) => !members.includes(id))) throw refusedMap(); }; const mailbox = (id: string, role: string | undefined, accountId: string) => ({ id: accountId === ACCOUNT_ID ? id : `${accountId}:${id}`, originalId: id, name: role ?? id, role, accountId, isShared: accountId !== ACCOUNT_ID, }); const client = { destroyed, getAccountId: () => ACCOUNT_ID, getCapabilities: () => (options.emailPushCapability ? { [EMAIL_PUSH_CAP]: {} } : {}), hasAccountCapability: (capability: string, accountId?: string) => capability in (accounts[accountId || ACCOUNT_ID]?.accountCapabilities ?? {}), getAccountInfo: (accountId: string) => accounts[accountId] ?? null, getIdentities: vi.fn(async () => identities.map((email, i) => ({ id: `identity-${i}`, name: email, email, mayDelete: i > 0 }))), getAllMailboxes: vi.fn(async () => [ mailbox('mb-inbox', 'inbox', ACCOUNT_ID), mailbox(JUNK_ID, 'junk', ACCOUNT_ID), mailbox('mb-archive', undefined, ACCOUNT_ID), mailbox('mb-group-inbox', 'inbox', GROUP_ACCOUNT_ID), mailbox(GROUP_JUNK_ID, 'junk', GROUP_ACCOUNT_ID), ...(accounts[ACL_ACCOUNT_ID] ? [mailbox('mb-acl-projects', undefined, ACL_ACCOUNT_ID)] : []), ]), listPushSubscriptions: vi.fn(async () => subs.filter((s) => !destroyed.includes(s.id))), createPushSubscription: vi.fn(async (params: { emailPush?: Record }) => { accept(params.emailPush); return 'push-new'; }), verifyPushSubscription: vi.fn(async () => undefined), updatePushSubscription: vi.fn(async (_id: string, patch: { emailPush?: Record | null }) => { accept(patch.emailPush); }), destroyPushSubscription: vi.fn(async (id: string) => { destroyed.push(id); }), }; return client as unknown as FakeClient & IJMAPClient; } // The accounts an emailPush map names, sorted. const mapKeys = (emailPush: Record | null | undefined) => Object.keys(emailPush ?? {}).sort(); const createCalls = (client: IJMAPClient) => (client.createPushSubscription as ReturnType).mock.calls.map( (c) => c[0] as { emailPush?: Record }, ); const updateCalls = (client: IJMAPClient) => (client.updatePushSubscription as ReturnType).mock.calls.map( (c) => c[1] as { emailPush?: Record }, ); // enableWebPush needs a browser that supports push; jsdom has none of it. function installPushBrowser() { const browserSub = { endpoint: 'https://fcm.example/endpoint', // The key installFetch's relay advertises ('QUJD' = "ABC"), so the // browser subscription belongs to that relay unless a test says otherwise. options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer as ArrayBuffer | null }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true), }; const registration = { pushManager: { getSubscription: vi.fn(async () => browserSub), subscribe: vi.fn(async () => browserSub), }, }; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: vi.fn(async () => registration), register: vi.fn(async () => registration), ready: Promise.resolve(registration), }, }); (window as unknown as { PushManager: unknown }).PushManager = class {}; (window as unknown as { Notification: unknown }).Notification = { permission: 'granted' }; (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'granted' }; return { browserSub, registration }; } // Relay endpoints enableWebPush walks through, plus a per-device liveness answer. function installFetch(activeByDevice: Record) { const calls: { url: string; method: string }[] = []; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); calls.push({ url, method: init?.method ?? 'GET' }); const json = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body, }) as Response; if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); if (url.includes('/api/push/verify/')) return json({ verificationCode: 'code-123' }); if (url.includes('/api/push/active/')) { const device = url.split('/api/push/active/')[1]; const state = activeByDevice[device]; if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404); return json({ active: state }); } return json({}); }); vi.stubGlobal('fetch', fetchMock); return calls; } beforeEach(() => { localStorage.clear(); resetWebPushResyncState(); installPushBrowser(); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('enableWebPush', () => { it('reuses the recorded subscription by default', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(client.destroyed).toEqual([]); }); it('destroys and recreates the recorded subscription with forceRecreate', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); expect(client.destroyed).toContain('push-old'); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123'); expect(result.subscriptionId).toBe('push-new'); expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); // The old record keeps delivering until its replacement is verified. const destroy = client.destroyPushSubscription as ReturnType; const verify = client.verifyPushSubscription as ReturnType; const destroyOld = destroy.mock.invocationCallOrder[destroy.mock.calls.findIndex((c) => c[0] === 'push-old')]; expect(destroyOld).toBeGreaterThan(verify.mock.invocationCallOrder[0]); }); // Stalwart refuses a 16th subscription (the default limit is 15) and counts // the one Re-register replaces, which is only destroyed after its // replacement is verified. const overQuota = () => jmapSetError(`PushSubscription/set create failed: ${OVER_QUOTA.description}`, OVER_QUOTA); it('frees the recorded subscription\'s place and retries once when Re-register hits the limit', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)]); const create = vi.fn() .mockRejectedValueOnce(overQuota()) .mockResolvedValueOnce('push-new'); client.createPushSubscription = create as unknown as IJMAPClient['createPushSubscription']; installFetch({}); const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); expect(create).toHaveBeenCalledTimes(2); const destroy = client.destroyPushSubscription as ReturnType; expect(destroy.mock.calls).toEqual([['push-old']]); expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan(create.mock.invocationCallOrder[0]); expect(destroy.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[1]); }); it('reports the limit and leaves push off when the retry is refused too', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)]); client.createPushSubscription = vi.fn(async () => { throw overQuota(); }) as unknown as IJMAPClient['createPushSubscription']; installFetch({}); const err = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }).catch((e) => e); expect(jmapErrorType(err)).toBe('overQuota'); expect(client.createPushSubscription).toHaveBeenCalledTimes(2); expect(client.destroyed).toEqual(['push-old']); expect(localStorage.getItem(SUB_KEY)).toBeNull(); }); it('keeps the working subscription at the limit when no Re-register was asked for', async () => { // Every map is refused on refresh, so a replacement is created, and the // server has no room for it: the old record keeps delivering. localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_0_16, members: [], }); client.createPushSubscription = vi.fn(async () => { throw overQuota(); }) as unknown as IJMAPClient['createPushSubscription']; installFetch({}); const err = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e) => e); expect(jmapErrorType(err)).toBe('overQuota'); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); expect(client.destroyed).toEqual([]); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('leaves another device alone when the relay cannot vouch for it', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([sub('push-other', OTHER_DEVICE)]); installFetch({ [OTHER_DEVICE]: 'unknown' }); await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); expect(client.destroyed).not.toContain('push-other'); }); it('reuses a browser subscription made with this relay key', async () => { const { browserSub, registration } = installPushBrowser(); installFetch({}); await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false }); expect(browserSub.unsubscribe).not.toHaveBeenCalled(); expect(registration.pushManager.subscribe).not.toHaveBeenCalled(); }); it('replaces a browser subscription made with another relay key', async () => { const { browserSub, registration } = installPushBrowser(); browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer; const calls = installFetch({}); await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false }); expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); const [{ applicationServerKey }] = registration.pushManager.subscribe.mock.calls[0] as unknown as [ { applicationServerKey: Uint8Array }, ]; expect(Array.from(applicationServerKey)).toEqual([65, 66, 67]); expect(calls.some((c) => c.url.endsWith('/api/push/register/web'))).toBe(true); }); it('subscribes anyway when the browser cannot retrieve the existing subscription', async () => { // Firefox rejects the lookup with `AbortError: Error retrieving push // subscription` when its local record is stale; that must not abort enable. const { registration } = installPushBrowser(); registration.pushManager.getSubscription.mockRejectedValue( new DOMException('Error retrieving push subscription', 'AbortError'), ); installFetch({}); const result = await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false }); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); expect(result.subscriptionId).toBe('push-new'); }); it('still subscribes when unsubscribing the mismatched subscription fails', async () => { const { browserSub, registration } = installPushBrowser(); browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer; browserSub.unsubscribe.mockRejectedValue(new Error('unsubscribe failed')); installFetch({}); await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false }); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); }); it('replaces a browser subscription whose key the browser does not expose', async () => { const { browserSub, registration } = installPushBrowser(); browserSub.options.applicationServerKey = null; installFetch({}); await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false }); expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); }); }); // Registrations made before the client learned about the delivery filter are // repaired in the background on app start - nobody should have to find the // settings toggle to stop the spam pushes. describe('resyncWebPush', () => { it('repairs a lost browser subscription for an account that previously opted in', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const { registration } = installPushBrowser(); registration.pushManager.getSubscription.mockResolvedValue(null as never); const client = makeClient([sub('push-old', THIS_DEVICE)]); const calls = installFetch({}); expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(false); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/web`, method: 'POST' }); }); it.each(['default', 'denied'])('does not resubscribe or prompt with %s permission', async (permission) => { localStorage.setItem(SUB_KEY, 'push-old'); const { registration } = installPushBrowser(); vi.stubGlobal('Notification', { permission, requestPermission: vi.fn() }); const client = makeClient([sub('push-old', THIS_DEVICE)]); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(registration.pushManager.subscribe).not.toHaveBeenCalled(); expect(Notification.requestPermission).not.toHaveBeenCalled(); }); it('allows retry after a transient relay failure', async () => { localStorage.setItem(SUB_KEY, 'push-old'); localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([sub('push-old', THIS_DEVICE)]); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); installFetch({}); // Not on the very next tab focus... expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); // ...but a quarter of an hour later. const later = Date.now() + 16 * 60_000; vi.spyOn(Date, 'now').mockReturnValue(later); try { expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); } finally { vi.mocked(Date.now).mockRestore(); } }); it('keeps the opt-in when recreating the subscription fails', async () => { localStorage.setItem(SUB_KEY, 'push-old'); localStorage.setItem(DEVICE_KEY, THIS_DEVICE); // The server lost the old subscription, and creating a new one fails. const client = makeClient([]); vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('server down')); installFetch({}); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('re-syncs an enabled registration and installs the missing filter', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(client.updatePushSubscription).toHaveBeenCalledWith( 'push-old', expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }), ); }); it('does nothing when push is not enabled for the account', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([], { emailPushCapability: true }); installFetch({}); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.listPushSubscriptions).not.toHaveBeenCalled(); expect(client.createPushSubscription).not.toHaveBeenCalled(); }); it('runs at most once a day per account', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1); // A tab left open past a day renews again, before Stalwart's 7-day expiry. now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2); now.mockRestore(); }); it('repairs a subscription the browser dropped while the tab stayed open, inside the daily limit (#1321)', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const { registration } = installPushBrowser(); const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(registration.pushManager.subscribe).not.toHaveBeenCalled(); // Safari cleared the site data: the browser subscription is gone, the // saved opt-in is not. The next return to the tab puts it back. registration.pushManager.getSubscription.mockResolvedValue(null as never); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); }); it('does not retry a failed repair on every return to the tab', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const { registration } = installPushBrowser(); registration.pushManager.getSubscription.mockResolvedValue(null as never); const client = makeClient([sub('push-old', THIS_DEVICE)]); const fetchMock = vi.fn().mockRejectedValue(new Error('offline')); vi.stubGlobal('fetch', fetchMock); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('runs one re-sync at a time per login and lets callers wait for it (#1321)', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const { registration, browserSub } = installPushBrowser(); registration.pushManager.getSubscription.mockResolvedValue(null as never); // The browser is still talking to the push service. let subscribed!: () => void; registration.pushManager.subscribe.mockReturnValueOnce( new Promise((resolve) => { subscribed = () => resolve(browserSub); }) as never, ); const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); const first = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); // A second call meanwhile - the tab came back to the foreground - joins // the running one instead of racing it on the shared subscription. const second = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); let settled = false; const waited = waitForWebPushResync(ACCOUNT_ID).then(() => { settled = true; }); await new Promise((r) => setTimeout(r, 0)); expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(false); expect(settled).toBe(false); expect(client.listPushSubscriptions).not.toHaveBeenCalled(); registration.pushManager.getSubscription.mockResolvedValue(browserSub); subscribed(); expect(await Promise.all([first, second])).toEqual([true, true]); await waited; expect(settled).toBe(true); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1); expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(true); // Nothing running: settles at once. await expect(waitForWebPushResync(ACCOUNT_ID)).resolves.toBeUndefined(); }); it('queues a forced update behind the running re-sync instead of racing it', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const { registration, browserSub } = installPushBrowser(); registration.pushManager.getSubscription.mockResolvedValue(null as never); let subscribed!: () => void; registration.pushManager.subscribe.mockReturnValueOnce( new Promise((resolve) => { subscribed = () => resolve(browserSub); }) as never, ); const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); const running = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const forced = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, force: true }); await new Promise((r) => setTimeout(r, 0)); expect(client.listPushSubscriptions).not.toHaveBeenCalled(); subscribed(); expect(await Promise.all([running, forced])).toEqual([true, true]); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2); }); it('swallows failures instead of surfacing them to the app', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)]); client.listPushSubscriptions = vi.fn(async () => { throw new Error('server down'); }); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('relay down'); })); await expect(resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).resolves.toBe(false); }); }); // Spam filed straight into Junk used to wake the device: `EmailDelivery` // fires for every ingested message. Servers that implement // draft-ietf-jmap-emailpush evaluate a per-account filter before pushing, so // the subscription carries one that excludes $junk and the Junk mailbox. describe('enableWebPush delivery filter (emailPush)', () => { it('installs a junk-excluding filter for every account when the server supports it', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([], { emailPushCapability: true }); installFetch({}); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); const params = (client.createPushSubscription as ReturnType).mock.calls[0][0]; expect(params.types).toEqual(['EmailDelivery']); expect(params.emailPush).toEqual(EXPECTED_EMAIL_PUSH); }); it('sends no emailPush to a server without the capability', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([]); installFetch({}); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const params = (client.createPushSubscription as ReturnType).mock.calls[0][0]; expect(params).not.toHaveProperty('emailPush'); expect(client.getAllMailboxes).not.toHaveBeenCalled(); }); it('adds the filter to an existing subscription that predates it', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(client.updatePushSubscription).toHaveBeenCalledWith( 'push-old', expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }), ); }); it('re-syncs a filter whose Junk mailbox id went stale', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const stale = { ...EXPECTED_EMAIL_PUSH, [ACCOUNT_ID]: { ...EXPECTED_EMAIL_PUSH[ACCOUNT_ID], filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['mb-junk-deleted'] }], }, }, }; const client = makeClient( [sub('push-old', THIS_DEVICE, { emailPush: stale })], { emailPushCapability: true }, ); installFetch({}); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.updatePushSubscription).toHaveBeenCalledWith( 'push-old', expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }), ); }); it('leaves a fresh subscription alone when its filter already matches', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); // Key order differs from what we build - must still compare equal. const stored = JSON.parse(JSON.stringify(EXPECTED_EMAIL_PUSH)); stored[ACCOUNT_ID] = { urgency: 'high', properties: ['id', 'threadId'], filter: stored[ACCOUNT_ID].filter, }; const client = makeClient( [sub('push-old', THIS_DEVICE, { emailPush: stored })], { emailPushCapability: true }, ); installFetch({}); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.updatePushSubscription).not.toHaveBeenCalled(); }); }); // Inbox-only mode swaps the "not in Junk" mailbox condition for a positive // "in Inbox" one, so mail a Sieve rule files into any other folder never // reaches the push subscription. describe('buildEmailPushConfig inbox-only mode', () => { it('keeps current behaviour when inboxOnly is false', async () => { const client = makeClient([], { emailPushCapability: true }); expect(await buildEmailPushConfig(client, false)).toEqual(EXPECTED_EMAIL_PUSH); }); it('requires the message to be in each account Inbox when inboxOnly is true', async () => { const client = makeClient([], { emailPushCapability: true }); const config = await buildEmailPushConfig(client, true); expect(config[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], }); expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-group-inbox' }], }); }); it('throws when an account has no discoverable Inbox mailbox', async () => { const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => [ { id: JUNK_ID, originalId: JUNK_ID, name: 'junk', role: 'junk', accountId: ACCOUNT_ID }, ]) as unknown as IJMAPClient['getAllMailboxes']; await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/No Inbox mailbox/); }); it('mutes a group account whose Inbox we cannot see instead of failing', async () => { // The group's Inbox isn't visible to us, only a folder. The server fans // the subscription out to the group anyway, so leaving it out of the map // would let it push the group unfiltered. const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => [ { id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID }, { id: `${GROUP_ACCOUNT_ID}:mb-proj`, originalId: 'mb-proj', name: 'Projects', accountId: GROUP_ACCOUNT_ID }, ]) as unknown as IJMAPClient['getAllMailboxes']; const config = await buildEmailPushConfig(client, true); expect(config[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], }); expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], }); }); it('rethrows a mailbox-fetch failure in inbox-only mode instead of reporting no Inbox', async () => { const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => { throw new Error('JMAP down'); }) as unknown as IJMAPClient['getAllMailboxes']; await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/JMAP down/); }); it('still emits a filter for an account that has an Inbox but no Junk mailbox', async () => { const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => [ { id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID }, { id: `${GROUP_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: GROUP_ACCOUNT_ID }, ]) as unknown as IJMAPClient['getAllMailboxes']; const config = await buildEmailPushConfig(client, false); expect(Object.keys(config).sort()).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }], }); }); }); describe('buildEmailPushConfig with a mailbox shared by ACL', () => { it('leaves the ACL share out on Stalwart 1.0, which lists emailpush only on owned accounts', async () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 }); const config = await buildEmailPushConfig(client, false); expect(mapKeys(config)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); }); it('keeps the ACL share on Stalwart 0.16, which lists emailpush on every account', async () => { // enableWebPush narrows the map once the server refuses it. const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 }); const config = await buildEmailPushConfig(client, false); expect(mapKeys(config)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); }); }); // Stalwart refuses the whole emailPush map as `forbidden` once it names a // mailbox shared by ACL. A refused refresh used to destroy the working // subscription, and the replacement was refused too, so push went off. describe('enableWebPush with a mailbox shared by ACL', () => { const healthy = () => sub('push-old', THIS_DEVICE); beforeEach(() => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); installFetch({}); }); it('narrows a refused map on refresh instead of destroying the working subscription on Stalwart 0.16', async () => { localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); const updates = updateCalls(client); expect(updates).toHaveLength(2); expect(mapKeys(updates[0].emailPush)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); // The group keeps its junk filter; only the ACL share is dropped. expect(mapKeys(updates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]); }); it('leaves a remembered refusal out of the map on the next run on Stalwart 0.16', async () => { localStorage.setItem(SUB_KEY, 'push-old'); localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID])); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 }); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const updates = updateCalls(client); expect(updates).toHaveLength(1); expect(mapKeys(updates[0].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); }); it('makes no refused call at all once the stored map matches on Stalwart 0.16', async () => { localStorage.setItem(SUB_KEY, 'push-old'); localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID])); const client = makeClient( [sub('push-old', THIS_DEVICE, { emailPush: EXPECTED_EMAIL_PUSH })], { emailPushCapability: true, accounts: SESSION_0_16 }, ); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.updatePushSubscription).not.toHaveBeenCalled(); }); it('falls back to the primary account alone when the group is refused too on Stalwart 0.16', async () => { localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16, members: [ACCOUNT_ID], }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); const updates = updateCalls(client); expect(updates).toHaveLength(3); expect(mapKeys(updates[2].emailPush)).toEqual([ACCOUNT_ID]); expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!).sort()).toEqual([ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); }); it('narrows a refused map when creating a subscription on Stalwart 0.16', async () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); const creates = createCalls(client); expect(creates).toHaveLength(2); expect(mapKeys(creates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]); }); it('keeps a group without create flags in the narrowed map on Stalwart 0.16, going by its identity', async () => { // Dropping the group would not silence it (the server still fans out to // it) but would take its junk filter away. const client = makeClient([], { emailPushCapability: true, accounts: MAIL_ONLY_GROUP_0_16 }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); const creates = createCalls(client); expect(creates).toHaveLength(2); expect(mapKeys(creates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]); }); it('asks for the identities only once the server refused a map on Stalwart 0.16', async () => { localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID])); const client = makeClient([], { emailPushCapability: true, accounts: MAIL_ONLY_GROUP_0_16 }); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(createCalls(client)).toHaveLength(1); expect(client.getIdentities).not.toHaveBeenCalled(); }); it('ignores refusals remembered under 0.16 once the server is Stalwart 1.0', async () => { // A group dropped as collateral on 0.16 belongs in the map again. localStorage.setItem(REFUSED_KEY, JSON.stringify([GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])); const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 }); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const creates = createCalls(client); expect(creates).toHaveLength(1); expect(creates[0].emailPush).toEqual(EXPECTED_EMAIL_PUSH); }); it('falls back to the primary account alone when Stalwart 1.0 refuses the map, with no identity lookup', async () => { // A membership that ended after the session was read: the capability map // is refused, and nothing on 1.0 makes a better guess than the primary. const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0, members: [ACCOUNT_ID] }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); const creates = createCalls(client); expect(creates.map((c) => mapKeys(c.emailPush))).toEqual([[ACCOUNT_ID, GROUP_ACCOUNT_ID].sort(), [ACCOUNT_ID]]); expect(client.getIdentities).not.toHaveBeenCalled(); }); it('leaves the ACL share out without a refused call on Stalwart 1.0', async () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 }); const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); const creates = createCalls(client); expect(creates).toHaveLength(1); expect(creates[0].emailPush).toEqual(EXPECTED_EMAIL_PUSH); expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); it('sends no map and never narrows on Stalwart 0.16.15, which has no emailpush', async () => { const client = makeClient([], { emailPushCapability: false, accounts: SESSION_0_16_15 }); await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const creates = createCalls(client); expect(creates).toHaveLength(1); expect(creates[0]).not.toHaveProperty('emailPush'); expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); it('does not narrow the map on overQuota', async () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 }); client.createPushSubscription = vi.fn(async () => { throw jmapSetError(`PushSubscription/set create failed: ${OVER_QUOTA.description}`, OVER_QUOTA); }) as unknown as IJMAPClient['createPushSubscription']; const err = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e) => e); expect(jmapErrorType(err)).toBe('overQuota'); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); it('does not narrow the map when the whole method call is refused', async () => { // A method-level `forbidden` (the account may not use push at all) is // not about the map: a narrower one fails the same way. localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 }); client.updatePushSubscription = vi.fn(async () => { throw jmapError('PushSubscription/set failed: forbidden', { type: 'forbidden' }); }) as unknown as IJMAPClient['updatePushSubscription']; await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(); expect(client.updatePushSubscription).toHaveBeenCalledTimes(1); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('forgets remembered refusals on forceRecreate and learns them again', async () => { localStorage.setItem(SUB_KEY, 'push-old'); localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID])); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 }); await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); const creates = createCalls(client); expect(mapKeys(creates[0].emailPush)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(mapKeys(creates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort()); expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]); }); it('keeps the working subscription and its key when the replacement is refused', async () => { localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16, members: [], }); const err = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }) .catch((e) => e); expect(jmapErrorType(err)).toBe('forbidden'); expect(client.destroyed).not.toContain('push-old'); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('replaces a subscription whose refresh the server refuses, and destroys the old one after verification', async () => { // `notFound`: the server dropped the record between the listing and the // update. localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_1_0 }); client.updatePushSubscription = vi.fn(async () => { throw jmapSetError('PushSubscription/set update failed: notFound', { type: 'notFound' }); }) as unknown as IJMAPClient['updatePushSubscription']; const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-new'); expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); const destroy = client.destroyPushSubscription as ReturnType; const verify = client.verifyPushSubscription as ReturnType; expect(destroy).toHaveBeenCalledWith('push-old'); expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan(verify.mock.invocationCallOrder[0]); }); }); // Only a verified replacement, the user turning push off, or the server no // longer holding the subscription may end it. A failure that says nothing // about the subscription must leave it and its key alone. describe('enableWebPush when the server cannot be asked', () => { beforeEach(() => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); installFetch({}); }); it.each([ ['a network error', () => new TypeError('Failed to fetch')], ['a timeout', () => new RequestTimeoutError(30_000)], ['rate limiting', () => new RateLimitError(60_000)], ])('destroys nothing when the refresh fails with %s', async (_label, failure) => { const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_1_0 }); client.updatePushSubscription = vi.fn(async () => { throw failure(); }) as unknown as IJMAPClient['updatePushSubscription']; await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(); expect(client.updatePushSubscription).toHaveBeenCalledTimes(1); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('stops without touching local state when the subscriptions cannot be listed', async () => { const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); client.listPushSubscriptions = vi.fn(async () => { throw new TypeError('Failed to fetch'); }) as unknown as IJMAPClient['listPushSubscriptions']; await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); }); it('keeps push enabled after a failed re-sync', async () => { // The worst case: the server refuses every map and the replacement too. const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_0_16, members: [], }); expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.destroyed).not.toContain('push-old'); expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(true); }); it('keeps push enabled after a re-sync that could not reach the server', async () => { const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_1_0 }); client.updatePushSubscription = vi.fn(async () => { throw new TypeError('Failed to fetch'); }) as unknown as IJMAPClient['updatePushSubscription']; expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.destroyPushSubscription).not.toHaveBeenCalled(); expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(true); }); }); // The settings pane tells users that mailboxes shared with them stay silent // in the background (Stalwart never fans push out to them). describe('mailAccountsWithoutPush', () => { it('names the account without emailpush on Stalwart 1.0, and nothing else', () => { // Groups count as owned even without the create flags: on 1.0 the // capability list is exact. const client = makeClient([], { emailPushCapability: true, accounts: MAIL_ONLY_GROUP_1_0 }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]); }); it('trusts the capability alone on Stalwart 1.0: no guess from create flags, no remembered refusal', () => { // Only a group is shared with this user. Its account lists emailpush, so // it gets push, whatever its create flags or a list kept under 0.16 say. localStorage.setItem(REFUSED_KEY, JSON.stringify([GROUP_ACCOUNT_ID])); const client = makeClient([], { emailPushCapability: true, accounts: MAIL_ONLY_GROUP_1_0, identities: [ACCOUNT_NAME] }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID])).toEqual([]); }); it('does not name a group of the user that has no create flags on Stalwart 0.16', () => { // The user's identities include the group's address; an ACL share's // owner address never is one of them. const client = makeClient([], { emailPushCapability: true, accounts: MAIL_ONLY_GROUP_0_16 }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID], [ACCOUNT_NAME, 'Team@Example.com'])) .toEqual([ACL_ACCOUNT_ID]); // Without the group's identity it cannot be told from an ACL share. expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID], [ACCOUNT_NAME])) .toEqual([GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID]); }); it('names the account that does not look owned on Stalwart 0.16', () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]); }); it('names the accounts the server refused on Stalwart 0.16', () => { localStorage.setItem(REFUSED_KEY, JSON.stringify([GROUP_ACCOUNT_ID])); const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID, ]); }); it('names the account that does not look owned on Stalwart 0.16.15', () => { const client = makeClient([], { emailPushCapability: false, accounts: SESSION_0_16_15 }); expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]); }); it('is empty when every mailbox is the user\'s own or a group\'s', () => { const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 }); expect(mailAccountsWithoutPush(client, [ACCOUNT_ID, GROUP_ACCOUNT_ID, GROUP_ACCOUNT_ID])).toEqual([]); expect(mailAccountsWithoutPush(client, [])).toEqual([]); }); }); // Muted accounts and folders (#1169) are left out by the server-side filter. describe('buildEmailPushConfig with muted folders and accounts', () => { const mutes = (accounts: string[], mailboxes: Record = {}) => ({ accounts, mailboxes }); it('adds a muted folder to the excluded mailboxes next to Junk', async () => { const client = makeClient([], { emailPushCapability: true }); const config = await buildEmailPushConfig(client, false, mutes([], { [`${ACCOUNT_ID}/mb-archive`]: true })); expect(config[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID, 'mb-archive'].sort() }], }); // Another account's folders are untouched. expect(config[GROUP_ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[GROUP_ACCOUNT_ID]); }); it('silences a muted account entirely', async () => { const client = makeClient([], { emailPushCapability: true }); const config = await buildEmailPushConfig(client, false, mutes([GROUP_ACCOUNT_ID])); expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], }); expect(config[ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[ACCOUNT_ID]); }); it('lets a muted account notify for the folders unmuted again', async () => { const client = makeClient([], { emailPushCapability: true }); const one = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], { [`${ACCOUNT_ID}/mb-archive`]: false })); expect(one[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-archive' }], }); const two = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], { [`${ACCOUNT_ID}/mb-inbox`]: false, [`${ACCOUNT_ID}/mb-archive`]: false, // Gone from the server: must not end up in the filter. [`${ACCOUNT_ID}/mb-deleted`]: false, })); expect(two[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [ { notKeyword: '$junk' }, { operator: 'OR', conditions: [{ inMailbox: 'mb-archive' }, { inMailbox: 'mb-inbox' }] }, ], }); }); it('silences the account in inbox-only mode when its Inbox is muted', async () => { const client = makeClient([], { emailPushCapability: true }); const config = await buildEmailPushConfig(client, true, mutes([], { [`${GROUP_ACCOUNT_ID}/mb-group-inbox`]: true })); expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], }); expect(config[ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], }); }); it('fails instead of dropping the mutes when the mailboxes cannot be read', async () => { const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => { throw new Error('JMAP down'); }) as unknown as IJMAPClient['getAllMailboxes']; await expect(buildEmailPushConfig(client, false, mutes([ACCOUNT_ID]))).rejects.toThrow(/JMAP down/); }); }); describe('disableWebPush', () => { it('destroys every subscription this device owns, not just the recorded id', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-tracked'); const client = makeClient([ sub('push-tracked', THIS_DEVICE), // Left behind by an earlier enable whose destroy never landed. sub('push-orphan', THIS_DEVICE), sub('push-other', OTHER_DEVICE), ]); installFetch({}); await disableWebPush({ client, relayBaseUrl: RELAY }); expect(client.destroyed.sort()).toEqual(['push-orphan', 'push-tracked']); expect(localStorage.getItem(SUB_KEY)).toBeNull(); // The deviceClientId survives so a later enable reuses the relay record. expect(localStorage.getItem(DEVICE_KEY)).toBe(THIS_DEVICE); }); it('forgets the accounts the server refused, so enabling again starts from the full map', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-tracked'); localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID])); const client = makeClient([sub('push-tracked', THIS_DEVICE)]); installFetch({}); await disableWebPush({ client, relayBaseUrl: RELAY }); expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); it('drops the relay registration for this device', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([]); const calls = installFetch({}); await disableWebPush({ client, relayBaseUrl: RELAY }); expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE', }); }); }); describe('listPushDevices', () => { it('flags this device and reports the relay status of each registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([ sub('push-mine', THIS_DEVICE), sub('push-live', OTHER_DEVICE), sub('push-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), sub('push-foreign', 'ffffffffffffffffffffffffffffffff'), ]); installFetch({ [THIS_DEVICE]: true, [OTHER_DEVICE]: true, deaddeaddeaddeaddeaddeaddeaddead: false, }); const devices = await listPushDevices({ client, relayBaseUrl: RELAY }); expect(devices.map((d) => [d.id, d.isThisDevice, d.relayStatus])).toEqual([ ['push-mine', true, 'active'], ['push-live', false, 'active'], ['push-dead', false, 'inactive'], ['push-foreign', false, 'unknown'], ]); }); }); describe('revokePushDevice', () => { it('destroys another device and frees its relay registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-mine'); const client = makeClient([sub('push-mine', THIS_DEVICE), sub('push-other', OTHER_DEVICE)]); const calls = installFetch({}); await revokePushDevice({ client, relayBaseUrl: RELAY, device: { id: 'push-other', deviceClientId: OTHER_DEVICE, isThisDevice: false }, }); expect(client.destroyed).toEqual(['push-other']); expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${OTHER_DEVICE}`, method: 'DELETE', }); // Revoking elsewhere must not disturb this browser's own registration. expect(localStorage.getItem(SUB_KEY)).toBe('push-mine'); }); it('runs the full local teardown when revoking this device', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-mine'); const client = makeClient([sub('push-mine', THIS_DEVICE)]); installFetch({}); await revokePushDevice({ client, relayBaseUrl: RELAY, device: { id: 'push-mine', deviceClientId: THIS_DEVICE, isThisDevice: true }, }); expect(client.destroyed).toEqual(['push-mine']); expect(localStorage.getItem(SUB_KEY)).toBeNull(); }); }); describe('enableWebPushForAccounts', () => { it('registers every account against the one browser subscription', async () => { const first = makeClient([]); const second = makeClient([]); installFetch({}); const result = await enableWebPushForAccounts([ { accountId: 'a', client: first, accountLabel: 'one' }, { accountId: 'b', client: second, accountLabel: 'two' }, ], { relayBaseUrl: RELAY, inboxOnly: false }); expect(result.enabled).toEqual(['a', 'b']); expect(result.failed).toEqual([]); expect(first.createPushSubscription).toHaveBeenCalledTimes(1); expect(second.createPushSubscription).toHaveBeenCalledTimes(1); }); it('stops at a browser-level refusal instead of prompting once per account', async () => { const first = makeClient([]); const second = makeClient([]); installFetch({}); // Blocked notifications are a property of the browser, not of an account: // walking on would fail identically for every one of them. (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; (window as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; const result = await enableWebPushForAccounts([ { accountId: 'a', client: first }, { accountId: 'b', client: second }, ], { relayBaseUrl: RELAY, inboxOnly: false }); expect(result.enabled).toEqual([]); expect(result.failed.map(f => f.accountId)).toEqual(['a', 'b']); expect(second.createPushSubscription).not.toHaveBeenCalled(); }); });