fix: say the mail server refuses push relays that are not on a public domain

This commit is contained in:
Linus Rath
2026-10-29 02:03:24 +01:00
parent 20be110537
commit 4ec2ccac11
4 changed files with 76 additions and 4 deletions
+1
View File
@@ -1058,6 +1058,7 @@
"err_relay_rejected": "The push relay rejected the registration: {detail}", "err_relay_rejected": "The push relay rejected the registration: {detail}",
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).", "err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
"err_up_android_only": "UnifiedPush is only available on Android.", "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_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_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.", "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.",
@@ -197,7 +197,8 @@ export function NotificationSettings() {
message: t('settings.notifications.push.status_busy', 'Working…'), message: t('settings.notifications.push.status_busy', 'Working…'),
}); });
try { 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({ await setupPushNotifications({
relayBaseUrl: trimmed, relayBaseUrl: trimmed,
accountLabel: username ?? undefined, accountLabel: username ?? undefined,
@@ -371,6 +371,45 @@ describe('setupPushNotifications subscription shape', () => {
expect(result.subscriptionId).toBe('new-server-id'); 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<typeof vi.fn>;
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 () => { it('rejects plain-http relay URLs', async () => {
await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' }); await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' });
expect(createMock).not.toHaveBeenCalled(); expect(createMock).not.toHaveBeenCalled();
+34 -3
View File
@@ -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 // 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 // refusals a user can do something about read as plain text; anything else
// keeps the error's own message. // 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; const type = err instanceof JMAPMethodError ? err.type : undefined;
if (type === 'forbidden') { if (type === 'forbidden') {
return new PushSetupError( return new PushSetupError(
@@ -1204,6 +1224,9 @@ async function setupPushNotificationsInner(
await migrateLegacyPushKeys(); await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId); 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); await setStoredRelayBaseUrl(relayBaseUrl);
// Register this account's device-client-id with the relay. Multiple // 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, // Nothing the server said about the subscription (a failed request,
// rate limiting, a refused method call): it keeps delivering, and // rate limiting, a refused method call): it keeps delivering, and
// the next run tries again. // the next run tries again.
throw subscriptionError(err); throw subscriptionError(err, relayBaseUrl);
} }
if (refreshed) { if (refreshed) {
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
@@ -1377,7 +1400,15 @@ async function setupPushNotificationsInner(
serverExpires = created.result.expires; serverExpires = created.result.expires;
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
} catch (err) { } 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'); logPhase('verify');