diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index d09e7d83b..50a4cd435 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PUSH_NOTIFICATION_PROMPT_DELAY_MS, PushNotificationPrompt } from "../push-notification-prompt"; import { PWA_INSTALL_PROMPT_VISIBILITY_EVENT } from "../pwa-install-prompt"; +import { jmapSetError } from "@/lib/jmap/jmap-error"; const mocks = vi.hoisted(() => ({ pathname: "/en", @@ -165,6 +166,33 @@ describe("PushNotificationPrompt", () => { ); }); + it.each([ + [ + "forbidden", + { type: "forbidden", description: "No access to one of the accounts in the emailPush map." }, + "error_forbidden", + ], + [ + "overQuota", + { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." }, + "error_over_quota", + ], + ])("explains a %s refusal instead of showing the raw server error", async (_type, setError, key) => { + mocks.enableWebPush.mockRejectedValue( + jmapSetError(`PushSubscription/set create failed: ${setError.description}`, setError), + ); + render(); + await advancePromptDelay(); + + await act(async () => { + fireEvent.click(screen.getByText("enable")); + await Promise.resolve(); + }); + + expect(screen.getByRole("alert")).toHaveTextContent(key); + expect(screen.getByRole("alert")).not.toHaveTextContent("emailPush"); + }); + it("does not render when push is already enabled", async () => { mocks.enabled = true; render(); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index d43492cdc..150947a00 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -14,6 +14,7 @@ import { resyncWebPush, } from "@/lib/web-push"; import { resolveActiveRelayUrl } from "@/lib/push-relays"; +import { jmapErrorType } from "@/lib/jmap/jmap-error"; import { isPWAInstallPromptVisible, PWA_INSTALL_PROMPT_VISIBILITY_EVENT, @@ -201,7 +202,11 @@ export function PushNotificationPrompt() { }); setShowPrompt(false); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to enable push"); + // Readable text for the server refusals a user can act on. + const type = jmapErrorType(err); + if (type === "forbidden") setError(tPush("error_forbidden")); + else if (type === "overQuota") setError(tPush("error_over_quota")); + else setError(err instanceof Error ? err.message : "Failed to enable push"); } finally { setIsEnabling(false); } diff --git a/components/settings/__tests__/notification-settings.test.tsx b/components/settings/__tests__/notification-settings.test.tsx index a9db650ba..2dfdef9ca 100644 --- a/components/settings/__tests__/notification-settings.test.tsx +++ b/components/settings/__tests__/notification-settings.test.tsx @@ -1,10 +1,13 @@ -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { act } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NotificationSettings } from '../notification-settings'; import { useSettingsStore } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; +import { useEmailStore } from '@/stores/email-store'; +import { jmapSetError } from '@/lib/jmap/jmap-error'; +import type { Mailbox } from '@/lib/jmap/types'; import * as webPush from '@/lib/web-push'; // next-intl's useTranslations -> identity so labels are their keys. @@ -25,6 +28,7 @@ vi.mock('@/lib/web-push', () => ({ enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })), disableWebPush: vi.fn(async () => undefined), listPushDevices: vi.fn(async () => []), + mailAccountsWithoutPush: vi.fn(() => []), revokePushDevice: vi.fn(async () => undefined), serverSupportsEmailPush: vi.fn(() => true), })); @@ -117,3 +121,92 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => { expect(webPush.enableWebPush).not.toHaveBeenCalled(); }); }); + +describe('NotificationSettings - mailboxes shared by ACL', () => { + const mailbox = (id: string, accountId: string, isShared: boolean) => + ({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox; + + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false }); + useAuthStore.setState({ client: fakeClient, username: 'me@example.com' }); + usePolicyStore.setState({ + ...usePolicyStore.getState(), + policy: {}, + isSettingLocked: () => false, + isSettingHidden: () => false, + } as never); + useEmailStore.setState({ + mailboxes: [ + mailbox('inbox', 'acct-1', false), + mailbox('acct-group:inbox', 'acct-group', true), + mailbox('acct-acl:projects', 'acct-acl', true), + ], + }); + }); + + it('says that mailboxes shared by other users send no background notifications', async () => { + (webPush.mailAccountsWithoutPush as ReturnType).mockReturnValue(['acct-acl']); + + const { findByText } = render(); + + await findByText('push.shared_mailboxes_no_push'); + expect(webPush.mailAccountsWithoutPush).toHaveBeenCalledWith(fakeClient, ['acct-group', 'acct-acl']); + }); + + it('says nothing when every shared mailbox is covered', async () => { + (webPush.mailAccountsWithoutPush as ReturnType).mockReturnValue([]); + + const { findByText, queryByText } = render(); + + await findByText('push.status_active'); + expect(queryByText('push.shared_mailboxes_no_push')).toBeNull(); + }); +}); + +describe('NotificationSettings - push errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false }); + useAuthStore.setState({ client: fakeClient, username: 'me@example.com' }); + usePolicyStore.setState({ + ...usePolicyStore.getState(), + policy: {}, + isSettingLocked: () => false, + isSettingHidden: () => false, + } as never); + (webPush.isWebPushEnabled as ReturnType).mockResolvedValue(false); + }); + + async function enableAndFail(error: unknown) { + (webPush.enableWebPush as ReturnType).mockRejectedValueOnce(error); + const view = render(); + await view.findByText('push.status_inactive'); + fireEvent.click(view.getByRole('switch', { name: 'push.enable' })); + return (await view.findByRole('alert')).textContent; + } + + it('explains a forbidden refusal instead of showing the raw server error', async () => { + const text = await enableAndFail(jmapSetError('PushSubscription/set create failed: forbidden', { + type: 'forbidden', + description: 'No access to one of the accounts in the emailPush map.', + })); + + expect(text).toBe('push.error_forbidden'); + }); + + it('explains the subscription limit on overQuota', async () => { + const text = await enableAndFail(jmapSetError('PushSubscription/set create failed', { + type: 'overQuota', + description: 'There are too many subscriptions, please delete some before adding a new one.', + })); + + expect(text).toBe('push.error_over_quota'); + }); + + it('keeps the message of any other failure', async () => { + const text = await enableAndFail(new Error('Relay register failed: 502')); + + expect(text).toBe('Relay register failed: 502'); + }); +}); diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index a7a63ab82..aefec9c96 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -1,8 +1,10 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; @@ -19,6 +21,7 @@ import { isWebPushEnabled, isWebPushSupported, listPushDevices, + mailAccountsWithoutPush, revokePushDevice, serverSupportsEmailPush, } from '@/lib/web-push'; @@ -120,6 +123,30 @@ export function NotificationSettings() { ? t('push.status_active') : t('push.status_inactive'); + // Stalwart pushes only the user's own mailboxes and their groups', so + // mailboxes shared by other users stay silent in the background (they still + // update while the app is open). Say so instead of leaving users to wonder. + const mailboxes = useEmailStore((s) => s.mailboxes); + const sharedMailboxesWithoutPush = useMemo(() => { + // Re-read after each enable run too: it may have added refused accounts. + if (!client || pushStatus.kind === 'unsupported') return false; + const accountIds = mailboxes.flatMap((m) => (m.isShared && m.accountId ? [m.accountId] : [])); + return mailAccountsWithoutPush(client, accountIds).length > 0; + }, [client, mailboxes, pushStatus.kind]); + + // The refusals a user can do something about get readable text; anything + // else keeps the error's own message. + const pushErrorMessage = (err: unknown, fallback: string) => { + switch (jmapErrorType(err)) { + case 'forbidden': + return t('push.error_forbidden'); + case 'overQuota': + return t('push.error_over_quota'); + default: + return err instanceof Error ? err.message : fallback; + } + }; + // `forceRecreate` is what the Re-register button is for: without it the enable // path just refreshes the existing subscription's expiry, so a registration // holding stale shared-mailbox access survives the round trip (#841). @@ -143,10 +170,7 @@ export function NotificationSettings() { setPushStatus({ kind: 'unsupported' }); return; } - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to enable push', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to enable push') }); } finally { await refreshDevices(); } @@ -180,10 +204,7 @@ export function NotificationSettings() { setPushStatus({ kind: 'unsupported' }); return; } - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to update push filter', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to update push filter') }); }) .finally(() => { if (!cancelled) void refreshDevices(); }); return () => { cancelled = true; }; @@ -274,6 +295,10 @@ export function NotificationSettings() { + {sharedMailboxesWithoutPush && ( +

{t('push.shared_mailboxes_no_push')}

+ )} + { it('is empty without options', () => { @@ -432,3 +433,190 @@ describe('JMAP client extensions', () => { }); }); }); + +/** + * PushSubscription/set refusals reach web-push as typed errors: a SetError + * (jmapSetError, the server refused this subscription) apart from a refused + * method call (plain jmapError). Bodies are Stalwart's (jmap/src/push/set.rs, + * identical on 0.16.16 to 0.16.23 and 1.0). + */ +describe('JMAP client push subscriptions', () => { + const EMAIL_PUSH = 'urn:ietf:params:jmap:emailpush'; + 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 EMAIL_PUSH_MAP = { + 'acct-1': { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] }, + b: { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] }, + }; + let fetchSpy: ReturnType; + let client: JMAPClient; + let requests: RequestBody[]; + + // 0.16.16 to 0.16.23 and 1.0 advertise emailpush in the session; 0.16.6 + // to 0.16.15 don't. `b` is a mailbox shared by ACL as 1.0 lists it: + // read-only, without emailpush and the other owner-only capabilities. + async function setup(withEmailPush: boolean) { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + const session = makeSession(withEmailPush ? { [EMAIL_PUSH]: {} } : {}); + if (withEmailPush) (session.capabilities as Record)[EMAIL_PUSH] = {}; + (session.accounts as Record).b = { + name: 'userb', + isPersonal: false, + isReadOnly: true, + accountCapabilities: { + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false }, + }, + }; + fetchSpy.mockResolvedValueOnce(jsonResponse(session)); + client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + requests = []; + } + + function answer(handler: (body: RequestBody) => unknown) { + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody; + requests.push(body); + return jsonResponse(handler(body)); + }); + } + + const newSubscription = { + deviceClientId: 'dcid', + url: 'https://relay.example/api/push/jmap/dcid', + types: ['EmailDelivery'], + emailPush: EMAIL_PUSH_MAP, + }; + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + client?.disconnect(); + vi.restoreAllMocks(); + }); + + it('throws a forbidden SetError when Stalwart refuses the emailPush map on create (0.16.16+ and 1.0)', async () => { + await setup(true); + answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: FORBIDDEN_MAP } }, '0']] })); + + const err = await client.createPushSubscription(newSubscription).catch((e) => e); + + expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]); + expect(requests[0].methodCalls[0][1]).toEqual({ create: { new: { ...newSubscription } } }); + expect(jmapErrorType(err)).toBe('forbidden'); + expect(isJmapSetError(err)).toBe(true); + expect(err.message).toContain('No access to one of the accounts in the emailPush map.'); + expect(err.message).not.toContain('{'); + }); + + it('throws an overQuota SetError past the subscription limit', async () => { + await setup(true); + answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: OVER_QUOTA } }, '0']] })); + + const err = await client.createPushSubscription(newSubscription).catch((e) => e); + + expect(jmapErrorType(err)).toBe('overQuota'); + expect(isJmapSetError(err)).toBe(true); + }); + + it('returns the id Stalwart assigns', async () => { + await setup(true); + answer(() => ({ + methodResponses: [['PushSubscription/set', { + created: { new: { id: 'k', keys: null, expires: '2026-10-02T15:00:00Z' } }, + }, '0']], + })); + + expect(await client.createPushSubscription(newSubscription)).toBe('k'); + }); + + it('never puts emailpush in `using` or sends the map on 0.16.6 to 0.16.15', async () => { + await setup(false); + answer(() => ({ methodResponses: [['PushSubscription/set', { created: { new: { id: 'k' } } }, '0']] })); + + await client.createPushSubscription(newSubscription); + + expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core']); + expect(requests[0].methodCalls[0][1]).toEqual({ + create: { new: { deviceClientId: 'dcid', url: newSubscription.url, types: ['EmailDelivery'] } }, + }); + }); + + it('throws a forbidden SetError when Stalwart refuses the emailPush map on update', async () => { + await setup(true); + answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: FORBIDDEN_MAP } }, '0']] })); + + const err = await client + .updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP }) + .catch((e) => e); + + expect(requests[0].methodCalls[0][1]).toEqual({ + update: { k: { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP } }, + }); + expect(jmapErrorType(err)).toBe('forbidden'); + expect(isJmapSetError(err)).toBe(true); + }); + + it('throws a notFound SetError for a subscription the server no longer has', async () => { + await setup(true); + answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: { type: 'notFound' } } }, '0']] })); + + const err = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e); + + expect(jmapErrorType(err)).toBe('notFound'); + expect(isJmapSetError(err)).toBe(true); + }); + + it('resolves when the update is applied', async () => { + await setup(true); + answer(() => ({ methodResponses: [['PushSubscription/set', { updated: { k: null } }, '0']] })); + + await expect(client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' })).resolves.toBeUndefined(); + }); + + it('throws a plain error, not a SetError, when the whole method call is refused', async () => { + await setup(true); + answer(() => ({ methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']] })); + + const createErr = await client.createPushSubscription(newSubscription).catch((e) => e); + const updateErr = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e); + const verifyErr = await client.verifyPushSubscription('k', 'code').catch((e) => e); + + for (const err of [createErr, updateErr, verifyErr]) { + expect(jmapErrorType(err)).toBe('forbidden'); + expect(isJmapSetError(err)).toBe(false); + } + }); + + it('throws instead of listing nothing when PushSubscription/get fails', async () => { + await setup(true); + answer(() => ({ methodResponses: [['error', { type: 'serverFail' }, '0']] })); + + const err = await client.listPushSubscriptions().catch((e) => e); + + expect(err).toBeInstanceOf(Error); + expect(jmapErrorType(err)).toBe('serverFail'); + }); + + it('describes each session account without flattening its capabilities', async () => { + await setup(true); + + expect(client.getAccountInfo('b')).toEqual({ + isPersonal: false, + isReadOnly: true, + accountCapabilities: { + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false }, + }, + }); + expect(client.getAccountInfo('acct-1')?.isPersonal).toBe(true); + expect(client.getAccountInfo('nope')).toBeNull(); + }); +}); diff --git a/lib/__tests__/jmap-error.test.ts b/lib/__tests__/jmap-error.test.ts index c0dfb50a2..dcd4066d8 100644 --- a/lib/__tests__/jmap-error.test.ts +++ b/lib/__tests__/jmap-error.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { jmapError, jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error'; +import { isJmapSetError, jmapError, jmapErrorProperties, jmapErrorType, jmapSetError } from '../jmap/jmap-error'; describe('jmapError', () => { it('keeps the SetError type and properties on the thrown error', () => { @@ -37,3 +37,24 @@ describe('jmapError', () => { expect(jmapErrorProperties(null)).toEqual([]); }); }); + +describe('jmapSetError', () => { + it('tells a refused object apart from a refused method call of the same type', () => { + const setError = jmapSetError('PushSubscription/set create failed', { + type: 'forbidden', + description: 'No access to one of the accounts in the emailPush map.', + }); + const methodError = jmapError('PushSubscription/set failed', { type: 'forbidden' }); + + expect(jmapErrorType(setError)).toBe('forbidden'); + expect(isJmapSetError(setError)).toBe(true); + expect(jmapErrorType(methodError)).toBe('forbidden'); + expect(isJmapSetError(methodError)).toBe(false); + }); + + it('is false for anything that is not a SetError', () => { + expect(isJmapSetError(new Error('Failed to fetch'))).toBe(false); + expect(isJmapSetError(undefined)).toBe(false); + expect(isJmapSetError({ jmapSetError: 'yes' })).toBe(false); + }); +}); diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 777925cc9..4fe8f438f 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types'; +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, @@ -18,6 +22,7 @@ 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, @@ -40,9 +45,15 @@ interface FakeClient extends Partial { } const EMAIL_PUSH_CAP = 'urn:ietf:params:jmap:emailpush'; +const MAIL_CAP = 'urn:ietf:params:jmap:mail'; const JUNK_ID = 'mb-junk'; -const SHARED_ACCOUNT_ID = 'acct-shared'; -const SHARED_JUNK_ID = 'mb-shared-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. @@ -55,21 +66,92 @@ const EXPECTED_EMAIL_PUSH: Record = { properties: ['id', 'threadId'], urgency: 'high', }, - [SHARED_ACCOUNT_ID]: { + [GROUP_ACCOUNT_ID]: { filter: { operator: 'AND', - conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [SHARED_JUNK_ID] }], + 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 }, + }; +} + +function account(isPersonal: boolean, isReadOnly: boolean, caps: Record): 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 = { + [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 = { + [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 = { + [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 } = {}, + 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[]; + } = {}, ): 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 | 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, @@ -82,17 +164,26 @@ function makeClient( 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-shared-inbox', 'inbox', SHARED_ACCOUNT_ID), - mailbox(SHARED_JUNK_ID, 'junk', SHARED_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 () => 'push-new'), + createPushSubscription: vi.fn(async (params: { emailPush?: Record }) => { + accept(params.emailPush); + return 'push-new'; + }), verifyPushSubscription: vi.fn(async () => undefined), - updatePushSubscription: vi.fn(async () => true), + updatePushSubscription: vi.fn(async (_id: string, patch: { emailPush?: Record | null }) => { + accept(patch.emailPush); + }), destroyPushSubscription: vi.fn(async (id: string) => { destroyed.push(id); }), @@ -100,6 +191,18 @@ function makeClient( 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 = { @@ -192,6 +295,11 @@ describe('enableWebPush', () => { 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]); }); it('leaves another device alone when the relay cannot vouch for it', async () => { @@ -381,9 +489,9 @@ describe('buildEmailPushConfig inbox-only mode', () => { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], }); - expect(config[SHARED_ACCOUNT_ID].filter).toEqual({ + expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', - conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-shared-inbox' }], + conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-group-inbox' }], }); }); @@ -396,13 +504,14 @@ describe('buildEmailPushConfig inbox-only mode', () => { await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/No Inbox mailbox/); }); - it('mutes a shared account whose Inbox we cannot see instead of failing', async () => { - // Someone shared a single folder: the account shows up with no Inbox role. - // Leaving it out of the map would let the server push it unfiltered. + 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: `${SHARED_ACCOUNT_ID}:mb-proj`, originalId: 'mb-proj', name: 'Projects', accountId: SHARED_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); @@ -411,7 +520,7 @@ describe('buildEmailPushConfig inbox-only mode', () => { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], }); - expect(config[SHARED_ACCOUNT_ID].filter).toEqual({ + expect(config[GROUP_ACCOUNT_ID].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], }); @@ -430,19 +539,334 @@ describe('buildEmailPushConfig inbox-only mode', () => { const client = makeClient([], { emailPushCapability: true }); client.getAllMailboxes = vi.fn(async () => [ { id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID }, - { id: `${SHARED_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: SHARED_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, SHARED_ACCOUNT_ID].sort()); - expect(config[SHARED_ACCOUNT_ID].filter).toEqual({ + 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; + 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 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); @@ -463,6 +887,18 @@ describe('disableWebPush', () => { 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([]); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index d3ceebeea..1e509118e 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1,5 +1,5 @@ import type { IJMAPClient, KeywordDiscoveryResult, KeywordMigration } from '@/lib/jmap/client-interface'; -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; +import type { AccountInfo, Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import { getDemoData, type DemoData } from './demo-data'; import { generateDemoId } from './demo-utils'; @@ -71,6 +71,15 @@ export class DemoJMAPClient implements IJMAPClient { return capability === 'urn:ietf:params:jmap:submission'; } + getAccountInfo(accountId: string): AccountInfo | null { + if (accountId !== this.getAccountId()) return null; + return { + isPersonal: true, + isReadOnly: false, + accountCapabilities: { 'urn:ietf:params:jmap:submission': {} }, + }; + } + getCapabilities(): Record { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 }, @@ -125,7 +134,9 @@ export class DemoJMAPClient implements IJMAPClient { async verifyPushSubscription(): Promise { throw new Error('Push subscriptions are not available in demo mode'); } - async updatePushSubscription(): Promise { return false; } + async updatePushSubscription(): Promise { + throw new Error('Push subscriptions are not available in demo mode'); + } async destroyPushSubscription(): Promise { /* no-op */ } // ── Quota ───────────────────────────────────────────────────── diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8c47ac350..8182794c5 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -2,6 +2,7 @@ import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountSta import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { FileNameRules } from "@/lib/file-name-rules"; import type { SortLevel } from "@/lib/message-list-order"; +import type { AccountInfo } from "./types"; /** What `migrateKeyword` managed to do. */ export interface KeywordMigration { @@ -68,6 +69,8 @@ export interface IJMAPClient { // ── Capabilities ────────────────────────────────────────────── getCapabilities(): Record; hasAccountCapability(capability: string, accountId?: string): boolean; + /** How the session describes an account; null when it doesn't list the id. */ + getAccountInfo(accountId: string): AccountInfo | null; /** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */ getFileNameRules?(accountId?: string): FileNameRules | null; getMaxSizeUpload(): number; @@ -101,6 +104,10 @@ export interface IJMAPClient { // ── PushSubscription (RFC 8620 §7.2) ─────────────────────────── // Browser-driven Web Push setup: register a relay URL the JMAP server can // forward StateChange events to. Mobile uses the same primitives. + // A refused object throws a jmapSetError carrying the SetError type + // (`forbidden`, `overQuota`, `notFound`...); a refused method call throws a + // plain jmapError, so callers can tell the two apart (isJmapSetError). + /** Throws when the server can't be asked - never an empty list instead. */ listPushSubscriptions(): Promise; createPushSubscription(params: { deviceClientId: string; @@ -115,7 +122,7 @@ export interface IJMAPClient { updatePushSubscription( id: string, patch: { expires?: string; types?: string[]; emailPush?: Record | null }, - ): Promise; + ): Promise; destroyPushSubscription(id: string): Promise; // ── Quota ───────────────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 002922acb..6525f3da6 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -15,12 +15,13 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { contactFromWire, contactToWire } from "./contact-wire"; import { SchedulingDeniedError } from "./scheduling-error"; -import { jmapError, jmapErrorType, type JmapErrorInfo } from "./jmap-error"; +import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error"; export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; +import type { AccountInfo } from "./types"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues @@ -5022,6 +5023,16 @@ export class JMAPClient implements IJMAPClient { return !!caps && capability in caps; } + getAccountInfo(accountId: string): AccountInfo | null { + const account = this.accounts[accountId]; + if (!account) return null; + return { + isPersonal: account.isPersonal === true, + isReadOnly: account.isReadOnly === true, + accountCapabilities: account.accountCapabilities ?? {}, + }; + } + getMaxSizeAttachmentsPerEmail(): number { const mail = this.getAccountCapability("urn:ietf:params:jmap:mail") as { maxSizeAttachmentsPerEmail?: number } | undefined; return mail?.maxSizeAttachmentsPerEmail || 0; @@ -9850,6 +9861,18 @@ export class JMAPClient implements IJMAPClient { : ['urn:ietf:params:jmap:core']; } + // The body of the one PushSubscription call in a response. A method-level + // error throws a plain jmapError: the server refused the whole call, which + // says nothing about any one subscription. + private pushMethodResult(response: JMAPResponse, method: string): T { + const [name, body] = response.methodResponses?.[0] ?? []; + if (name !== method) { + const error = body as JmapErrorInfo | undefined; + throw jmapError(`${method} failed: ${error?.description || error?.type || 'no response'}`, error); + } + return (body ?? {}) as T; + } + async listPushSubscriptions(): Promise { const emailPush = this.hasEmailPushCapability(); // `emailPush` is not in the server's default property set, so ask for it @@ -9862,8 +9885,9 @@ export class JMAPClient implements IJMAPClient { [['PushSubscription/get', args, '0']], this.pushUsing(emailPush), ); - const [, body] = response.methodResponses[0] ?? []; - return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? []; + // Throws rather than answering []: to web-push an empty list means "the + // server dropped our subscription", which forgets a working registration. + return this.pushMethodResult<{ list?: PushSubscription[] }>(response, 'PushSubscription/get').list ?? []; } async createPushSubscription(params: { @@ -9886,14 +9910,22 @@ export class JMAPClient implements IJMAPClient { [['PushSubscription/set', { create: { new: created } }, '0']], this.pushUsing(withEmailPush), ); - const [, body] = response.methodResponses[0] ?? []; - const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined); - const id = result?.created?.new?.id; - if (!id) { - throw new Error( - `PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`, + const result = this.pushMethodResult<{ + created?: { new?: { id?: string } }; + notCreated?: { new?: JmapErrorInfo }; + }>(response, 'PushSubscription/set'); + // Stalwart refuses the whole emailPush map as `forbidden` when it names an + // account the user doesn't own, and answers `overQuota` past its + // subscription limit (15 per account by default). + const refused = result.notCreated?.new; + if (refused) { + throw jmapSetError( + `PushSubscription/set create failed: ${refused.description || refused.type || 'unknown error'}`, + refused, ); } + const id = result.created?.new?.id; + if (!id) throw new Error('PushSubscription/set create returned no id'); return id; } @@ -9902,19 +9934,27 @@ export class JMAPClient implements IJMAPClient { [['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']], ['urn:ietf:params:jmap:core'], ); - const [, body] = response.methodResponses[0] ?? []; - const notUpdated = (body as { notUpdated?: Record } | undefined)?.notUpdated?.[id]; - if (notUpdated) { - throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`); + const result = this.pushMethodResult<{ notUpdated?: Record }>( + response, + 'PushSubscription/set', + ); + const refused = result.notUpdated?.[id]; + if (refused) { + throw jmapSetError( + `PushSubscription verification failed: ${refused.description || refused.type || 'unknown error'}`, + refused, + ); } } - // Returns false when the server rejects the update (e.g. the subscription - // was already destroyed) - the caller treats that as a signal to recreate. + // Throws when the server refuses the update: a jmapSetError with the + // SetError type (`notFound` once the subscription is gone, `forbidden` for + // an emailPush map naming an account the user may not subscribe to), a + // plain error for anything that says nothing about the subscription. async updatePushSubscription( id: string, patch: { expires?: string; types?: string[]; emailPush?: Record | null }, - ): Promise { + ): Promise { const withEmailPush = patch.emailPush !== undefined && this.hasEmailPushCapability(); const update: Record = { ...patch }; if (!withEmailPush) delete update.emailPush; @@ -9922,10 +9962,20 @@ export class JMAPClient implements IJMAPClient { [['PushSubscription/set', { update: { [id]: update } }, '0']], this.pushUsing(withEmailPush), ); - const [, body] = response.methodResponses[0] ?? []; - const r = body as { updated?: Record; notUpdated?: Record } | undefined; - if (r?.notUpdated?.[id]) return false; - return r?.updated?.[id] !== undefined; + const result = this.pushMethodResult<{ + updated?: Record | null; + notUpdated?: Record; + }>(response, 'PushSubscription/set'); + const refused = result.notUpdated?.[id]; + if (refused) { + throw jmapSetError( + `PushSubscription/set update failed: ${refused.description || refused.type || 'unknown error'}`, + refused, + ); + } + if (!result.updated || !(id in result.updated)) { + throw new Error(`PushSubscription/set did not report ${id} as updated`); + } } async destroyPushSubscription(id: string): Promise { diff --git a/lib/jmap/jmap-error.ts b/lib/jmap/jmap-error.ts index bff0daa6c..73bd542fa 100644 --- a/lib/jmap/jmap-error.ts +++ b/lib/jmap/jmap-error.ts @@ -15,7 +15,7 @@ export interface JmapErrorInfo { properties?: string[]; } -export type JmapError = Error & { jmapType?: string; jmapProperties?: string[] }; +export type JmapError = Error & { jmapType?: string; jmapProperties?: string[]; jmapSetError?: boolean }; /** An `Error` with `message` that keeps the server's error type and properties. */ export function jmapError(message: string, info?: JmapErrorInfo | null): JmapError { @@ -25,6 +25,24 @@ export function jmapError(message: string, info?: JmapErrorInfo | null): JmapErr return error; } +/** + * {@link jmapError} for a SetError: the server looked at one object of a /set + * call and refused it (`notCreated`, `notUpdated`, `notDestroyed`, RFC 8620 + * §5.3), as opposed to refusing the whole method call. The same type can come + * either way (`forbidden` is both), and only the SetError says something about + * that object. + */ +export function jmapSetError(message: string, info?: JmapErrorInfo | null): JmapError { + const error = jmapError(message, info); + error.jmapSetError = true; + return error; +} + +/** Whether a thrown value is a SetError (see {@link jmapSetError}). */ +export function isJmapSetError(error: unknown): boolean { + return (error as JmapError | null | undefined)?.jmapSetError === true; +} + /** The JMAP error type a thrown value carries, if any. */ export function jmapErrorType(error: unknown): string | undefined { return (error as JmapError | null | undefined)?.jmapType; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 397d3d13b..1bf167df6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -22,6 +22,17 @@ export interface SharedAccount { }; } +/** + * One account as the JMAP session describes it (RFC 8620 §2), unflattened: + * the raw per-account capability objects rather than SharedAccount's + * booleans, for callers that need a capability's fields. + */ +export interface AccountInfo { + isPersonal: boolean; + isReadOnly: boolean; + accountCapabilities: Record; +} + export interface Email { id: string; threadId: string; diff --git a/lib/web-push.ts b/lib/web-push.ts index 133b9c53a..3b53bf193 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -5,7 +5,8 @@ // register endpoint we hit on the relay. import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { EmailPushConfig, Mailbox } from '@/lib/jmap/types'; +import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; +import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { IS_LITE } from '@/lib/lite'; @@ -16,6 +17,10 @@ import { IS_LITE } from '@/lib/lite'; // so a globally-shared key meant re-registering account B overwrote A. const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.'; const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.v1.'; +// JMAP account ids the server refused in this account's emailPush map (see +// emailPushFallbacks). Left out of the map from then on, so a refusal isn't +// provoked again on every page load. Re-register and disable clear it. +const EMAIL_PUSH_REFUSED_PREFIX = 'bulwark.push.emailPushRefused.v1.'; function deviceClientIdKey(accountId: string): string { return DEVICE_CLIENT_ID_PREFIX + accountId; @@ -25,6 +30,10 @@ function subscriptionIdKey(accountId: string): string { return SUBSCRIPTION_ID_PREFIX + accountId; } +function emailPushRefusedKey(accountId: string): string { + return EMAIL_PUSH_REFUSED_PREFIX + accountId; +} + const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); const SW_SCOPE = `${BASE_PATH}/`; const SW_URL = `${BASE_PATH}/sw.js`; @@ -82,17 +91,24 @@ export function serverSupportsEmailPush(client: IJMAPClient): boolean { * * Inbox-only mode: same `$junk` keyword guard, but the mailbox condition * requires the message to be IN the account's Inbox rather than merely NOT - * in Junk - mail a Sieve rule files into any other folder stays silent. An - * account with no Inbox we can see (someone shared a single folder with us) - * gets a filter that never matches: leaving it out of the map would make the - * server fall back to unfiltered pushes for that account. + * in Junk - mail a Sieve rule files into any other folder stays silent. A + * group account with no Inbox we can see gets a filter that never matches: + * leaving it out of the map would make the server fall back to unfiltered + * pushes for that account. + * + * Only the primary account and the accounts that list emailpush in their + * accountCapabilities go in the map. Stalwart fans a subscription out to the + * user's own account and their groups only, and refuses the whole map as + * `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on + * exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and + * enableWebPush narrows the map when the server refuses it. */ export async function buildEmailPushConfig( client: IJMAPClient, inboxOnly: boolean, ): Promise> { const primary = client.getAccountId(); - // Every account that has any mailbox gets a filter entry - a secondary/shared + // Every such account that has any mailbox gets a filter entry - a group // account without a Junk-role mailbox still needs one so its subscription // isn't left unfiltered. const accountIds = new Set([primary]); @@ -106,6 +122,7 @@ export async function buildEmailPushConfig( : await client.getAllMailboxes().catch(() => [] as Mailbox[]); for (const m of mailboxes) { const accountId = m.accountId || primary; + if (accountId !== primary && !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, accountId)) continue; accountIds.add(accountId); // Shared-account mailboxes carry a client-side ":" id; // the server only knows the original. @@ -165,6 +182,134 @@ function sameEmailPush( return normalizeEmailPush(a) === normalizeEmailPush(b); } +// 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. A user +// without calendars, contacts or files has none of the flags, so their groups +// don't look owned either. +const OWNERSHIP_FLAGS: ReadonlyArray = [ + ['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'], + ['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'], + ['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'], +]; + +function looksOwned(info: AccountInfo | null): boolean { + if (!info) return false; + if (info.isPersonal) return true; + if (info.isReadOnly) return false; + return OWNERSHIP_FLAGS.some(([urn, flag]) => { + const capability = info.accountCapabilities[urn] as Record | undefined; + return capability?.[flag] === true; + }); +} + +/** + * The emailPush maps to try, widest first. Stalwart refuses the whole map as + * `forbidden` when it names an account the user neither owns nor belongs to + * as a group member - a mailbox shared by ACL - and only fans a subscription + * out to owned accounts anyway, so dropping the others loses nothing. 0.16 + * lists both kinds as isPersonal:false, isReadOnly:false, so the first + * fallback keeps the accounts that look owned (see OWNERSHIP_FLAGS) and the + * last keeps only the primary account, which is always accepted. + */ +function emailPushFallbacks( + desired: Record, + primaryAccountId: string, + accountInfo: (accountId: string) => AccountInfo | null, +): Record[] { + const keep = (predicate: (id: string) => boolean) => + Object.fromEntries( + Object.entries(desired).filter(([id]) => id === primaryAccountId || predicate(id)), + ); + const out = [desired]; + for (const next of [keep((id) => looksOwned(accountInfo(id))), keep(() => false)]) { + // Only steps that actually drop an account are worth another round-trip. + if (Object.keys(next).length < Object.keys(out[out.length - 1]).length) out.push(next); + } + return out; +} + +/** + * Run a subscription write that carries `emailPush`, narrowing the map along + * emailPushFallbacks while the server refuses it with a `forbidden` SetError. + * Anything else - a method-level error, overQuota, a failed request - would + * fail the same way with a narrower map, so it is thrown as is. Resolves to + * the write's result and the map the server took. + */ +async function writeWithEmailPush( + client: IJMAPClient, + emailPush: Record | null, + write: (emailPush: Record | null) => Promise, +): Promise<{ result: T; emailPush: Record | null }> { + const attempts = emailPush + ? emailPushFallbacks(emailPush, client.getAccountId(), (id) => client.getAccountInfo(id)) + : [null]; + for (let i = 0; ; i++) { + try { + return { result: await write(attempts[i]), emailPush: attempts[i] }; + } catch (err) { + const mapRefused = isJmapSetError(err) && jmapErrorType(err) === 'forbidden'; + if (!mapRefused || i === attempts.length - 1) throw err; + } + } +} + +function readRefusedEmailPushAccounts(accountId: string): string[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(emailPushRefusedKey(accountId)) ?? '[]'); + return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : []; + } catch { + return []; + } +} + +/** Add the accounts the server dropped from `wanted` to reach `accepted`. */ +function rememberRefusedEmailPushAccounts( + accountId: string, + refusedBefore: string[], + wanted: Record | null, + accepted: Record | null, +): void { + if (!wanted || !accepted) return; + const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted)); + if (refusedNow.length === 0) return; + localStorage.setItem( + emailPushRefusedKey(accountId), + JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))), + ); +} + +function withoutAccounts( + config: Record, + accountIds: string[], +): Record { + return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id))); +} + +/** + * Which of `accountIds` (accounts the user sees mailboxes in) background + * notifications don't reach: mailboxes others share with the user by ACL. + * Stalwart only fans a subscription out to the user's own account and their + * groups, whatever the emailPush map says, on every version. + * + * 1.0 leaves emailpush out of exactly those accounts' capabilities, so an + * account without it settles the question for every account. 0.16.16 to + * 0.16.23 list emailpush everywhere (and 0.16.6 to 0.16.15 nowhere), so there + * it is the accounts the server refused in the map, and the ones that don't + * look owned. + */ +export function mailAccountsWithoutPush(client: IJMAPClient, accountIds: Iterable): string[] { + const primary = client.getAccountId(); + const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary); + if (others.length === 0) return []; + if (serverSupportsEmailPush(client)) { + const withoutEmailPush = others.filter((id) => !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, id)); + if (withoutEmailPush.length > 0) return withoutEmailPush; + } + const refused = readRefusedEmailPushAccounts(primary); + return others.filter((id) => refused.includes(id) || !looksOwned(client.getAccountInfo(id))); +} + export interface EnableWebPushParams { client: IJMAPClient; // Optional - falls back to DEFAULT_RELAY_BASE_URL. @@ -398,6 +543,13 @@ async function pollVerificationCode( throw new Error('Timed out waiting for PushVerification from the JMAP server'); } +// Push the subscription's expiry forward and re-sync `types` / the delivery +// filter when they drift. Resolves to false when the server refused the +// update with a SetError (the subscription is gone, or no narrower map is +// accepted), which the caller answers with a replacement; otherwise to the +// emailPush map the update installed (null when it left the filter alone). +// Every other failure - the network, a timeout, rate limiting, a method-level +// error - says nothing about the subscription and is thrown. async function refreshSubscriptionExpires( client: IJMAPClient, sub: { @@ -408,7 +560,7 @@ async function refreshSubscriptionExpires( }, // null when the server has no emailPush support - leave the property alone. desiredEmailPush: Record | null, -): Promise { +): Promise | null }> { const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); // Also re-sync the delivery filter: a subscription created before this // client learned about emailPush has none, and the Junk mailbox id can @@ -417,17 +569,22 @@ async function refreshSubscriptionExpires( if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) { const remainingMs = new Date(sub.expires).getTime() - Date.now(); const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000; - if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true; + if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return { emailPush: null }; } + const patch: { expires?: string; types?: string[] } = { + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + }; + if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; try { - const patch: { expires?: string; types?: string[]; emailPush?: Record } = { - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - }; - if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; - if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush; - return await client.updatePushSubscription(sub.id, patch); - } catch { - return false; + const { emailPush } = await writeWithEmailPush( + client, + emailPushNeedsUpdate ? desiredEmailPush : null, + (filter) => client.updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), + ); + return { emailPush }; + } catch (err) { + if (isJmapSetError(err)) return false; + throw err; } } @@ -484,33 +641,53 @@ export async function enableWebPush( // Reuse the JMAP-side PushSubscription if the server still has it, just // refreshing the expiry so it doesn't time out between sessions. With - // forceRecreate we skip the reuse and destroy it instead: the account set a - // Stalwart subscription fans out to is fixed at creation time, so refreshing + // forceRecreate we replace it instead: the account set a Stalwart + // subscription fans out to is fixed at creation time, so refreshing // `expires` carries stale permissions forward and only a new record picks up - // revoked shared-mailbox access (#841). - const existingSubs = await params.client.listPushSubscriptions().catch(() => []); + // revoked shared-mailbox access (#841). Either way the old record keeps + // delivering until its replacement is verified, so a replacement the server + // refuses never leaves the browser without push. + // + // A failed listing ends the run here: taken for "the server has no + // subscription", it would drop the key of a working registration and push + // would read as off from then on. + const existingSubs = await params.client.listPushSubscriptions(); + // A forced re-registration re-learns which accounts the server accepts in + // the emailPush map. + if (params.forceRecreate) localStorage.removeItem(emailPushRefusedKey(accountId)); + const refusedBefore = readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush(params.client) - ? await buildEmailPushConfig(params.client, params.inboxOnly) + ? withoutAccounts(await buildEmailPushConfig(params.client, params.inboxOnly), refusedBefore) : null; const subIdKey = subscriptionIdKey(accountId); const storedServerId = localStorage.getItem(subIdKey); + let replacedServerId: string | null = null; if (storedServerId) { const match = existingSubs.find((s) => s.id === storedServerId); if (match) { if (!params.forceRecreate) { const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush); - if (refreshed) return { subscriptionId: storedServerId }; + if (refreshed) { + rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); + return { subscriptionId: storedServerId }; + } } - await params.client.destroyPushSubscription(storedServerId).catch(() => undefined); + // The server refused the refresh, or the caller asked for a fresh + // record: create a replacement below and destroy this one once the + // replacement is verified. + replacedServerId = storedServerId; + } else { + // The server no longer has it (expired, or revoked elsewhere). + localStorage.removeItem(subIdKey); } - localStorage.removeItem(subIdKey); } // Reap leftover subscriptions that would otherwise starve the new one's - // verification. Stalwart emits only one PushVerification per account per ~60s - // and picks the oldest unverified subscription, so a single stale straggler - // blocks every fresh attempt - the symptom is the confusing "Timed out - // waiting for PushVerification" error. We can't read a subscription's + // verification. Stalwart emits only one PushVerification per account per + // ~60s; up to 0.16.13 it picks the first unverified subscription it stores, + // usually the oldest, so a single stale straggler blocks every fresh attempt + // - the symptom is the confusing "Timed out waiting for PushVerification" + // error (0.16.14 and later pick the newest). We can't read a subscription's // verified state or URL over JMAP (Stalwart hides both), only its // deviceClientId, so we decide what's safe to remove like this: // - same deviceClientId as ours: a previous attempt from THIS browser, @@ -531,17 +708,24 @@ export async function enableWebPush( } } - const serverAssignedId = await params.client.createPushSubscription({ - deviceClientId, - url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - ...(emailPush ? { emailPush } : {}), - }); + const created = await writeWithEmailPush(params.client, emailPush, (filter) => + params.client.createPushSubscription({ + deviceClientId, + url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), + types: [...PUSH_TYPES], + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + ...(filter ? { emailPush: filter } : {}), + }), + ); + const serverAssignedId = created.result; + rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); await params.client.verifyPushSubscription(serverAssignedId, verificationCode); localStorage.setItem(subIdKey, serverAssignedId); + if (replacedServerId) { + await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined); + } return { subscriptionId: serverAssignedId }; } @@ -582,6 +766,8 @@ export async function disableWebPush(params: DisableWebPushParams): Promise undefined); } localStorage.removeItem(subIdKey); + // Enabling again starts from the full emailPush map. + localStorage.removeItem(emailPushRefusedKey(accountId)); if (deviceClientId && relayBaseUrl) { await fetch( @@ -711,7 +897,8 @@ export interface ResyncWebPushParams { * action: refresh its expiry and install/repair the delivery filter. Nothing * here can prompt - it only runs when push is already on for the account - * and every failure is swallowed because the app must not care whether the - * background touch-up worked. Returns true when a re-sync actually ran. + * background touch-up worked. A failed re-sync leaves the registration and + * its key as they were. Returns true when a re-sync actually ran. */ export async function resyncWebPush(params: ResyncWebPushParams): Promise { let accountId: string; diff --git a/locales/ar/common.json b/locales/ar/common.json index ca082f2db..67c3b7545 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1192,7 +1192,10 @@ "revoke": "إلغاء التسجيل", "confirm_revoke_title": "إلغاء تسجيل هذا الجهاز؟", "confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.", - "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية." + "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية.", + "shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.", + "error_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.", + "error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى." }, "sound_selection": { "title": "صوت الإشعار", diff --git a/locales/ca/common.json b/locales/ca/common.json index 49d68b0ff..3ca531189 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1192,7 +1192,10 @@ "revoke": "Revoca", "confirm_revoke_title": "Voleu revocar aquest dispositiu?", "confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.", - "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament." + "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament.", + "shared_mailboxes_no_push": "Les bústies que altres usuaris comparteixen amb vós no envien notificacions en segon pla.", + "error_forbidden": "El servidor de correu no permet notificacions en segon pla per a aquest compte.", + "error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar." }, "sound_selection": { "title": "So de notificació", diff --git a/locales/cs/common.json b/locales/cs/common.json index 06c3edf8e..61e525085 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1215,7 +1215,10 @@ "revoke": "Odvolat", "confirm_revoke_title": "Odvolat toto zařízení?", "confirm_revoke_message": "Okamžitě přestane přijímat oznámení na pozadí. Opětovné zapnutí push na tom zařízení jej zaregistruje znovu.", - "confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí." + "confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí.", + "shared_mailboxes_no_push": "Poštovní schránky, které s vámi sdílejí ostatní, neodesílají oznámení na pozadí.", + "error_forbidden": "Poštovní server pro tento účet nepovoluje oznámení na pozadí.", + "error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu." } }, "language_region": { diff --git a/locales/da/common.json b/locales/da/common.json index 49fd21cb5..0a022b465 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1192,7 +1192,10 @@ "revoke": "Tilbagekald", "confirm_revoke_title": "Tilbagekald denne enhed?", "confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.", - "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme." + "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme.", + "shared_mailboxes_no_push": "Postkasser, som andre deler med dig, sender ikke baggrundsnotifikationer.", + "error_forbidden": "Mailserveren tillader ikke baggrundsnotifikationer for denne konto.", + "error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen." }, "sound_selection": { "title": "Notifikationslyd", diff --git a/locales/de/common.json b/locales/de/common.json index 40dd51804..58f133c21 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1215,7 +1215,10 @@ "revoke": "Widerrufen", "confirm_revoke_title": "Dieses Gerät widerrufen?", "confirm_revoke_message": "Es empfängt sofort keine Hintergrundbenachrichtigungen mehr. Wird Push auf diesem Gerät erneut aktiviert, wird es neu registriert.", - "confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr." + "confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr.", + "shared_mailboxes_no_push": "Postfächer, die andere mit Ihnen teilen, senden keine Hintergrundbenachrichtigungen.", + "error_forbidden": "Der Mailserver erlaubt für dieses Konto keine Hintergrundbenachrichtigungen.", + "error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut." } }, "language_region": { diff --git a/locales/en/common.json b/locales/en/common.json index c3d04186f..f4d4a08ff 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1192,7 +1192,10 @@ "revoke": "Revoke", "confirm_revoke_title": "Revoke this device?", "confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.", - "confirm_revoke_message_this": "This device will stop receiving background notifications immediately." + "confirm_revoke_message_this": "This device will stop receiving background notifications immediately.", + "shared_mailboxes_no_push": "Mailboxes others share with you don't send background notifications.", + "error_forbidden": "The mail server doesn't allow background notifications for this account.", + "error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again." }, "sound_selection": { "title": "Notification Sound", diff --git a/locales/es/common.json b/locales/es/common.json index f2590f4fd..109544269 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1215,7 +1215,10 @@ "revoke": "Revocar", "confirm_revoke_title": "¿Revocar este dispositivo?", "confirm_revoke_message": "Dejará de recibir notificaciones en segundo plano de inmediato. Si vuelves a activar las notificaciones push en ese dispositivo, se registrará de nuevo.", - "confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato." + "confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato.", + "shared_mailboxes_no_push": "Los buzones que otras personas comparten contigo no envían notificaciones en segundo plano.", + "error_forbidden": "El servidor de correo no permite notificaciones en segundo plano para esta cuenta.", + "error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo." } }, "language_region": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 88bf78c40..881343805 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1192,7 +1192,10 @@ "revoke": "لغو", "confirm_revoke_title": "این دستگاه لغو شود؟", "confirm_revoke_message": "بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند. با روشن‌کردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت می‌شود.", - "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند." + "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند.", + "shared_mailboxes_no_push": "صندوق‌های پستی که دیگران با شما به اشتراک می‌گذارند اعلان پس‌زمینه ارسال نمی‌کنند.", + "error_forbidden": "سرور ایمیل اعلان‌های پس‌زمینه را برای این حساب مجاز نمی‌داند.", + "error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید." }, "sound_selection": { "title": "صدای اعلان", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1b1e3481e..1d786d852 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1215,7 +1215,10 @@ "revoke": "Révoquer", "confirm_revoke_title": "Révoquer cet appareil ?", "confirm_revoke_message": "Il cessera immédiatement de recevoir les notifications en arrière-plan. Réactiver les notifications push sur cet appareil l’enregistrera à nouveau.", - "confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan." + "confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan.", + "shared_mailboxes_no_push": "Les boîtes aux lettres que d'autres personnes partagent avec vous n'envoient pas de notifications en arrière-plan.", + "error_forbidden": "Le serveur de messagerie n'autorise pas les notifications en arrière-plan pour ce compte.", + "error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez." } }, "language_region": { diff --git a/locales/he/common.json b/locales/he/common.json index 1fd9dc1ff..eb133a0d7 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1180,7 +1180,10 @@ "revoke": "ביטול", "confirm_revoke_title": "לבטל את המכשיר הזה?", "confirm_revoke_message": "הוא יפסיק לקבל התראות ברקע מיד. הפעלה מחדש של פוש באותו מכשיר תרשום אותו מחדש.", - "confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד." + "confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד.", + "shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.", + "error_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.", + "error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב." } }, "language_region": { diff --git a/locales/hu/common.json b/locales/hu/common.json index d19bf53fa..ef3d08361 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1192,7 +1192,10 @@ "revoke": "Visszavonás", "confirm_revoke_title": "Visszavonja ezt az eszközt?", "confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.", - "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést." + "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést.", + "shared_mailboxes_no_push": "A mások által Önnel megosztott postafiókok nem küldenek háttérértesítéseket.", + "error_forbidden": "A levelezőszerver ennél a fióknál nem engedélyezi a háttérértesítéseket.", + "error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra." }, "sound_selection": { "title": "Értesítési hang", diff --git a/locales/it/common.json b/locales/it/common.json index 5395cada7..703841eaf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1215,7 +1215,10 @@ "revoke": "Revoca", "confirm_revoke_title": "Revocare questo dispositivo?", "confirm_revoke_message": "Smetterà subito di ricevere le notifiche in background. Riattivando il push su quel dispositivo verrà registrato di nuovo.", - "confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background." + "confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background.", + "shared_mailboxes_no_push": "Le caselle di posta che altri condividono con te non inviano notifiche in background.", + "error_forbidden": "Il server di posta non consente le notifiche in background per questo account.", + "error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova." } }, "language_region": { diff --git a/locales/ja/common.json b/locales/ja/common.json index b8e0f0a0a..d8013a019 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1215,7 +1215,10 @@ "revoke": "取り消す", "confirm_revoke_title": "このデバイスを取り消しますか?", "confirm_revoke_message": "すぐにバックグラウンド通知の受信を停止します。そのデバイスでプッシュを再度有効にすると、あらためて登録されます。", - "confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。" + "confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。", + "shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。", + "error_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。", + "error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。" } }, "language_region": { diff --git a/locales/ko/common.json b/locales/ko/common.json index e09255b76..8965300bc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1215,7 +1215,10 @@ "revoke": "해지", "confirm_revoke_title": "이 기기를 해지할까요?", "confirm_revoke_message": "즉시 백그라운드 알림 수신을 멈추게 됩니다. 해당 기기에서 푸시를 다시 켜면 새로 등록됩니다.", - "confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다." + "confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다.", + "shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않습니다.", + "error_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않습니다.", + "error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요." } }, "language_region": { diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bba2ce2..c08563c71 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1215,7 +1215,10 @@ "revoke": "Atsaukt", "confirm_revoke_title": "Atsaukt šo ierīci?", "confirm_revoke_message": "Tā nekavējoties pārstās saņemt fona paziņojumus. Atkārtoti ieslēdzot push šajā ierīcē, tā tiks reģistrēta no jauna.", - "confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus." + "confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus.", + "shared_mailboxes_no_push": "Pastkastes, ko ar jums kopīgo citi, nesūta fona paziņojumus.", + "error_forbidden": "Pasta serveris šim kontam neatļauj fona paziņojumus.", + "error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz." } }, "language_region": { diff --git a/locales/mn/common.json b/locales/mn/common.json index bf088be6e..0c3f539f9 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1192,7 +1192,10 @@ "revoke": "Цуцлах", "confirm_revoke_title": "Энэ төхөөрөмжийг цуцлах уу?", "confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.", - "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино." + "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино.", + "shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.", + "error_forbidden": "Шуудангийн сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.", + "error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу." }, "sound_selection": { "title": "Мэдэгдэлийн дуу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 7446344e0..cfe859321 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1192,7 +1192,10 @@ "revoke": "Tilbakekall", "confirm_revoke_title": "Vil du tilbakekalle tilgangen for denne enheten?", "confirm_revoke_message": "Enheten slutter umiddelbart å motta bakgrunnsvarsler. Hvis pushvarsler aktiveres på nytt på enheten, registreres den på nytt.", - "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler." + "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler.", + "shared_mailboxes_no_push": "Postbokser som andre deler med deg, sender ikke bakgrunnsvarsler.", + "error_forbidden": "E-postserveren tillater ikke bakgrunnsvarsler for denne kontoen.", + "error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen." }, "sound_selection": { "title": "Varslingslyd", diff --git a/locales/nl/common.json b/locales/nl/common.json index d9e169636..9d1303c0e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1215,7 +1215,10 @@ "revoke": "Intrekken", "confirm_revoke_title": "Dit apparaat intrekken?", "confirm_revoke_message": "Het ontvangt meteen geen achtergrondmeldingen meer. Push opnieuw inschakelen op dat apparaat registreert het opnieuw.", - "confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer." + "confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer.", + "shared_mailboxes_no_push": "Mailboxen die anderen met u delen, sturen geen achtergrondmeldingen.", + "error_forbidden": "De mailserver staat voor dit account geen achtergrondmeldingen toe.", + "error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw." } }, "language_region": { diff --git a/locales/pl/common.json b/locales/pl/common.json index aab0dae63..2b3c7ef59 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1215,7 +1215,10 @@ "revoke": "Unieważnij", "confirm_revoke_title": "Unieważnić to urządzenie?", "confirm_revoke_message": "Natychmiast przestanie odbierać powiadomienia w tle. Ponowne włączenie push na tym urządzeniu zarejestruje je od nowa.", - "confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle." + "confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle.", + "shared_mailboxes_no_push": "Skrzynki pocztowe udostępnione ci przez innych nie wysyłają powiadomień w tle.", + "error_forbidden": "Serwer poczty nie zezwala na powiadomienia w tle dla tego konta.", + "error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie." } }, "language_region": { diff --git a/locales/pt/common.json b/locales/pt/common.json index b5458da50..94c8df4fb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1215,7 +1215,10 @@ "revoke": "Revogar", "confirm_revoke_title": "Revogar este dispositivo?", "confirm_revoke_message": "Ele deixará de receber notificações em segundo plano imediatamente. Ativar o push novamente nesse dispositivo o registrará de novo.", - "confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente." + "confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente.", + "shared_mailboxes_no_push": "Caixas de correio que outras pessoas compartilham com você não enviam notificações em segundo plano.", + "error_forbidden": "O servidor de e-mail não permite notificações em segundo plano para esta conta.", + "error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente." } }, "language_region": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 603d0cf87..9af079720 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1192,7 +1192,10 @@ "revoke": "Revocă", "confirm_revoke_title": "Revocați acest dispozitiv?", "confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.", - "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal." + "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal.", + "shared_mailboxes_no_push": "Căsuțele poștale pe care alții le partajează cu dvs. nu trimit notificări în fundal.", + "error_forbidden": "Serverul de e-mail nu permite notificări în fundal pentru acest cont.", + "error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou." }, "sound_selection": { "title": "Sunet de notificare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2bbb86ec7..e1b1a97e5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1215,7 +1215,10 @@ "revoke": "Отозвать", "confirm_revoke_title": "Отозвать это устройство?", "confirm_revoke_message": "Оно сразу перестанет получать фоновые уведомления. Если снова включить push на этом устройстве, оно зарегистрируется заново.", - "confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления." + "confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления.", + "shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.", + "error_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.", + "error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку." } }, "language_region": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 0ead21296..711fb5491 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1218,7 +1218,10 @@ "revoke": "Odvolať", "confirm_revoke_title": "Odvolať toto zariadenie?", "confirm_revoke_message": "Okamžite prestane prijímať oznámenia na pozadí. Opätovné zapnutie push na tom zariadení ho zaregistruje nanovo.", - "confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí." + "confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí.", + "shared_mailboxes_no_push": "Poštové schránky, ktoré s vami zdieľajú iní, neposielajú oznámenia na pozadí.", + "error_forbidden": "Poštový server pre tento účet nepovoľuje oznámenia na pozadí.", + "error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova." } }, "language_region": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 8f589f9f8..6b4f64df3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1215,7 +1215,10 @@ "revoke": "İptal et", "confirm_revoke_title": "Bu cihaz iptal edilsin mi?", "confirm_revoke_message": "Arka plan bildirimlerini almayı hemen bırakacak. O cihazda push’u yeniden açarsanız yeniden kaydedilir.", - "confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak." + "confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak.", + "shared_mailboxes_no_push": "Başkalarının sizinle paylaştığı posta kutuları arka plan bildirimi göndermez.", + "error_forbidden": "Posta sunucusu bu hesap için arka plan bildirimlerine izin vermiyor.", + "error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin." } }, "language_region": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 5a6747179..7dac42481 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1215,7 +1215,10 @@ "revoke": "Відкликати", "confirm_revoke_title": "Відкликати цей пристрій?", "confirm_revoke_message": "Він одразу перестане отримувати фонові сповіщення. Якщо знову увімкнути push на цьому пристрої, він зареєструється заново.", - "confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення." + "confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення.", + "shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.", + "error_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.", + "error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз." } }, "language_region": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0c8965d4f..96d7aa049 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1192,7 +1192,10 @@ "revoke": "撤銷", "confirm_revoke_title": "要撤銷此裝置的註冊嗎?", "confirm_revoke_message": "此裝置會立即停止接收背景通知。若在該裝置上再次啟用推送,將重新註冊。", - "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。" + "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。", + "shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。", + "error_forbidden": "郵件伺服器不允許此帳號使用背景通知。", + "error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。" }, "sound_selection": { "title": "通知聲音", diff --git a/locales/zh/common.json b/locales/zh/common.json index 77cac9d65..31114f78e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1215,7 +1215,10 @@ "revoke": "撤销", "confirm_revoke_title": "要撤销此设备吗?", "confirm_revoke_message": "它将立即停止接收后台通知。在该设备上重新启用推送会重新注册。", - "confirm_revoke_message_this": "此设备将立即停止接收后台通知。" + "confirm_revoke_message_this": "此设备将立即停止接收后台通知。", + "shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。", + "error_forbidden": "邮件服务器不允许此账户使用后台通知。", + "error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。" } }, "language_region": {