).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": {