mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
fix: keep push notifications working when a mailbox is shared with you
Stalwart refuses a push subscription's whole emailPush map as a `forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and later, and 1.0), and 1.0 stops listing emailpush on such accounts. The app narrowed the map on any `forbidden`, including a refused method call, and put every account with folders in the first attempt, so 1.0 cost one refused call per install. Worse, a failed listing counted as "the server has no subscription" and dropped the key of a working registration, and any failed refresh - a network error, a timeout, rate limiting - started a replacement. - `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same name, carrying `properties`) and `isJMAPSetError`. `assertSetResult` throws it, so every refused object can be told apart from a refused call; existing `instanceof` checks and the outbox's name-based error classification are unchanged. - The emailPush map holds the primary account and the accounts whose `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused map still narrows to the accounts that look owned, then to the primary account, but only on a `forbidden` SetError. - A subscription ends only with a verified replacement, a user disable or the server's verdict: a failed listing aborts the run, and only a SetError on the refresh starts a replacement. Other failures leave the subscription and its key alone and the next run tries again. - `forbidden` and `overQuota` read as plain text in settings and the onboarding prompt, and settings says that mailboxes others share don't send background notifications. New strings in all 27 languages.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
assertSetResult,
|
||||
isJMAPSetError,
|
||||
JMAPMethodError,
|
||||
JMAPSetError,
|
||||
requireMethodResult,
|
||||
} from '../jmap-result';
|
||||
import { isTransientNetworkError } from '../../lib/network-error';
|
||||
|
||||
// A SetError refuses one object of a /set call; a method error refuses the
|
||||
// whole call. `forbidden` comes both ways, and only the SetError says
|
||||
// something about the object. Shapes as Stalwart 0.16.16-0.16.23 and 1.0
|
||||
// answer them (jmap-proto method/set.rs, error/set.rs).
|
||||
const EMAIL_PUSH_REFUSED = {
|
||||
type: 'forbidden',
|
||||
description: 'No access to one of the accounts in the emailPush map.',
|
||||
};
|
||||
|
||||
describe('JMAPSetError', () => {
|
||||
it.each([
|
||||
['notCreated', 'new', 'create'],
|
||||
['notUpdated', 's1', 'update'],
|
||||
['notDestroyed', 's1', 'destroy'],
|
||||
])('is what assertSetResult throws for %s (Stalwart 0.16.16+ and 1.0)', (bucket, id, verb) => {
|
||||
const body = { [bucket]: { [id]: EMAIL_PUSH_REFUSED } };
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertSetResult(body, [id], 'push subscription');
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(isJMAPSetError(thrown)).toBe(true);
|
||||
// Still a JMAPMethodError under that name, so older checks keep working.
|
||||
expect(thrown).toBeInstanceOf(JMAPMethodError);
|
||||
expect(thrown).toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
||||
expect((thrown as Error).message).toBe(
|
||||
`Failed to ${verb} push subscription ${id}: forbidden – No access to one of the accounts in the emailPush map.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the properties the server named (Stalwart 1.0 keyword cap)', () => {
|
||||
const body = {
|
||||
notUpdated: {
|
||||
m1: { type: 'invalidProperties', description: 'Keywords cannot be longer than 128 bytes.', properties: ['keywords'] },
|
||||
},
|
||||
};
|
||||
expect(() => assertSetResult(body, ['m1'], 'email')).toThrow(JMAPSetError);
|
||||
try {
|
||||
assertSetResult(body, ['m1'], 'email');
|
||||
} catch (err) {
|
||||
expect((err as JMAPSetError).properties).toEqual(['keywords']);
|
||||
expect((err as JMAPSetError).type).toBe('invalidProperties');
|
||||
}
|
||||
});
|
||||
|
||||
it('is not what a refused method call throws, even with the same type', () => {
|
||||
const res = {
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']] as Array<
|
||||
[string, Record<string, unknown>, string]
|
||||
>,
|
||||
};
|
||||
let thrown: unknown;
|
||||
try {
|
||||
requireMethodResult(res, '0', 'PushSubscription/set');
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(JMAPMethodError);
|
||||
expect(isJMAPSetError(thrown)).toBe(false);
|
||||
expect((thrown as JMAPMethodError).type).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('counts as a server refusal for the outbox, not a connectivity blip', () => {
|
||||
// The outbox classifies by error name; a SetError must stay terminal.
|
||||
expect(isTransientNetworkError(new JMAPSetError('forbidden', 'request timed out'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads nothing else as a SetError', () => {
|
||||
expect(isJMAPSetError(new JMAPMethodError('forbidden'))).toBe(false);
|
||||
expect(isJMAPSetError(new Error('forbidden'))).toBe(false);
|
||||
expect(isJMAPSetError({ type: 'forbidden' })).toBe(false);
|
||||
expect(isJMAPSetError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updatePushSubscription,
|
||||
verifyPushSubscription,
|
||||
} from '../push';
|
||||
import { isJMAPSetError } from '../jmap-result';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
@@ -191,6 +192,51 @@ describe('push operations', () => {
|
||||
});
|
||||
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// The push setup narrows the emailPush map, or replaces a subscription,
|
||||
// only on the server's verdict about the subscription itself.
|
||||
const rejection = (call: () => Promise<unknown>) => call().then(() => null, (err: unknown) => err);
|
||||
|
||||
it('throws a refused create as a SetError (Stalwart 0.16.16+ and 1.0)', async () => {
|
||||
// As Stalwart answers a map naming a mailbox shared by ACL: no
|
||||
// accountId, state or `created` key (jmap-proto method/set.rs).
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
||||
});
|
||||
const err = await rejection(() =>
|
||||
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
||||
expect(isJMAPSetError(err)).toBe(true);
|
||||
expect(err).toMatchObject({ type: 'forbidden' });
|
||||
});
|
||||
|
||||
it('throws an update of a vanished subscription as a notFound SetError', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['PushSubscription/set', { notUpdated: { s1: { type: 'notFound' } } }, '0']],
|
||||
});
|
||||
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
expect(isJMAPSetError(err)).toBe(true);
|
||||
expect(err).toMatchObject({ type: 'notFound' });
|
||||
});
|
||||
|
||||
it('throws a refused method call as a plain method error, even when forbidden', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Permission denied' }, '0']],
|
||||
});
|
||||
const create = await rejection(() =>
|
||||
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
||||
const update = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
for (const err of [create, update]) {
|
||||
expect(err).toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
||||
expect(isJMAPSetError(err)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not read an update that is neither applied nor refused as a SetError', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/set', {}, '0']] });
|
||||
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
expect(err).toMatchObject({ name: 'JMAPMethodError' });
|
||||
expect(isJMAPSetError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPolling', () => {
|
||||
|
||||
+27
-20
@@ -60,8 +60,31 @@ export function requireMethodResult<T = Record<string, any>>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||||
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||||
* A SetError (RFC 8620 §5.3): the server looked at one object of a /set call
|
||||
* and refused it (`notCreated` / `notUpdated` / `notDestroyed`), as opposed
|
||||
* to refusing the whole method call. The same type can come either way
|
||||
* (`forbidden` is both), and only a SetError says something about that
|
||||
* object. It is a `JMAPMethodError` with that name, so `instanceof` checks
|
||||
* and the name-based classification in lib/network-error treat it as before.
|
||||
*/
|
||||
export class JMAPSetError extends JMAPMethodError {
|
||||
/** The properties the server named (`invalidProperties`), if any. */
|
||||
properties?: string[];
|
||||
constructor(type: string, description?: string, properties?: string[]) {
|
||||
super(type, description);
|
||||
if (properties?.length) this.properties = properties;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a thrown value is a SetError rather than a refused method call. */
|
||||
export function isJMAPSetError(err: unknown): err is JMAPSetError {
|
||||
return err instanceof JMAPSetError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a `JMAPSetError` when a /set response reports the given ids (or any
|
||||
* id when `ids` is omitted) under `notCreated` / `notUpdated` /
|
||||
* `notDestroyed`.
|
||||
*/
|
||||
export function assertSetResult(
|
||||
body: Record<string, any>,
|
||||
@@ -82,9 +105,10 @@ export function assertSetResult(
|
||||
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err?.description) detail.push(err.description);
|
||||
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||||
throw new JMAPMethodError(
|
||||
throw new JMAPSetError(
|
||||
err?.type ?? 'setError',
|
||||
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||||
err?.properties,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,23 +158,6 @@ export function batched<T>(items: T[], size: number): T[][] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SetError (RFC 8620 §5.3): the server looked at one object of a /set call
|
||||
* and refused it (`notCreated` / `notUpdated` / `notDestroyed`), as opposed
|
||||
* to refusing the whole method call. The same type can come either way
|
||||
* (`forbidden` is both), and only a SetError says something about that
|
||||
* object. It is a `JMAPMethodError` with that name, so `instanceof` checks
|
||||
* and the name-based classification in lib/network-error treat it as before.
|
||||
*/
|
||||
export class JMAPSetError extends JMAPMethodError {
|
||||
/** The properties the server named (`invalidProperties`), if any. */
|
||||
properties?: string[];
|
||||
constructor(type: string, description?: string, properties?: string[]) {
|
||||
super(type, description);
|
||||
if (properties?.length) this.properties = properties;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties a thrown SetError names, so a caller can tell
|
||||
* `invalidProperties` on `media` from one on `name`; empty for anything else.
|
||||
|
||||
+13
-7
@@ -20,6 +20,11 @@ function pushUsing(withEmailPush: boolean): string[] {
|
||||
return withEmailPush ? [CAPABILITIES.CORE, CAPABILITIES.EMAIL_PUSH] : [CAPABILITIES.CORE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every PushSubscription of the account. Throws when the server can't be
|
||||
* asked or refuses the call - never an empty list instead: to the push setup
|
||||
* an empty list means "the server no longer has our subscription".
|
||||
*/
|
||||
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
const withEmailPush = hasEmailPushCapability();
|
||||
// `emailPush` is not in the server's default property set, so ask for it
|
||||
@@ -41,9 +46,9 @@ export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
||||
* Returns the server-assigned id (which the client also registers with the
|
||||
* relay so the relay can route incoming pushes to an Expo token) and the
|
||||
* expiry the server settled on. A refusal throws a JMAPMethodError carrying
|
||||
* the server's SetError type, so callers can tell a `forbidden` emailPush map
|
||||
* from other failures.
|
||||
* expiry the server settled on. A refused subscription throws a JMAPSetError
|
||||
* carrying the SetError type, so callers can tell a `forbidden` emailPush map
|
||||
* from a refused method call and other failures.
|
||||
*/
|
||||
export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
@@ -88,10 +93,11 @@ export async function createPushSubscription(params: {
|
||||
|
||||
/**
|
||||
* Push the subscription's expiry forward and re-sync its types or delivery
|
||||
* filter (RFC 8620 §7.2.1). Throws a JMAPMethodError carrying the server's
|
||||
* SetError type when it refuses the update - `notFound` once the
|
||||
* subscription is gone, `forbidden` for an emailPush map naming an account
|
||||
* the user may not subscribe to.
|
||||
* filter (RFC 8620 §7.2.1). Throws a JMAPSetError carrying the SetError type
|
||||
* when the server refuses the update - `notFound` once the subscription is
|
||||
* gone, `forbidden` for an emailPush map naming an account the user may not
|
||||
* subscribe to. An answer that neither updates nor refuses it is a plain
|
||||
* JMAPMethodError: it says nothing about the subscription.
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
isPushSupported,
|
||||
isValidRelayUrl,
|
||||
listPushDevices,
|
||||
mailAccountsWithoutPush,
|
||||
PushSetupError,
|
||||
readPushAccountIds,
|
||||
revokePushDevice,
|
||||
@@ -143,6 +145,26 @@ export function NotificationSettings() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeAccountId, supported]);
|
||||
|
||||
// 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 [sharedWithoutPush, setSharedWithoutPush] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!supported || !client) {
|
||||
setSharedWithoutPush(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const accountIds = mailboxes.flatMap((m) => (m.isShared && m.accountId ? [m.accountId] : []));
|
||||
// Re-read after each enable run too: it may have added refused accounts.
|
||||
void mailAccountsWithoutPush(accountIds).then(
|
||||
(ids) => { if (!cancelled) setSharedWithoutPush(ids.length > 0); },
|
||||
() => undefined,
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [supported, client, mailboxes, pushStatus.kind]);
|
||||
|
||||
const describeError = (error: unknown): string => {
|
||||
if (error instanceof PushSetupError) {
|
||||
const phase = PHASE_KEYS[error.phase];
|
||||
@@ -284,6 +306,15 @@ export function NotificationSettings() {
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{sharedWithoutPush && (
|
||||
<Text style={styles.fieldDescription}>
|
||||
{t(
|
||||
'settings.notifications.push.shared_mailboxes_no_push',
|
||||
"Mailboxes others share with you don't send background notifications.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{upSupported && (
|
||||
<>
|
||||
<SettingItem
|
||||
|
||||
@@ -4,12 +4,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
// Mailbox/get (and the shared accounts') on every sign-in and start, next to
|
||||
// the mail store's identical load (PF7).
|
||||
|
||||
// A group the user belongs to lists emailpush on 0.16.16+ and 1.0; a mailbox
|
||||
// shared by ACL does on 0.16.16-0.16.23 only.
|
||||
const { EMAIL_PUSH } = vi.hoisted(() => ({
|
||||
EMAIL_PUSH: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
username: 'user@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
accountId: 'jmap-primary',
|
||||
currentSession: { capabilities: {} },
|
||||
currentSession: {
|
||||
capabilities: EMAIL_PUSH,
|
||||
accounts: {
|
||||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
|
||||
team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: true, accountCapabilities: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -53,6 +66,18 @@ describe('push filter folders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out an account that does not list emailpush (a mailbox shared by ACL on Stalwart 1.0)', async () => {
|
||||
provideLoadedMailboxes(async () => [
|
||||
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
|
||||
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b', isShared: true },
|
||||
] as never);
|
||||
|
||||
const config = await buildEmailPushConfig();
|
||||
|
||||
expect(Object.keys(config).sort()).toEqual(['jmap-primary', 'team']);
|
||||
});
|
||||
|
||||
it('fetches them itself when the store has none for this account', async () => {
|
||||
provideLoadedMailboxes(async () => null);
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
deviceClientIdKey,
|
||||
disablePushForAccount,
|
||||
isValidRelayUrl,
|
||||
mailAccountsWithoutPush,
|
||||
notificationTapJmapAccountId,
|
||||
readPushAccountIds,
|
||||
readPushJmapAccountIds,
|
||||
@@ -89,7 +90,7 @@ import {
|
||||
verifyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { getSharedMailboxes } from '../../api/email';
|
||||
import { JMAPMethodError } from '../../api/jmap-result';
|
||||
import { JMAPMethodError, JMAPSetError } from '../../api/jmap-result';
|
||||
import type { EmailPushConfig } from '../../api/types';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { NativeModules } from 'react-native';
|
||||
@@ -354,11 +355,13 @@ describe('setupPushNotifications subscription shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 0.16.16-0.16.23 (B18)', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
// 0.16.16 to 0.16.23 list emailpush on every account, ACL shares included.
|
||||
const calendars = (mayCreateCalendar: boolean) => ({
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:calendars': { mayCreateCalendar },
|
||||
});
|
||||
// What Stalwart puts in the session: a group the user belongs to and a
|
||||
@@ -371,8 +374,10 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(false) },
|
||||
},
|
||||
};
|
||||
// The refusal is a SetError on the subscription (`notCreated` /
|
||||
// `notUpdated`), not a method error.
|
||||
const forbidden = () =>
|
||||
new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
// Stalwart refuses the whole map as soon as it names an account the user
|
||||
// doesn't own; `allowed` is what it accepts.
|
||||
const refuseUnless = (allowed: string[]) => (emailPush?: Record<string, EmailPushConfig>) => {
|
||||
@@ -514,6 +519,262 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it('does not narrow the map when the whole call is refused as forbidden', async () => {
|
||||
// A method-level `forbidden` (no permission to subscribe at all) says
|
||||
// nothing about the accounts in the map.
|
||||
createMock.mockImplementation(async () => {
|
||||
throw new JMAPMethodError('forbidden', 'You do not have enough permissions.');
|
||||
});
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toBe("The mail server doesn't allow background notifications for this account.");
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('explains a refused create when the server has too many subscriptions', async () => {
|
||||
createMock.mockImplementation(async () => {
|
||||
throw new JMAPSetError('overQuota', 'There are too many push subscriptions, please delete some.');
|
||||
});
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toContain('too many push registrations on the mail server');
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 1.0', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||
const OWNER = { accountIdForPrincipal: 'jmap-primary', principalId: 'jmap-primary' };
|
||||
// 1.0 lists emailpush on the user's own account and their groups only; an
|
||||
// ACL share lacks it (and Submission, Sieve, VacationResponse, Quota), and
|
||||
// every account carries principals:owner (jmap/src/api/session.rs).
|
||||
const SESSION_1_0 = {
|
||||
capabilities: { 'urn:ietf:params:jmap:emailpush': {}, 'urn:ietf:params:jmap:metadata': {} },
|
||||
accounts: {
|
||||
'jmap-primary': {
|
||||
name: 'user',
|
||||
isPersonal: true,
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:principals:owner': OWNER,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
name: 'team',
|
||||
isPersonal: false,
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'team' },
|
||||
},
|
||||
},
|
||||
'acl-b': {
|
||||
name: 'userb',
|
||||
isPersonal: false,
|
||||
isReadOnly: true,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'acl-b' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mapKeys = (emailPush: Record<string, EmailPushConfig> | undefined) =>
|
||||
Object.keys(emailPush ?? {}).sort();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
(jmapClient as { currentSession: unknown }).currentSession = SESSION_1_0;
|
||||
sharedMock.mockResolvedValue([
|
||||
{ id: 'team:inbox', originalId: 'inbox', role: 'inbox', accountId: 'team' },
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||||
]);
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
sharedMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('leaves the ACL share out of the map up front, without a refused call', async () => {
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(mapKeys(createMock.mock.calls[0][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('adds the group to an existing map in one update, leaving the ACL share out', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
const filter = { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'], urgency: 'high' };
|
||||
listMock.mockResolvedValue([{
|
||||
id: 'existing',
|
||||
deviceClientId: OUR_DCID,
|
||||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||||
types: ['EmailDelivery'],
|
||||
emailPush: { 'jmap-primary': filter },
|
||||
}]);
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.subscriptionId).toBe('existing');
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications when the server cannot be asked', () => {
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||||
const dueForRefresh = () => ({ id: 'existing', deviceClientId: OUR_DCID, expires: inDays(3), types: ['EmailDelivery'] });
|
||||
const named = (name: string, message: string) => Object.assign(new Error(message), { name });
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listMock.mockImplementation(async () => []);
|
||||
updateMock.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
it.each([false, true])('keeps the subscription and its key when listing fails (forceRecreate: %s)', async (forceRecreate) => {
|
||||
listMock.mockRejectedValue(named('NetworkError', 'Network request failed'));
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toContain('Network request failed');
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(destroyMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a network error', named('NetworkError', 'Network request failed')],
|
||||
['a timeout', named('RequestTimeoutError', 'Request timed out after 30s')],
|
||||
['rate limiting', named('RateLimitError', 'Rate limited by server')],
|
||||
['a refused method call', new JMAPMethodError('serverFail', 'Store unavailable')],
|
||||
])('keeps a working subscription when its refresh fails with %s', async (_what, error) => {
|
||||
listMock.mockResolvedValue([dueForRefresh()]);
|
||||
updateMock.mockRejectedValue(error);
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(destroyMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it('replaces the subscription when the server says it is gone, destroying it after verification', async () => {
|
||||
listMock.mockResolvedValue([dueForRefresh()]);
|
||||
updateMock.mockRejectedValue(new JMAPSetError('notFound'));
|
||||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.subscriptionId).toBe('new-server-id');
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
const destroyOrder = destroyMock.mock.invocationCallOrder[
|
||||
destroyMock.mock.calls.findIndex((c) => c[0] === 'existing')
|
||||
];
|
||||
expect(destroyOrder).toBeGreaterThan(verifyMock.mock.invocationCallOrder[0]);
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailAccountsWithoutPush', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const account = (isPersonal: boolean, caps: Record<string, unknown>, isReadOnly = false) => ({
|
||||
name: 'x', isPersonal, isReadOnly, accountCapabilities: caps,
|
||||
});
|
||||
const owned = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: true } };
|
||||
const aclShared = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false } };
|
||||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
});
|
||||
|
||||
it('names the accounts without emailpush on Stalwart 1.0', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||||
team: account(false, { ...owned, ...emailPush }),
|
||||
'acl-b': account(false, aclShared, true),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-b', 'jmap-primary'])).toEqual(['acl-b']);
|
||||
});
|
||||
|
||||
it('falls back to refused and not-owned accounts on Stalwart 0.16.16-0.16.23', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||||
team: account(false, { ...owned, ...emailPush }),
|
||||
'acl-b': account(false, { ...aclShared, ...emailPush }),
|
||||
'acl-c': account(false, { ...owned, ...emailPush }),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||||
// An account the server refused in the map, whatever it looks like.
|
||||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-c']));
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-c'])).toEqual(['acl-b', 'acl-c']);
|
||||
});
|
||||
|
||||
it('judges by ownership alone on Stalwart 0.16.6-0.16.15, which has no emailpush', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: {},
|
||||
accounts: {
|
||||
'jmap-primary': account(true, owned),
|
||||
team: account(false, owned),
|
||||
'acl-b': account(false, aclShared),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||||
});
|
||||
|
||||
it('names nothing for the user\'s own mailboxes', async () => {
|
||||
expect(await mailAccountsWithoutPush(['jmap-primary'])).toEqual([]);
|
||||
expect(await mailAccountsWithoutPush([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications over UnifiedPush', () => {
|
||||
@@ -574,18 +835,22 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('narrows a refused emailPush map over UnifiedPush as well', async () => {
|
||||
it('narrows a refused emailPush map over UnifiedPush as well (Stalwart 0.16.16+)', async () => {
|
||||
installUpFetch();
|
||||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||
accounts: { 'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false } },
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: emailPush },
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: emailPush },
|
||||
},
|
||||
};
|
||||
(getSharedMailboxes as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||||
]);
|
||||
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||||
if (params.emailPush && 'acl-b' in params.emailPush) {
|
||||
throw new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
throw new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
}
|
||||
return CREATED;
|
||||
});
|
||||
|
||||
+125
-20
@@ -10,9 +10,9 @@ import {
|
||||
import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||
import { loadedMailboxes } from './mailbox-source';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { JMAPMethodError } from '../api/jmap-result';
|
||||
import { isJMAPSetError, JMAPMethodError } from '../api/jmap-result';
|
||||
import { CAPABILITIES } from '../api/types';
|
||||
import type { EmailPushConfig, JMAPAccountInfo, Mailbox } from '../api/types';
|
||||
import type { EmailPushConfig, JMAPAccountInfo, Mailbox, PushSubscription } from '../api/types';
|
||||
import { generateAccountId } from './account-utils';
|
||||
import { t } from '../stores/locale-store';
|
||||
import {
|
||||
@@ -251,12 +251,29 @@ export function serverSupportsEmailPush(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session lists emailpush among the account's own capabilities.
|
||||
* Not jmapClient.hasAccountCapability: that falls back to the session-level
|
||||
* capability, which Stalwart advertises whatever the account.
|
||||
*/
|
||||
function accountListsEmailPush(accountId: string): boolean {
|
||||
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities;
|
||||
return !!caps && EMAIL_PUSH_CAPABILITY in caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The delivery filter we want on every account the subscription fans out to:
|
||||
* skip anything the spam filter tagged `$junk` and anything that lives only in
|
||||
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
|
||||
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
|
||||
* to keyword-only filtering rather than letting everything through.
|
||||
*
|
||||
* 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
|
||||
* the setup narrows the map when the server refuses it.
|
||||
*/
|
||||
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
|
||||
const primary = jmapClient.accountId;
|
||||
@@ -272,6 +289,7 @@ export async function buildEmailPushConfig(): Promise<Record<string, EmailPushCo
|
||||
];
|
||||
for (const m of all) {
|
||||
const accountId = m.accountId || primary;
|
||||
if (accountId !== primary && !accountListsEmailPush(accountId)) continue;
|
||||
const junk = junkByAccount.get(accountId) ?? [];
|
||||
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
|
||||
// the server only knows the original.
|
||||
@@ -363,8 +381,11 @@ export function emailPushFallbacks(
|
||||
|
||||
/**
|
||||
* Run a subscription write that carries `emailPush`, narrowing the map along
|
||||
* emailPushFallbacks while the server refuses it as `forbidden`. Resolves to
|
||||
* the write's result and the map the server took.
|
||||
* emailPushFallbacks while the server refuses the subscription with a
|
||||
* `forbidden` SetError. Anything else - a refused method call (which can be
|
||||
* `forbidden` too), 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<T>(
|
||||
emailPush: Record<string, EmailPushConfig> | null,
|
||||
@@ -377,8 +398,8 @@ async function writeWithEmailPush<T>(
|
||||
try {
|
||||
return { result: await write(attempts[i]), emailPush: attempts[i] };
|
||||
} catch (err) {
|
||||
const forbidden = err instanceof JMAPMethodError && err.type === 'forbidden';
|
||||
if (!forbidden || i === attempts.length - 1) throw err;
|
||||
const mapRefused = isJMAPSetError(err) && err.type === 'forbidden';
|
||||
if (!mapRefused || i === attempts.length - 1) throw err;
|
||||
logPhase('jmap', 'emailPush map refused, retrying with fewer accounts');
|
||||
}
|
||||
}
|
||||
@@ -416,6 +437,40 @@ function withoutAccounts(
|
||||
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 async function mailAccountsWithoutPush(accountIds: Iterable<string>): Promise<string[]> {
|
||||
let primary: string;
|
||||
try {
|
||||
primary = jmapClient.accountId;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary);
|
||||
if (others.length === 0) return [];
|
||||
if (serverSupportsEmailPush()) {
|
||||
const withoutEmailPush = others.filter((id) => !accountListsEmailPush(id));
|
||||
if (withoutEmailPush.length > 0) return withoutEmailPush;
|
||||
}
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
const refused = username && serverUrl
|
||||
? await readRefusedEmailPushAccounts(generateAccountId(username, serverUrl))
|
||||
: [];
|
||||
const accounts = jmapClient.currentSession?.accounts;
|
||||
return others.filter((id) => refused.includes(id) || !looksOwned(accounts?.[id]));
|
||||
}
|
||||
|
||||
type BulwarkFcmNative = {
|
||||
getToken(): Promise<string>;
|
||||
deleteToken(): Promise<void>;
|
||||
@@ -488,6 +543,35 @@ export class PushSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// A subscription write the mail server refused or could not take. The
|
||||
// refusals a user can do something about read as plain text; anything else
|
||||
// keeps the error's own message.
|
||||
function subscriptionError(err: unknown): PushSetupError {
|
||||
const type = err instanceof JMAPMethodError ? err.type : undefined;
|
||||
if (type === 'forbidden') {
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_forbidden', "The mail server doesn't allow background notifications for this account."),
|
||||
);
|
||||
}
|
||||
if (type === 'overQuota') {
|
||||
// Stalwart allows 15 subscriptions per account by default, and a
|
||||
// replacement is created before the one it replaces is destroyed.
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t(
|
||||
'settings.notifications.push.err_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.',
|
||||
),
|
||||
);
|
||||
}
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
|
||||
);
|
||||
}
|
||||
|
||||
function randomClientId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
@@ -916,8 +1000,20 @@ async function setupPushNotificationsInner(
|
||||
// With forceRecreate we skip the reuse and replace it instead (#841). Either
|
||||
// way the old record keeps delivering until its replacement is verified, so
|
||||
// a replacement the server refuses never leaves the device 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.
|
||||
logPhase('jmap');
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
let existingSubs: PushSubscription[];
|
||||
try {
|
||||
existingSubs = await listPushSubscriptions();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_list', 'Could not read the push registrations from the mail server: {detail}', { detail }),
|
||||
);
|
||||
}
|
||||
// A forced re-registration re-learns which accounts the server accepts in
|
||||
// the emailPush map.
|
||||
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
|
||||
@@ -939,7 +1035,15 @@ async function setupPushNotificationsInner(
|
||||
if (match) {
|
||||
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
|
||||
if (!params.forceRecreate) {
|
||||
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||
let refreshed: Awaited<ReturnType<typeof refreshSubscriptionExpires>>;
|
||||
try {
|
||||
refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||
} catch (err) {
|
||||
// Nothing the server said about the subscription (a failed request,
|
||||
// rate limiting, a refused method call): it keeps delivering, and
|
||||
// the next run tries again.
|
||||
throw subscriptionError(err);
|
||||
}
|
||||
if (refreshed) {
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
await addPushAccountId(accountId);
|
||||
@@ -949,8 +1053,9 @@ async function setupPushNotificationsInner(
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
}
|
||||
// Server rejected the refresh or the caller asked for a fresh record:
|
||||
// create a replacement below and destroy this one once it's verified.
|
||||
// The server refused the refresh with a SetError, or the caller asked
|
||||
// for a fresh record: create a replacement below and destroy this one
|
||||
// once it's verified.
|
||||
replacedServerId = storedServerId;
|
||||
} else {
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
@@ -998,11 +1103,7 @@ async function setupPushNotificationsInner(
|
||||
serverExpires = created.result.expires;
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
|
||||
);
|
||||
throw subscriptionError(err);
|
||||
}
|
||||
|
||||
logPhase('verify');
|
||||
@@ -1041,9 +1142,12 @@ async function addPushAccountId(accountId: string): Promise<void> {
|
||||
// server's ceiling, and re-sync `types` / the delivery filter when they drift
|
||||
// from what this client wants (a subscription created by an older build still
|
||||
// listens to `Email`/`Mailbox`; a Junk mailbox id can change under us).
|
||||
// Returns false if the server rejects the update, which the caller treats as
|
||||
// "replace"; otherwise the emailPush map the update installed (null when it
|
||||
// left the filter alone).
|
||||
// Returns false when the server refuses the update with a SetError (the
|
||||
// subscription is gone, or no narrower map is accepted), which the caller
|
||||
// answers with a replacement; otherwise the emailPush map the update
|
||||
// installed (null when it left the filter alone). Every other failure - the
|
||||
// network, a timeout, rate limiting, a refused method call - says nothing
|
||||
// about the subscription and is thrown.
|
||||
async function refreshSubscriptionExpires(
|
||||
sub: {
|
||||
id: string;
|
||||
@@ -1075,8 +1179,9 @@ async function refreshSubscriptionExpires(
|
||||
(filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
|
||||
);
|
||||
return { emailPush };
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
if (isJMAPSetError(err)) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user