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:
Linus Rath
2026-09-24 19:09:35 +02:00
parent 9e95273c14
commit d8046a8aad
4 changed files with 400 additions and 39 deletions
+32 -1
View File
@@ -12,7 +12,7 @@ vi.mock('../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>;
@@ -26,6 +26,37 @@ describe('push operations', () => {
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', () => {
it('should poll at the specified interval', async () => {
mockRequest.mockResolvedValue({
+16 -11
View File
@@ -1,4 +1,5 @@
import { jmapClient } from './jmap-client';
import { assertSetResult, JMAPMethodError } from './jmap-result';
import { CAPABILITIES } 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.
* 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: {
deviceClientId: string;
@@ -50,25 +53,25 @@ export async function createPushSubscription(params: {
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
assertSetResult(body, ['new'], 'push subscription');
const result = body?.created?.new as { id?: string } | undefined;
if (!result?.id) {
const notCreated = body?.notCreated?.new;
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
);
throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`);
}
return result.id;
}
/**
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
* the server rejected the update (e.g. the subscription no longer exists),
* which the caller treats as a signal to recreate.
* 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.
*/
export async function updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
): Promise<boolean> {
): Promise<void> {
const res = await jmapClient.request(
[
[
@@ -80,8 +83,10 @@ export async function updatePushSubscription(
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
if (body?.notUpdated?.[id]) return false;
return body?.updated?.[id] !== undefined;
assertSetResult(body, [id], 'push subscription');
if (body?.updated?.[id] === undefined) {
throw new JMAPMethodError('notUpdated', `PushSubscription/set did not update ${id}`);
}
}
/**
+197 -2
View File
@@ -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
// 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'),
verifyPushSubscription: 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';
@@ -75,7 +75,11 @@ import {
createPushSubscription,
destroyPushSubscription,
updatePushSubscription,
verifyPushSubscription,
} 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 { NativeModules } from 'react-native';
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', () => {
type UpNative = {
getDistributors: ReturnType<typeof vi.fn>;
@@ -344,6 +510,35 @@ describe('setupPushNotifications over UnifiedPush', () => {
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 () => {
upNative().getEndpoint.mockResolvedValue({
url: 'https://legacy.example/up123456',
+155 -25
View File
@@ -9,7 +9,9 @@ import {
} from '../api/push';
import { getMailboxes, getSharedMailboxes } from '../api/email';
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 {
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").
const NOTIFIED_IDS_PREFIX = 'push:notifiedIds: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
// setupPushNotifications / pushBackgroundTask call, then deleted.
@@ -67,6 +73,10 @@ function promptDismissedKey(accountId: string): string {
return PROMPT_DISMISSED_PREFIX + accountId;
}
function emailPushRefusedKey(accountId: string): string {
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
@@ -277,6 +287,106 @@ export 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.
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 = {
getToken(): Promise<string>;
deleteToken(): Promise<void>;
@@ -739,10 +849,18 @@ async function setupPushNotificationsInner(
// 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.
// 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');
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 storedServerId = await AsyncStorage.getItem(subKey);
let jmapAccountId: string | null = null;
@@ -751,24 +869,26 @@ async function setupPushNotificationsInner(
} catch {
jmapAccountId = null;
}
let replacedServerId: string | null = null;
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
if (!params.forceRecreate) {
const refreshed = await refreshSubscriptionExpires(match, emailPush);
if (refreshed) {
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
logPhase('done', 'reused existing subscription');
return { subscriptionId: storedServerId, verified: true };
}
}
// Server rejected the refresh (likely the subscription was already
// deleted server-side) or the caller asked for a fresh record - drop
// the stale id and recreate below.
await destroyPushSubscription(storedServerId).catch(() => undefined);
// Server rejected the refresh 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);
}
await AsyncStorage.removeItem(subKey);
}
// Reap leftover Stalwart subscriptions that would otherwise starve the new
@@ -798,13 +918,17 @@ async function setupPushNotificationsInner(
let serverAssignedId: string;
try {
serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(emailPush ? { emailPush } : {}),
});
const created = await writeWithEmailPush(emailPush, (filter) =>
createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(filter ? { emailPush: filter } : {}),
}),
);
serverAssignedId = created.result;
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`);
@@ -820,6 +944,9 @@ async function setupPushNotificationsInner(
}
await AsyncStorage.setItem(subKey, serverAssignedId);
if (replacedServerId) {
await destroyPushSubscription(replacedServerId).catch(() => undefined);
}
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
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
// 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
// "recreate".
// "replace"; otherwise the emailPush map the update installed (null when it
// left the filter alone).
async function refreshSubscriptionExpires(
sub: {
id: string;
@@ -849,7 +977,7 @@ async function refreshSubscriptionExpires(
},
// null when the server has no emailPush support - leave the property alone.
desiredEmailPush: Record<string, EmailPushConfig> | null,
): Promise<boolean> {
): Promise<false | { emailPush: Record<string, EmailPushConfig> | null }> {
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
const emailPushNeedsUpdate =
desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush);
@@ -858,18 +986,19 @@ async function refreshSubscriptionExpires(
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
// Plenty of life left - skip the update round-trip.
return true;
return { emailPush: null };
}
}
try {
const patch: {
expires?: string;
types?: string[];
emailPush?: Record<string, EmailPushConfig>;
} = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) };
const patch: { expires?: string; types?: string[] } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
return await updatePushSubscription(sub.id, patch);
const { emailPush } = await writeWithEmailPush(
emailPushNeedsUpdate ? desiredEmailPush : null,
(filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
);
return { emailPush };
} catch {
return false;
}
@@ -891,6 +1020,7 @@ async function clearAccountPushKeys(accountId: string): Promise<void> {
deviceClientIdKey(accountId),
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
emailPushRefusedKey(accountId),
]);
await writePushJmapAccountId(accountId, null);
}