mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: say the mail server refuses push relays that are not on a public domain
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 () => {
|
||||
await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user