From 4ec2ccac1163fe3d079460fe7cc94ca203e83c05 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:03:24 +0100 Subject: [PATCH] fix: say the mail server refuses push relays that are not on a public domain --- locales/rn/en.json | 1 + .../settings/NotificationSettings.tsx | 3 +- src/lib/__tests__/push-notifications.test.ts | 39 +++++++++++++++++++ src/lib/push-notifications.ts | 37 ++++++++++++++++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index f81aa537..7ac7c963 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1058,6 +1058,7 @@ "err_relay_rejected": "The push relay rejected the registration: {detail}", "err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).", "err_up_android_only": "UnifiedPush is only available on Android.", + "err_url_refused": "The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.", "err_verify_rejected": "The mail server rejected the verification code: {detail}", "err_verify_relay": "The relay never received the verification code from the mail server (last relay response: {detail}).", "err_verify_timeout": "The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.", diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 059d0fb9..2b3b1c85 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -197,7 +197,8 @@ export function NotificationSettings() { message: t('settings.notifications.push.status_busy', 'Working…'), }); try { - await setStoredRelayBaseUrl(trimmed); + // The setup stores the relay itself, and keeps the one in use when the + // mail server refuses the new one's address. await setupPushNotifications({ relayBaseUrl: trimmed, accountLabel: username ?? undefined, diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 1869c02d..221ab701 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -371,6 +371,45 @@ describe('setupPushNotifications subscription shape', () => { expect(result.subscriptionId).toBe('new-server-id'); }); + it('says the mail server refuses a relay that is not on a public domain (Stalwart 1.0)', async () => { + const relay = 'https://relay.home.lan'; + createMock.mockRejectedValueOnce(new JMAPSetError( + 'invalidProperties', + 'Failed to create push subscription new: invalidProperties – properties=[url] – Host is not a public domain name', + ['url'], + )); + const err = await setupPushNotifications({ relayBaseUrl: relay }).catch((e) => e); + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('jmap'); + expect(err.message).toContain(relay); + expect(err.message).toContain('public domain name'); + expect(err.message).not.toContain('properties=[url]'); + }); + + it('keeps the relay a working registration goes through when the server refuses the new one', 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 fetchMock = global.fetch as ReturnType; + + const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + 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}`]); + + // The same relay refused (a server that tightened its rules) stays as it is. + fetchMock.mockClear(); + createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url'])); + await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch(() => undefined); + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY); + expect(fetchMock.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false); + }); + it('rejects plain-http relay URLs', async () => { await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' }); expect(createMock).not.toHaveBeenCalled(); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index de331774..4d3a3187 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -684,10 +684,30 @@ export class PushSetupError extends Error { } } +/** + * Whether the server refused the push address itself. Stalwart checks the + * URL when a subscription is created: 0.16.27 and 1.0 refuse plain http and + * private or reserved IP addresses, and 1.0 also refuses any host that is not + * a public domain name (localhost, .local, .lan, .internal, a single label). + */ +function isRefusedPushUrl(err: unknown): boolean { + return isJMAPSetError(err) && err.type === 'invalidProperties' && !!err.properties?.includes('url'); +} + // 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 { +function subscriptionError(err: unknown, relayBaseUrl: string): PushSetupError { + if (isRefusedPushUrl(err)) { + return new PushSetupError( + 'jmap', + t( + 'settings.notifications.push.err_url_refused', + 'The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.', + { url: relayBaseUrl }, + ), + ); + } const type = err instanceof JMAPMethodError ? err.type : undefined; if (type === 'forbidden') { return new PushSetupError( @@ -1204,6 +1224,9 @@ async function setupPushNotificationsInner( await migrateLegacyPushKeys(); const deviceClientId = await getOrCreateDeviceClientId(accountId); + // The relay a working registration goes through, put back should the mail + // server refuse the new one's address. + const previousRelayBaseUrl = await getStoredRelayBaseUrl(); await setStoredRelayBaseUrl(relayBaseUrl); // Register this account's device-client-id with the relay. Multiple @@ -1291,7 +1314,7 @@ async function setupPushNotificationsInner( // 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); + throw subscriptionError(err, relayBaseUrl); } if (refreshed) { await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); @@ -1377,7 +1400,15 @@ async function setupPushNotificationsInner( serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { - throw subscriptionError(err); + // The server will never take this relay's address. Whatever registration + // still works goes through the relay used before: keep that one stored + // (token refreshes and the next launch register with it) and drop the + // record this run left on the refused relay. + if (isRefusedPushUrl(err) && previousRelayBaseUrl !== relayBaseUrl) { + await setStoredRelayBaseUrl(previousRelayBaseUrl); + await deregisterFromRelay(relayBaseUrl, deviceClientId); + } + throw subscriptionError(err, relayBaseUrl); } logPhase('verify');