mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: keep push on for users with mailboxes shared to them by ACL
The emailPush map named every account with mailboxes. Stalwart refuses the whole map as `forbidden` when it names an account the user neither owns nor belongs to as a group member, so a user with a mailbox shared by ACL had every refresh rejected. The refresh path then destroyed the working subscription and the recreate was refused the same way, leaving push off for good. - On a `forbidden` refusal, retry with a narrower map: first the accounts that look owned (Stalwart sets `mayCreateCalendar` and its siblings only on the user's own and group accounts), then the primary account alone. Stalwart only fans a subscription out to owned accounts, so nothing is lost. - Remember the refused accounts per account and leave them out of the map on later runs; a forced re-register re-learns them. - Keep the old subscription until its replacement is verified, so a refused or failed replacement never leaves the device without push. - `createPushSubscription` and `updatePushSubscription` now throw a `JMAPMethodError` carrying the SetError type. The JMAP part is shared by FCM and UnifiedPush, so both get the fix.
This commit is contained in:
@@ -12,7 +12,7 @@ vi.mock('../jmap-client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { jmapClient } from '../jmap-client';
|
import { jmapClient } from '../jmap-client';
|
||||||
import { startPolling } from '../push';
|
import { createPushSubscription, startPolling, updatePushSubscription } from '../push';
|
||||||
|
|
||||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
@@ -26,6 +26,37 @@ describe('push operations', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PushSubscription/set refusals', () => {
|
||||||
|
const forbidden = {
|
||||||
|
type: 'forbidden',
|
||||||
|
description: 'No access to one of the accounts in the emailPush map.',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('surfaces the SetError type when a create is refused', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }),
|
||||||
|
).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the SetError type when an update is refused', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['PushSubscription/set', { notUpdated: { s1: forbidden } }, '0']],
|
||||||
|
});
|
||||||
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }))
|
||||||
|
.rejects.toMatchObject({ type: 'forbidden' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves when the update is applied', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
||||||
|
});
|
||||||
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('startPolling', () => {
|
describe('startPolling', () => {
|
||||||
it('should poll at the specified interval', async () => {
|
it('should poll at the specified interval', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
|
|||||||
+16
-11
@@ -1,4 +1,5 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { assertSetResult, JMAPMethodError } from './jmap-result';
|
||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
|
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
|
||||||
|
|
||||||
@@ -18,7 +19,9 @@ export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
|||||||
/**
|
/**
|
||||||
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
||||||
* Returns the server-assigned id (which the client also registers with the
|
* Returns the server-assigned id (which the client also registers with the
|
||||||
* relay so the relay can route incoming pushes to an Expo token).
|
* relay so the relay can route incoming pushes to an Expo token). A refusal
|
||||||
|
* throws a JMAPMethodError carrying the server's SetError type, so callers
|
||||||
|
* can tell a `forbidden` emailPush map from other failures.
|
||||||
*/
|
*/
|
||||||
export async function createPushSubscription(params: {
|
export async function createPushSubscription(params: {
|
||||||
deviceClientId: string;
|
deviceClientId: string;
|
||||||
@@ -50,25 +53,25 @@ export async function createPushSubscription(params: {
|
|||||||
[CAPABILITIES.CORE],
|
[CAPABILITIES.CORE],
|
||||||
);
|
);
|
||||||
const [, body] = res.methodResponses[0] ?? [];
|
const [, body] = res.methodResponses[0] ?? [];
|
||||||
|
assertSetResult(body, ['new'], 'push subscription');
|
||||||
const result = body?.created?.new as { id?: string } | undefined;
|
const result = body?.created?.new as { id?: string } | undefined;
|
||||||
if (!result?.id) {
|
if (!result?.id) {
|
||||||
const notCreated = body?.notCreated?.new;
|
throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`);
|
||||||
throw new Error(
|
|
||||||
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result.id;
|
return result.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
|
* Push the subscription's expiry forward and re-sync its types or delivery
|
||||||
* the server rejected the update (e.g. the subscription no longer exists),
|
* filter (RFC 8620 §7.2.1). Throws a JMAPMethodError carrying the server's
|
||||||
* which the caller treats as a signal to recreate.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export async function updatePushSubscription(
|
export async function updatePushSubscription(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
|
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
|
||||||
): Promise<boolean> {
|
): Promise<void> {
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -80,8 +83,10 @@ export async function updatePushSubscription(
|
|||||||
[CAPABILITIES.CORE],
|
[CAPABILITIES.CORE],
|
||||||
);
|
);
|
||||||
const [, body] = res.methodResponses[0] ?? [];
|
const [, body] = res.methodResponses[0] ?? [];
|
||||||
if (body?.notUpdated?.[id]) return false;
|
assertSetResult(body, [id], 'push subscription');
|
||||||
return body?.updated?.[id] !== undefined;
|
if (body?.updated?.[id] === undefined) {
|
||||||
|
throw new JMAPMethodError('notUpdated', `PushSubscription/set did not update ${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
// Provide the Android native FCM surface setupPushNotifications needs. The
|
// Provide the Android native FCM surface setupPushNotifications needs. The
|
||||||
// global test-setup mocks react-native with an empty NativeModules, so override
|
// global test-setup mocks react-native with an empty NativeModules, so override
|
||||||
@@ -58,7 +58,7 @@ vi.mock('../../api/push', () => ({
|
|||||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||||
verifyPushSubscription: vi.fn(async () => undefined),
|
verifyPushSubscription: vi.fn(async () => undefined),
|
||||||
destroyPushSubscription: vi.fn(async () => undefined),
|
destroyPushSubscription: vi.fn(async () => undefined),
|
||||||
updatePushSubscription: vi.fn(async () => true),
|
updatePushSubscription: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
@@ -75,7 +75,11 @@ import {
|
|||||||
createPushSubscription,
|
createPushSubscription,
|
||||||
destroyPushSubscription,
|
destroyPushSubscription,
|
||||||
updatePushSubscription,
|
updatePushSubscription,
|
||||||
|
verifyPushSubscription,
|
||||||
} from '../../api/push';
|
} from '../../api/push';
|
||||||
|
import { getSharedMailboxes } from '../../api/email';
|
||||||
|
import { JMAPMethodError } from '../../api/jmap-result';
|
||||||
|
import type { EmailPushConfig } from '../../api/types';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
import { NativeModules } from 'react-native';
|
import { NativeModules } from 'react-native';
|
||||||
import { generateAccountId } from '../account-utils';
|
import { generateAccountId } from '../account-utils';
|
||||||
@@ -286,6 +290,168 @@ describe('setupPushNotifications subscription shape', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setupPushNotifications with ACL-shared accounts (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>;
|
||||||
|
const calendars = (mayCreateCalendar: boolean) => ({
|
||||||
|
'urn:ietf:params:jmap:calendars': { mayCreateCalendar },
|
||||||
|
});
|
||||||
|
// What Stalwart puts in the session: a group the user belongs to and a
|
||||||
|
// mailbox another user shared by ACL look alike except for the create flags.
|
||||||
|
const SESSION = {
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||||
|
accounts: {
|
||||||
|
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: calendars(true) },
|
||||||
|
team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(true) },
|
||||||
|
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(false) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const forbidden = () =>
|
||||||
|
new JMAPMethodError('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>) => {
|
||||||
|
if (emailPush && Object.keys(emailPush).some((id) => !allowed.includes(id))) throw forbidden();
|
||||||
|
};
|
||||||
|
const healthy = () => ({
|
||||||
|
id: 'existing',
|
||||||
|
deviceClientId: OUR_DCID,
|
||||||
|
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||||||
|
types: ['EmailDelivery'],
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
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([]);
|
||||||
|
createMock.mockImplementation(async () => 'new-server-id');
|
||||||
|
updateMock.mockImplementation(async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows a refused map on refresh instead of destroying the working subscription', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
listMock.mockResolvedValue([healthy()]);
|
||||||
|
const accept = refuseUnless(['jmap-primary', 'team']);
|
||||||
|
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||||||
|
accept(patch.emailPush),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
|
||||||
|
expect(result.subscriptionId).toBe('existing');
|
||||||
|
expect(updateMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['acl-b', 'jmap-primary', 'team']);
|
||||||
|
// The group keeps its junk filter; only the ACL share is dropped.
|
||||||
|
expect(mapKeys(updateMock.mock.calls[1][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||||
|
expect(destroyMock).not.toHaveBeenCalled();
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||||
|
expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!)).toEqual(['acl-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a remembered refusal out of the map on the next run', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-b']));
|
||||||
|
listMock.mockResolvedValue([healthy()]);
|
||||||
|
const accept = refuseUnless(['jmap-primary', 'team']);
|
||||||
|
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||||||
|
accept(patch.emailPush),
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
|
||||||
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the primary account alone when the group is refused too', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
listMock.mockResolvedValue([healthy()]);
|
||||||
|
const accept = refuseUnless(['jmap-primary']);
|
||||||
|
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||||||
|
accept(patch.emailPush),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
|
||||||
|
expect(result.subscriptionId).toBe('existing');
|
||||||
|
expect(updateMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mapKeys(updateMock.mock.calls[2][1].emailPush)).toEqual(['jmap-primary']);
|
||||||
|
expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!).sort()).toEqual(['acl-b', 'team']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows a refused map when creating a subscription', async () => {
|
||||||
|
const accept = refuseUnless(['jmap-primary', 'team']);
|
||||||
|
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||||||
|
accept(params.emailPush);
|
||||||
|
return 'new-server-id';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
|
||||||
|
expect(result).toEqual({ subscriptionId: 'new-server-id', verified: true });
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mapKeys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||||
|
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry other refusals with a narrower map', async () => {
|
||||||
|
createMock.mockImplementation(async () => {
|
||||||
|
throw new JMAPMethodError('overQuota', 'There are too many subscriptions.');
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(PushSetupError);
|
||||||
|
expect(err.phase).toBe('jmap');
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys the replaced subscription only after the new one is verified', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
listMock.mockResolvedValue([healthy()]);
|
||||||
|
|
||||||
|
await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true });
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the working subscription when its replacement is refused', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
listMock.mockResolvedValue([healthy()]);
|
||||||
|
createMock.mockImplementation(async () => {
|
||||||
|
throw forbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e);
|
||||||
|
|
||||||
|
expect(err.phase).toBe('jmap');
|
||||||
|
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||||||
|
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('setupPushNotifications over UnifiedPush', () => {
|
describe('setupPushNotifications over UnifiedPush', () => {
|
||||||
type UpNative = {
|
type UpNative = {
|
||||||
getDistributors: ReturnType<typeof vi.fn>;
|
getDistributors: ReturnType<typeof vi.fn>;
|
||||||
@@ -344,6 +510,35 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
|||||||
expect(createMock).toHaveBeenCalledTimes(1);
|
expect(createMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('narrows a refused emailPush map over UnifiedPush as well', async () => {
|
||||||
|
installUpFetch();
|
||||||
|
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||||
|
accounts: { 'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false } },
|
||||||
|
};
|
||||||
|
(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.');
|
||||||
|
}
|
||||||
|
return 'new-server-id';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(Object.keys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary']);
|
||||||
|
} finally {
|
||||||
|
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||||
|
};
|
||||||
|
createMock.mockImplementation(async () => 'new-server-id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
|
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
|
||||||
upNative().getEndpoint.mockResolvedValue({
|
upNative().getEndpoint.mockResolvedValue({
|
||||||
url: 'https://legacy.example/up123456',
|
url: 'https://legacy.example/up123456',
|
||||||
|
|||||||
+155
-25
@@ -9,7 +9,9 @@ import {
|
|||||||
} from '../api/push';
|
} from '../api/push';
|
||||||
import { getMailboxes, getSharedMailboxes } from '../api/email';
|
import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import type { EmailPushConfig, Mailbox } from '../api/types';
|
import { JMAPMethodError } from '../api/jmap-result';
|
||||||
|
import { CAPABILITIES } from '../api/types';
|
||||||
|
import type { EmailPushConfig, JMAPAccountInfo, Mailbox } from '../api/types';
|
||||||
import { generateAccountId } from './account-utils';
|
import { generateAccountId } from './account-utils';
|
||||||
import {
|
import {
|
||||||
getUnifiedPushDistributors,
|
getUnifiedPushDistributors,
|
||||||
@@ -39,6 +41,10 @@ export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
|
|||||||
// lastNotified id, which could not tell "already shown" from "older mail").
|
// lastNotified id, which could not tell "already shown" from "older mail").
|
||||||
const NOTIFIED_IDS_PREFIX = 'push:notifiedIds:v1:';
|
const NOTIFIED_IDS_PREFIX = 'push:notifiedIds:v1:';
|
||||||
const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:';
|
const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed: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
|
||||||
|
// re-provoked on every launch.
|
||||||
|
const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:';
|
||||||
|
|
||||||
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||||
@@ -67,6 +73,10 @@ function promptDismissedKey(accountId: string): string {
|
|||||||
return PROMPT_DISMISSED_PREFIX + accountId;
|
return PROMPT_DISMISSED_PREFIX + accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emailPushRefusedKey(accountId: string): string {
|
||||||
|
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function readPushAccountIds(): Promise<string[]> {
|
export async function readPushAccountIds(): Promise<string[]> {
|
||||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -277,6 +287,106 @@ export function sameEmailPush(
|
|||||||
return normalizeEmailPush(a) === normalizeEmailPush(b);
|
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.
|
||||||
|
const OWNERSHIP_FLAGS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
[CAPABILITIES.CALENDARS, 'mayCreateCalendar'],
|
||||||
|
[CAPABILITIES.CONTACTS, 'mayCreateAddressBook'],
|
||||||
|
[CAPABILITIES.FILES, 'mayCreateTopLevelFileNode'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksOwned(info: JMAPAccountInfo | undefined): 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<string, unknown> | 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. The
|
||||||
|
* session 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.
|
||||||
|
*/
|
||||||
|
export function emailPushFallbacks(
|
||||||
|
desired: Record<string, EmailPushConfig>,
|
||||||
|
primaryAccountId: string,
|
||||||
|
accounts: Record<string, JMAPAccountInfo> | undefined,
|
||||||
|
): Record<string, EmailPushConfig>[] {
|
||||||
|
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(accounts?.[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 as `forbidden`. Resolves to
|
||||||
|
* the write's result and the map the server took.
|
||||||
|
*/
|
||||||
|
async function writeWithEmailPush<T>(
|
||||||
|
emailPush: Record<string, EmailPushConfig> | null,
|
||||||
|
write: (emailPush: Record<string, EmailPushConfig> | null) => Promise<T>,
|
||||||
|
): Promise<{ result: T; emailPush: Record<string, EmailPushConfig> | null }> {
|
||||||
|
const attempts = emailPush
|
||||||
|
? emailPushFallbacks(emailPush, jmapClient.accountId, jmapClient.currentSession?.accounts)
|
||||||
|
: [null];
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
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;
|
||||||
|
logPhase('jmap', 'emailPush map refused, retrying with fewer accounts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRefusedEmailPushAccounts(accountId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse((await AsyncStorage.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`. */
|
||||||
|
async function rememberRefusedEmailPushAccounts(
|
||||||
|
accountId: string,
|
||||||
|
refusedBefore: string[],
|
||||||
|
wanted: Record<string, EmailPushConfig> | null,
|
||||||
|
accepted: Record<string, EmailPushConfig> | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!wanted || !accepted) return;
|
||||||
|
const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted));
|
||||||
|
if (refusedNow.length === 0) return;
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
emailPushRefusedKey(accountId),
|
||||||
|
JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutAccounts(
|
||||||
|
config: Record<string, EmailPushConfig>,
|
||||||
|
accountIds: string[],
|
||||||
|
): Record<string, EmailPushConfig> {
|
||||||
|
return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id)));
|
||||||
|
}
|
||||||
|
|
||||||
type BulwarkFcmNative = {
|
type BulwarkFcmNative = {
|
||||||
getToken(): Promise<string>;
|
getToken(): Promise<string>;
|
||||||
deleteToken(): Promise<void>;
|
deleteToken(): Promise<void>;
|
||||||
@@ -739,10 +849,18 @@ async function setupPushNotificationsInner(
|
|||||||
|
|
||||||
// Reuse the previous JMAP subscription when the server still has it, but
|
// Reuse the previous JMAP subscription when the server still has it, but
|
||||||
// push the expiry forward so it doesn't time out before the next app start.
|
// push the expiry forward so it doesn't time out before the next app start.
|
||||||
// With forceRecreate we skip the reuse and destroy it instead (#841).
|
// 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.
|
||||||
logPhase('jmap');
|
logPhase('jmap');
|
||||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||||
const emailPush = serverSupportsEmailPush() ? await buildEmailPushConfig() : null;
|
// A forced re-registration re-learns which accounts the server accepts in
|
||||||
|
// the emailPush map.
|
||||||
|
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
|
||||||
|
const refusedBefore = await readRefusedEmailPushAccounts(accountId);
|
||||||
|
const emailPush = serverSupportsEmailPush()
|
||||||
|
? withoutAccounts(await buildEmailPushConfig(), refusedBefore)
|
||||||
|
: null;
|
||||||
const subKey = subscriptionIdKey(accountId);
|
const subKey = subscriptionIdKey(accountId);
|
||||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||||
let jmapAccountId: string | null = null;
|
let jmapAccountId: string | null = null;
|
||||||
@@ -751,24 +869,26 @@ async function setupPushNotificationsInner(
|
|||||||
} catch {
|
} catch {
|
||||||
jmapAccountId = null;
|
jmapAccountId = null;
|
||||||
}
|
}
|
||||||
|
let replacedServerId: string | null = null;
|
||||||
if (storedServerId) {
|
if (storedServerId) {
|
||||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||||
if (match) {
|
if (match) {
|
||||||
if (!params.forceRecreate) {
|
if (!params.forceRecreate) {
|
||||||
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
|
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||||
await addPushAccountId(accountId);
|
await addPushAccountId(accountId);
|
||||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||||
logPhase('done', 'reused existing subscription');
|
logPhase('done', 'reused existing subscription');
|
||||||
return { subscriptionId: storedServerId, verified: true };
|
return { subscriptionId: storedServerId, verified: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Server rejected the refresh (likely the subscription was already
|
// Server rejected the refresh or the caller asked for a fresh record:
|
||||||
// deleted server-side) or the caller asked for a fresh record - drop
|
// create a replacement below and destroy this one once it's verified.
|
||||||
// the stale id and recreate below.
|
replacedServerId = storedServerId;
|
||||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
} else {
|
||||||
|
await AsyncStorage.removeItem(subKey);
|
||||||
}
|
}
|
||||||
await AsyncStorage.removeItem(subKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
||||||
@@ -798,13 +918,17 @@ async function setupPushNotificationsInner(
|
|||||||
|
|
||||||
let serverAssignedId: string;
|
let serverAssignedId: string;
|
||||||
try {
|
try {
|
||||||
serverAssignedId = await createPushSubscription({
|
const created = await writeWithEmailPush(emailPush, (filter) =>
|
||||||
deviceClientId,
|
createPushSubscription({
|
||||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
deviceClientId,
|
||||||
types: [...PUSH_TYPES],
|
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
||||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
types: [...PUSH_TYPES],
|
||||||
...(emailPush ? { emailPush } : {}),
|
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||||
});
|
...(filter ? { emailPush: filter } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
serverAssignedId = created.result;
|
||||||
|
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(err);
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`);
|
throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`);
|
||||||
@@ -820,6 +944,9 @@ async function setupPushNotificationsInner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await AsyncStorage.setItem(subKey, serverAssignedId);
|
await AsyncStorage.setItem(subKey, serverAssignedId);
|
||||||
|
if (replacedServerId) {
|
||||||
|
await destroyPushSubscription(replacedServerId).catch(() => undefined);
|
||||||
|
}
|
||||||
await addPushAccountId(accountId);
|
await addPushAccountId(accountId);
|
||||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||||
logPhase('done', 'subscription verified');
|
logPhase('done', 'subscription verified');
|
||||||
@@ -839,7 +966,8 @@ async function addPushAccountId(accountId: string): Promise<void> {
|
|||||||
// from what this client wants (a subscription created by an older build still
|
// 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).
|
// 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
|
// Returns false if the server rejects the update, which the caller treats as
|
||||||
// "recreate".
|
// "replace"; otherwise the emailPush map the update installed (null when it
|
||||||
|
// left the filter alone).
|
||||||
async function refreshSubscriptionExpires(
|
async function refreshSubscriptionExpires(
|
||||||
sub: {
|
sub: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -849,7 +977,7 @@ async function refreshSubscriptionExpires(
|
|||||||
},
|
},
|
||||||
// null when the server has no emailPush support - leave the property alone.
|
// null when the server has no emailPush support - leave the property alone.
|
||||||
desiredEmailPush: Record<string, EmailPushConfig> | null,
|
desiredEmailPush: Record<string, EmailPushConfig> | null,
|
||||||
): Promise<boolean> {
|
): Promise<false | { emailPush: Record<string, EmailPushConfig> | null }> {
|
||||||
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
|
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
|
||||||
const emailPushNeedsUpdate =
|
const emailPushNeedsUpdate =
|
||||||
desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush);
|
desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush);
|
||||||
@@ -858,18 +986,19 @@ async function refreshSubscriptionExpires(
|
|||||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
||||||
// Plenty of life left - skip the update round-trip.
|
// Plenty of life left - skip the update round-trip.
|
||||||
return true;
|
return { emailPush: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const patch: {
|
const patch: { expires?: string; types?: string[] } = {
|
||||||
expires?: string;
|
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||||
types?: string[];
|
};
|
||||||
emailPush?: Record<string, EmailPushConfig>;
|
|
||||||
} = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) };
|
|
||||||
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
|
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
|
||||||
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
|
const { emailPush } = await writeWithEmailPush(
|
||||||
return await updatePushSubscription(sub.id, patch);
|
emailPushNeedsUpdate ? desiredEmailPush : null,
|
||||||
|
(filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
|
||||||
|
);
|
||||||
|
return { emailPush };
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -891,6 +1020,7 @@ async function clearAccountPushKeys(accountId: string): Promise<void> {
|
|||||||
deviceClientIdKey(accountId),
|
deviceClientIdKey(accountId),
|
||||||
lastNotifiedKey(accountId),
|
lastNotifiedKey(accountId),
|
||||||
notifiedIdsKey(accountId),
|
notifiedIdsKey(accountId),
|
||||||
|
emailPushRefusedKey(accountId),
|
||||||
]);
|
]);
|
||||||
await writePushJmapAccountId(accountId, null);
|
await writePushJmapAccountId(accountId, null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user