Files
webmail/lib/__tests__/web-push.test.ts
T
Linus Rath ffa909600c fix: keep web push on when someone shares a mailbox with you
Stalwart refuses a push subscription's whole emailPush map as
`forbidden` when it names a mailbox shared by ACL (0.16.16 and later,
and 1.0). Bulwark put every account with mailboxes in the map, so
enabling push showed raw JSON, and the background re-sync destroyed the
working subscription after its refresh was refused and dropped its key:
push stopped without a word and could not be turned back on while the
share existed. A network error, a timeout or rate limiting during the
refresh ended it the same way.

- The map holds the primary account and the accounts that list
  `urn:ietf:params:jmap:emailpush`, which 1.0 leaves off ACL shares.
  0.16 lists it everywhere, so a refused map narrows to the accounts
  that look owned, then to the primary account, as the mobile app does.
  Refused accounts are remembered under
  `bulwark.push.emailPushRefused.v1.<id>`; Re-register and disable
  clear the list.
- A subscription ends only when a verified replacement exists, the user
  turns push off, or the server no longer has it. The old record is
  destroyed after the new one is verified. A failed listing, a network
  error or a refused method call leave it and its key alone.
- PushSubscription/set failures throw typed errors (`jmapSetError` for
  a refused object, `jmapError` for a refused call) instead of JSON in
  the message or `false`, and the client exposes `getAccountInfo`.
- Settings says that mailboxes others share don't send background
  notifications, and `forbidden` and `overQuota` read as plain text in
  settings and the onboarding prompt.
2026-09-25 17:59:17 +02:00

980 lines
41 KiB
TypeScript

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,
resetWebPushResyncState,
resyncWebPush,
revokePushDevice,
} 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> = {},
): 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<IJMAPClient> {
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<string, EmailPushConfig> = {
[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<string, unknown> {
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 },
};
}
function account(isPersonal: boolean, isReadOnly: boolean, caps: Record<string, unknown>): AccountInfo {
return { 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<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }),
[GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }),
[ACL_ACCOUNT_ID]: account(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<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }),
[GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }),
[ACL_ACCOUNT_ID]: account(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<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, {
[MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(ACCOUNT_ID),
}),
[GROUP_ACCOUNT_ID]: account(false, false, {
[MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(GROUP_ACCOUNT_ID),
}),
[ACL_ACCOUNT_ID]: account(false, true, { [MAIL_CAP]: {}, ...collectionCaps(false), ...owner(ACL_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<string, AccountInfo>;
// Accounts the server accepts in an emailPush map: Stalwart's is_member,
// the user's own account and their groups.
members?: 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 accept = (emailPush?: Record<string, EmailPushConfig> | 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,
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<string, EmailPushConfig> }) => {
accept(params.emailPush);
return 'push-new';
}),
verifyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> | 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<string, EmailPushConfig> | null | undefined) =>
Object.keys(emailPush ?? {}).sort();
const createCalls = (client: IJMAPClient) =>
(client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c[0] as { emailPush?: Record<string, EmailPushConfig> },
);
const updateCalls = (client: IJMAPClient) =>
(client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c[1] as { emailPush?: Record<string, EmailPushConfig> },
);
// enableWebPush needs a browser that supports push; jsdom has none of it.
function installPushBrowser() {
const browserSub = {
endpoint: 'https://fcm.example/endpoint',
options: { applicationServerKey: new ArrayBuffer(8) },
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<string, boolean | 'unknown'>) {
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<typeof vi.fn>;
const verify = client.verifyPushSubscription as ReturnType<typeof vi.fn>;
const destroyOld = destroy.mock.invocationCallOrder[destroy.mock.calls.findIndex((c) => c[0] === 'push-old')];
expect(destroyOld).toBeGreaterThan(verify.mock.invocationCallOrder[0]);
});
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');
});
});
// 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('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('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<typeof vi.fn>).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<typeof vi.fn>).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('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<typeof vi.fn>;
const verify = client.verifyPushSubscription as ReturnType<typeof vi.fn>;
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 mailOnly = { ...SESSION_1_0, [GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {} }) };
const client = makeClient([], { emailPushCapability: true, accounts: mailOnly });
expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([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([]);
});
});
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();
});
});