diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 48d2d7b99..6b5662efd 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -484,6 +484,91 @@ describe('enableWebPush delivery filter (emailPush)', () => { }); }); +// Stalwart refuses emailPush entries for accounts that only shared a folder +// with the user; only the own account and groups the user belongs to pass. +describe('enableWebPush with a folder shared by another user (#1050)', () => { + const GROUP_ACCOUNT_ID = 'acct-group'; + const FORBIDDEN = 'PushSubscription/set create failed: {"type":"forbidden","description":"No access to one of the accounts in the emailPush map."}'; + const pushParams = (client: IJMAPClient, call: number) => + (client.createPushSubscription as ReturnType).mock.calls[call][0]; + + // Behaves like Stalwart: any map naming the sharing account is refused. + function refuseSharedAccount(client: IJMAPClient) { + vi.mocked(client.createPushSubscription).mockImplementation(async (params) => { + if (params.emailPush && SHARED_ACCOUNT_ID in params.emailPush) throw new Error(FORBIDDEN); + return 'push-new'; + }); + vi.mocked(client.updatePushSubscription).mockImplementation(async (_id, patch) => + !(patch.emailPush && SHARED_ACCOUNT_ID in patch.emailPush)); + } + + it('still creates the subscription, filtered for the own account only', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + const client = makeClient([], { emailPushCapability: true }); + refuseSharedAccount(client); + installFetch({}); + + const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(result.subscriptionId).toBe('push-new'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123'); + expect(client.createPushSubscription).toHaveBeenCalledTimes(2); + expect(pushParams(client, 1).emailPush).toEqual({ [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] }); + }); + + it('keeps the filter for a group account the user belongs to', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + const client = makeClient([], { emailPushCapability: true }); + const base = await client.getAllMailboxes(); + vi.mocked(client.getAllMailboxes).mockResolvedValue([ + ...base, + { ...base[0], id: `${GROUP_ACCOUNT_ID}:mb-group-inbox`, originalId: 'mb-group-inbox', accountId: GROUP_ACCOUNT_ID }, + ]); + // What Stalwart's session says: create rights on owned accounts (own and + // group), none on an account that only shared a folder. + (client as { getAccountCapability?: unknown }).getAccountCapability = vi.fn( + (urn: string, accountId: string) => (urn === 'urn:ietf:params:jmap:calendars' + ? { mayCreateCalendar: accountId !== SHARED_ACCOUNT_ID } + : undefined), + ); + refuseSharedAccount(client); + installFetch({}); + + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(client.createPushSubscription).toHaveBeenCalledTimes(2); + expect(Object.keys(pushParams(client, 1).emailPush).sort()).toEqual([GROUP_ACCOUNT_ID, ACCOUNT_ID].sort()); + }); + + it('does not retry a create that failed for another reason', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + const client = makeClient([], { emailPushCapability: true }); + vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('PushSubscription/set create failed: {"type":"overQuota"}')); + installFetch({}); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(/overQuota/); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + }); + + it('refreshes an existing subscription with the accounts the server accepts', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); + refuseSharedAccount(client); + 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([]); + expect(client.updatePushSubscription).toHaveBeenLastCalledWith( + 'push-old', + expect.objectContaining({ emailPush: { [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] } }), + ); + }); +}); + // 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. diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 74496d2a7..baab195cf 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -88,6 +88,8 @@ export interface IJMAPClient { // ── Capabilities ────────────────────────────────────────────── getCapabilities(): Record; hasAccountCapability(capability: string, accountId?: string): boolean; + /** The value of an account capability (primary account by default). */ + getAccountCapability?(capability: string, accountId?: string): unknown; /** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */ getFileNameRules?(accountId?: string): FileNameRules | null; getMaxSizeUpload(): number; diff --git a/lib/web-push.ts b/lib/web-push.ts index 3efa0a20c..0bf26daf2 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -202,6 +202,99 @@ function sameEmailPush( return normalizeEmailPush(a) === normalizeEmailPush(b); } +function pickAccounts( + config: Record, + accountIds: readonly string[], +): Record { + const out: Record = {}; + for (const id of accountIds) if (config[id]) out[id] = config[id]; + return out; +} + +// A stored filter that the fallback below narrowed down still counts as up to +// date: same entries for the accounts it covers, and it covers the primary. +// Without this every resync would retry the full map and get refused again. +function coveredByEmailPush( + stored: Record | null | undefined, + desired: Record, + primary: string, +): boolean { + if (!stored || !stored[primary]) return false; + const keys = Object.keys(stored); + if (!keys.every((id) => desired[id])) return false; + return sameEmailPush(stored, pickAccounts(desired, keys)); +} + +function isForbiddenError(err: unknown): boolean { + return err instanceof Error && err.message.includes('"type":"forbidden"'); +} + +/** + * Smaller emailPush maps to retry with after the server refused the full one. + * + * Stalwart only accepts emailPush entries for the user's own account and the + * groups they belong to. An entry for an account that merely shared a folder + * with them fails the whole PushSubscription/set with `forbidden` (#1050). + * Those accounts never push to this subscription anyway - Stalwart fans out + * to member accounts only - so leaving their entries out loses nothing. The + * session lists both kinds alike, so first keep the accounts that look owned + * (see OWNERSHIP_FLAGS), then fall back to the primary account alone. The + * native app does the same (repos/react-native emailPushFallbacks). + */ +function narrowedEmailPushConfigs( + client: IJMAPClient, + config: Record, +): Record[] { + const primary = client.getAccountId(); + const others = Object.keys(config).filter((id) => id !== primary); + if (others.length === 0) return []; + const candidates: Record[] = []; + const owned = others.filter((id) => looksOwned(client, id)); + if (owned.length > 0 && owned.length < others.length) { + candidates.push(pickAccounts(config, [primary, ...owned])); + } + candidates.push(pickAccounts(config, [primary])); + return candidates; +} + +// Stalwart marks the accounts a user owns - their own and the groups they +// belong to - through the create flags of its per-account collection +// capabilities: true there, false on accounts shared with them by ACL. +const OWNERSHIP_FLAGS: ReadonlyArray = [ + ['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'], + ['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'], + ['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'], +]; + +function looksOwned(client: IJMAPClient, accountId: string): boolean { + return OWNERSHIP_FLAGS.some(([urn, flag]) => { + const capability = client.getAccountCapability?.(urn, accountId) as Record | undefined; + return capability?.[flag] === true; + }); +} + +async function createPushSubscriptionWithEmailPush( + client: IJMAPClient, + params: Omit[0], 'emailPush'>, + emailPush: Record | null, +): Promise { + try { + return await client.createPushSubscription({ ...params, ...(emailPush ? { emailPush } : {}) }); + } catch (err) { + if (!emailPush || !isForbiddenError(err)) throw err; + let lastError = err; + for (const narrowed of narrowedEmailPushConfigs(client, emailPush)) { + try { + return await client.createPushSubscription({ ...params, emailPush: narrowed }); + } catch (retryErr) { + if (!isForbiddenError(retryErr)) throw retryErr; + lastError = retryErr; + } + } + throw lastError; + } +} + export interface EnableWebPushParams { client: IJMAPClient; // Optional - falls back to DEFAULT_RELAY_BASE_URL. @@ -473,7 +566,22 @@ async function refreshSubscriptionExpires( }; if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush; - return await client.updatePushSubscription(sub.id, patch); + if (await client.updatePushSubscription(sub.id, patch)) return true; + if (!patch.emailPush) return false; + // The server may refuse entries for accounts that only shared a folder + // with us (#1050). Retry with the accounts it accepted last time, then + // with the same smaller maps a fresh create would fall back to. + const desired = patch.emailPush; + const candidates: Record[] = []; + if (coveredByEmailPush(sub.emailPush, desired, client.getAccountId())) { + candidates.push(pickAccounts(desired, Object.keys(sub.emailPush ?? {}))); + } else { + candidates.push(...narrowedEmailPushConfigs(client, desired)); + } + for (const narrowed of candidates) { + if (await client.updatePushSubscription(sub.id, { ...patch, emailPush: narrowed })) return true; + } + return false; } catch { return false; } @@ -595,13 +703,12 @@ export async function enableWebPush( } } - const serverAssignedId = await params.client.createPushSubscription({ + const serverAssignedId = await createPushSubscriptionWithEmailPush(params.client, { deviceClientId, url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), types: [...PUSH_TYPES], expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - ...(emailPush ? { emailPush } : {}), - }); + }, emailPush); const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); await params.client.verifyPushSubscription(serverAssignedId, verificationCode);