fix: register with the previous push relay again when the mail server refuses a new one

This commit is contained in:
Linus Rath
2026-10-29 02:33:11 +01:00
parent 93ea948045
commit db47162367
2 changed files with 80 additions and 24 deletions
+43 -2
View File
@@ -399,8 +399,18 @@ describe('setupPushNotifications subscription shape', () => {
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
expect(destroyMock).not.toHaveBeenCalledWith('existing');
const deleted = fetchMock.mock.calls.filter((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE').map((c) => String(c[0]));
expect(deleted).toEqual([`https://relay.home.lan/api/push/register/${OUR_DCID}`]);
const relayCalls = fetchMock.mock.calls
.filter((c) => String(c[0]).includes('/api/push/register'))
.map((c) => `${(c[1] as RequestInit | undefined)?.method ?? 'GET'} ${String(c[0])}`);
// The refused URL may be another name of the relay in use: after dropping
// the record there, this device registers with the previous relay again.
expect(relayCalls).toEqual([
'POST https://relay.home.lan/api/push/register',
`DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`,
`POST ${RELAY}/api/push/register`,
]);
const restored = fetchMock.mock.calls.filter((c) => String(c[0]) === `${RELAY}/api/push/register`);
expect(JSON.parse(String((restored[0][1] as RequestInit).body))).toMatchObject({ subscriptionId: OUR_DCID, fcmToken: 'fcm-token-xyz' });
// The same relay refused (a server that tightened its rules) stays as it is.
fetchMock.mockClear();
@@ -1299,6 +1309,37 @@ describe('setupPushNotifications over UnifiedPush', () => {
}
});
it("puts the distributor back on the previous relay's VAPID key when the server refuses the new relay", async () => {
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
await AsyncStorage.setItem(SUB_KEY, 'existing');
listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]);
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
const registered: string[] = [];
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.endsWith('/api/push/vapid-public-key')) {
const publicKey = url.startsWith(RELAY) ? 'VAPID-PUB' : 'VAPID-LAN';
return { ok: true, status: 200, json: async () => ({ publicKey }) } as Response;
}
if (url.includes('/api/push/register')) {
registered.push(`${init?.method ?? 'GET'} ${url}`);
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e);
expect(err).toBeInstanceOf(PushSetupError);
expect(upNative().register.mock.calls.map((c) => c[0])).toEqual(['VAPID-LAN', 'VAPID-PUB']);
expect(registered).toEqual([
'POST https://relay.home.lan/api/push/register/unifiedpush',
`DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`,
`POST ${RELAY}/api/push/register/unifiedpush`,
]);
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
});
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
upNative().getEndpoint.mockResolvedValue({
url: 'https://legacy.example/up123456',
+37 -22
View File
@@ -1235,28 +1235,42 @@ async function setupPushNotificationsInner(
// individually and tags it with the JMAP account id so the headless task
// can route it.
logPhase('relay', relayBaseUrl);
if (apnsToken) {
await registerWithRelayApns({
relayBaseUrl,
subscriptionId: deviceClientId,
deviceToken: apnsToken,
accountLabel: params.accountLabel,
});
} else if (upEndpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl,
subscriptionId: deviceClientId,
endpoint: upEndpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
const registerWith = async (relay: string, endpoint: UnifiedPushEndpoint | null) => {
if (apnsToken) {
await registerWithRelayApns({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
deviceToken: apnsToken,
accountLabel: params.accountLabel,
});
} else if (endpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
endpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
};
await registerWith(relayBaseUrl, upEndpoint);
// Undo this run's relay steps once the mail server refuses the new relay's
// address. The refused URL may be another name of the relay in use, so the
// record dropped from it can be the working one; and a UnifiedPush
// distributor now holds the refused relay's VAPID key, which may make it
// turn away the previous relay's pushes. Registering with the previous relay
// again puts both back; the record itself only for an account that had a
// working subscription through it.
const restoreRelay = async (relay: string, hadSubscription: boolean) => {
const endpoint = upEndpoint ? await getUnifiedPushEndpointOrThrow(relay) : null;
if (hadSubscription) await registerWith(relay, endpoint);
};
// 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.
@@ -1410,6 +1424,7 @@ async function setupPushNotificationsInner(
if (isRefusedPushUrl(err) && previousRelayBaseUrl !== relayBaseUrl) {
await setStoredRelayBaseUrl(previousRelayBaseUrl);
await deregisterFromRelay(relayBaseUrl, deviceClientId);
if (previousRelayBaseUrl) await restoreRelay(previousRelayBaseUrl, !!match).catch(() => undefined);
}
throw subscriptionError(err, relayBaseUrl);
}