fix: renew push for every signed-in account and show each account's push state in the notification settings

This commit is contained in:
Linus Rath
2026-10-29 02:57:24 +01:00
parent 9ccb62286e
commit d92a2ca835
5 changed files with 268 additions and 8 deletions
+5 -1
View File
@@ -35,6 +35,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
notificationTapJmapAccountId,
renewOtherPushAccounts,
resyncPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
@@ -672,13 +673,16 @@ export default function App() {
relayBaseUrl,
accountLabel: client.username ?? undefined,
});
if (cancelled) return;
} catch (error) {
console.warn(
'[push] relay setup failed:',
error instanceof Error ? error.message : error,
);
}
if (cancelled) return;
// The other signed-in accounts' subscriptions expire too (webmail #1054); each
// is renewed through a client of its own.
await renewOtherPushAccounts().catch(() => undefined);
};
void doSetup();
+1
View File
@@ -1041,6 +1041,7 @@
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
},
"push": {
"accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.",
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"err_android_only": "Push notifications are only available on Android.",
@@ -13,6 +13,7 @@ import Dialog from '../Dialog';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAccountStore } from '../../stores/account-store';
import { useAuthStore } from '../../stores/auth-store';
import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store';
@@ -28,12 +29,14 @@ import {
mailAccountsWithoutPush,
PushSetupError,
readPushAccountIds,
readPushAccountStates,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
enablePushFromSettings,
getStoredPushTransport,
setStoredPushTransport,
type PushAccountState,
type PushDevice,
type PushSetupPhase,
type PushTransport,
@@ -178,6 +181,22 @@ export function NotificationSettings() {
return () => { cancelled = true; };
}, [supported, client, mailboxes, pushStatus.kind]);
// Push is set up per account, and the switch above is the shown account's.
// List every signed-in account's state so the others aren't a guess (webmail #1054).
const accounts = useAccountStore((s) => s.accounts);
const accountIdsKey = accounts.map((a) => a.id).join('\n');
const [accountStates, setAccountStates] = useState<Record<string, PushAccountState>>({});
useEffect(() => {
if (!supported || accounts.length < 2) return;
let cancelled = false;
void readPushAccountStates(accounts.map((a) => a.id)).then(
(states) => { if (!cancelled) setAccountStates(states); },
() => undefined,
);
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [supported, accountIdsKey, pushEnabled, pushStatus.kind]);
const describeError = (error: unknown): string => {
if (error instanceof PushSetupError) {
const phase = PHASE_KEYS[error.phase];
@@ -500,6 +519,34 @@ export function NotificationSettings() {
</SettingsSection>
)}
{supported && accounts.length > 1 && (
<SettingsSection
title={t('settings.notifications.push_all.title', 'All accounts')}
description={t(
'settings.notifications.push.accounts_desc_mobile',
'Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.',
)}
>
{accounts.map((account, index) => {
const on = accountStates[account.id]?.enabled === true;
const address = account.email || account.username;
const state = on
? t('settings.notifications.push_all.state_on', 'notifications on')
: t('settings.notifications.push_all.state_off', 'notifications off');
return (
<SettingItem
key={account.id}
label={account.displayName || address}
description={`${address} · ${state}`}
noBorder={index === accounts.length - 1}
>
{on ? <CheckCircle2 size={16} color={c.success} /> : <View />}
</SettingItem>
);
})}
</SettingsSection>
)}
<SettingsSection
title={t('settings.notifications.email.title', 'Email Notifications')}
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
+99 -7
View File
@@ -63,6 +63,9 @@ const { DETACHED, SYNC_SESSION } = vi.hoisted(() => {
loaded: true,
types: ['EmailDelivery'] as string[],
deviceClientId: null as string | null,
// The subscription's expiry, and what an update of it settles on.
expires: null as string | null,
settles: null as string | null,
log: [] as unknown[],
session: SYNC_SESSION,
},
@@ -84,14 +87,29 @@ vi.mock('../../api/jmap-client', () => ({
DETACHED.log.push(['load', id]);
return DETACHED.loaded;
}
get username() {
return 'bob';
}
get accountId() {
return 'bob-jmap';
}
async request(calls: Array<[string, Record<string, any>, string]>) {
const [name, args, id] = calls[0];
DETACHED.log.push([name, args]);
if (name === 'PushSubscription/get') {
const listed = { id: args.ids[0], types: DETACHED.types, ...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}) };
return { methodResponses: [[name, { list: [listed] }, id]] };
}
return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] };
const methodResponses = calls.map(([name, args, id]) => {
DETACHED.log.push([name, args]);
if (name === 'PushSubscription/get') {
const listed = {
id: args.ids[0],
types: DETACHED.types,
...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}),
...(DETACHED.expires ? { expires: DETACHED.expires } : {}),
};
return [name, { list: [listed] }, id];
}
const target = Object.keys(args.update)[0];
if (args.update[target].expires && DETACHED.settles) DETACHED.expires = DETACHED.settles;
return [name, { updated: { [target]: null } }, id];
});
return { methodResponses };
}
},
}));
@@ -126,7 +144,9 @@ import {
readPushJmapAccountIds,
PushSetupError,
pushTypesFor,
readPushAccountStates,
refreshPushSubscriptionTypes,
renewOtherPushAccounts,
resyncPushNotifications,
revokePushDevice,
teardownPushNotificationsForAccount,
@@ -1683,6 +1703,78 @@ describe('resyncPushNotifications', () => {
});
});
describe('renewOtherPushAccounts (#1054)', () => {
const OTHER = 'bob@other.example.net';
const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp';
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID, OTHER]));
await AsyncStorage.setItem(SUB_KEY, 'existing');
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID);
Object.assign(DETACHED, { loaded: true, deviceClientId: OTHER_DCID, expires: inDays(2), settles: inDays(7), log: [] });
installFetch({});
});
afterEach(() => {
Object.assign(DETACHED, { deviceClientId: null, expires: null, settles: null });
});
it("extends another account's subscription through a client of its own and records the server's expiry", async () => {
const settles = DETACHED.settles;
await renewOtherPushAccounts();
const calls = DETACHED.log.map((entry) => (entry as unknown[])[0]);
// The loaded account is the launch-time resync's; only the other is touched here.
expect(calls).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
expect(DETACHED.log[0]).toEqual(['load', OTHER]);
expect(updateMock).not.toHaveBeenCalled();
expect(listMock).not.toHaveBeenCalled();
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(settles);
// Its relay registration carries this device's current token.
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const registration = fetchMock.mock.calls.find((c) => String(c[0]) === `${RELAY}/api/push/register`);
expect(JSON.parse(String((registration![1] as RequestInit).body))).toEqual({
subscriptionId: OTHER_DCID,
fcmToken: 'fcm-token-xyz',
accountLabel: 'bob',
});
});
it('only records the expiry of a subscription with more than a week left', async () => {
DETACHED.expires = inDays(30);
await renewOtherPushAccounts();
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(DETACHED.expires);
});
it("leaves alone another client's subscription that reuses the id, and an account push was turned off for", async () => {
DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm';
await renewOtherPushAccounts();
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
DETACHED.log = [];
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
await renewOtherPushAccounts();
expect(DETACHED.log).toEqual([]);
});
it('tells the settings which accounts have push on this device', async () => {
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
await AsyncStorage.removeItem(`push:subscriptionId:v2:${OTHER}`);
expect(await readPushAccountStates([ACCOUNT_ID, OTHER, 'carl@example.org'])).toEqual({
[ACCOUNT_ID]: { enabled: true, optedOut: false },
[OTHER]: { enabled: false, optedOut: true },
'carl@example.org': { enabled: false, optedOut: false },
});
});
});
describe('teardownPushNotificationsForAccount', () => {
beforeEach(async () => {
vi.clearAllMocks();
+116
View File
@@ -34,6 +34,7 @@ import { credentialsKey } from '../api/jmap-client';
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
import {
getUnifiedPushDistributors,
getUnifiedPushEndpoint,
isUnifiedPushSupported,
registerUnifiedPush,
unregisterUnifiedPush,
@@ -1853,6 +1854,121 @@ export async function refreshPushSubscriptionTypes(accountId: string): Promise<v
}
}
/**
* Keep push alive for the signed-in accounts the app is not showing (webmail #1054).
* The launch-time resync works on the loaded account only, and Stalwart keeps
* a subscription for seven days from its last refresh, so another account's
* push stopped about a week after the user last switched to it. Each account
* goes through a client of its own, never the singleton: its registration
* with the relay is renewed (the device token may have rotated since), its
* subscription's expiry pushed forward and the expiry the server settled on
* recorded. A subscription the server no longer has - lapsed, or revoked
* elsewhere - is left to the account's own resync, which can tell the two
* apart and verify a new one; so are its delivery filter and push types.
* Best effort: whatever fails is tried again on the next launch.
*/
export async function renewOtherPushAccounts(): Promise<void> {
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const served = username && serverUrl ? generateAccountId(username, serverUrl) : null;
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of await readPushAccountIds()) {
if (accountId === served) continue;
try {
await renewPushAccount(accountId, relayBaseUrl);
} catch (err) {
logPhase('renew', `${accountId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
type ListedSubscription = { id: string; deviceClientId?: string | null; expires?: string | null };
async function renewPushAccount(accountId: string, relayBaseUrl: string | null): Promise<void> {
if (await AsyncStorage.getItem(optedOutKey(accountId))) return;
const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
if (!subscriptionId) return;
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
const client = new JMAPClient();
if (!(await client.loadAccount(accountId))) return;
if (relayBaseUrl && deviceClientId) {
await registerDeviceWithRelay(relayBaseUrl, deviceClientId, client.username ?? undefined).catch(() => undefined);
}
const using = [CAPABILITIES.CORE];
const properties = ['id', 'deviceClientId', 'expires'];
const ownIn = (res: Awaited<ReturnType<JMAPClient['request']>>, callId: string) =>
(requireMethodResult(res, callId, 'PushSubscription/get').list as ListedSubscription[] | undefined)
?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
const current = ownIn(
await client.request([['PushSubscription/get', { ids: [subscriptionId], properties }, '0']], using),
'0',
);
if (!current) return;
let expires = current.expires ?? null;
const remainingMs = Date.parse(expires ?? '') - Date.now();
if (!(remainingMs > SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000)) {
const res = await client.request(
[
['PushSubscription/set', { update: { [subscriptionId]: { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) } } }, '0'],
// The update's answer doesn't say what the server settled on.
['PushSubscription/get', { ids: [subscriptionId], properties }, '1'],
],
using,
);
expires = ownIn(res, '1')?.expires ?? expires;
}
if (expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires);
let jmapAccountId: string | null = null;
try {
jmapAccountId = client.accountId;
} catch {
jmapAccountId = null;
}
await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl);
}
/**
* Register an account's device client id with the relay again, with the
* device token or endpoint this device has now. Never asks for anything: no
* permission prompt, no new UnifiedPush registration.
*/
async function registerDeviceWithRelay(relayBaseUrl: string, deviceClientId: string, accountLabel?: string): Promise<void> {
const transport = await getEffectivePushTransport();
if (transport === 'apns') {
const token = (await Notifications.getDevicePushTokenAsync()).data;
if (typeof token !== 'string' || !token) return;
await registerWithRelayApns({ relayBaseUrl, subscriptionId: deviceClientId, deviceToken: token, accountLabel });
} else if (transport === 'unifiedpush') {
const endpoint = await getUnifiedPushEndpoint();
if (!endpoint) return;
await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, endpoint, accountLabel });
} else {
const token = await getFcmToken();
if (!token) return;
await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, fcmToken: token, accountLabel });
}
}
export interface PushAccountState {
// A subscription is recorded for the account on this device.
enabled: boolean;
// The user turned push off for it here (see disablePushForAccount).
optedOut: boolean;
}
/** What this device knows about each account's push, for the settings. */
export async function readPushAccountStates(accountIds: string[]): Promise<Record<string, PushAccountState>> {
await migrateLegacyPushKeys();
const out: Record<string, PushAccountState> = {};
for (const accountId of accountIds) {
out[accountId] = {
enabled: (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null,
optedOut: (await AsyncStorage.getItem(optedOutKey(accountId))) !== null,
};
}
return out;
}
/**
* Tear down push for ALL accounts on this device. Used by the logout-all
* flow; best-effort because we typically aren't authenticated to every