fix: open a notification or link for another account over that account's inbox

This commit is contained in:
Linus Rath
2026-10-31 13:24:56 +01:00
parent 688e8bef3b
commit f9a36b2892
3 changed files with 92 additions and 27 deletions
+18 -21
View File
@@ -82,10 +82,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import {
acceptSignInLink,
enterLinkAccount,
handleDeepLink,
parseDeepLink,
routeParkedSignInLink,
shareToDeepLink,
type AccountSwitchResult,
type DeepLink,
} from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
@@ -107,20 +109,26 @@ const navigationRef = createNavigationContainerRef<RootStackParamList>();
// on every getInitialURL(), and a sign-in link must not run twice.
let initialSignInLinkRead = false;
// Move to the account a notification, reminder or link names. An account
// that was signed out meanwhile has nothing safe to open.
async function switchToAccount(accountId: string): Promise<AccountSwitchResult> {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
if (!useAccountStore.getState().getAccountById(accountId)) return false;
await auth.switchAccount(accountId);
return useAuthStore.getState().activeAccountId === accountId ? 'switched' : false;
}
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
// account would fetch the email from the wrong server and fail. After a
// switch the stack starts over on that account's inbox, so Back from the
// message lands there instead of on the old account's screens.
if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
navigationRef.navigate('EmailThread', {
@@ -138,12 +146,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
// under the account it was scheduled for. The target is parked for
// CalendarScreen, which also picks it up when it mounts after a cold start.
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
const auth = useAuthStore.getState();
if (target.appAccountId && target.appAccountId !== auth.activeAccountId) {
if (!useAccountStore.getState().getAccountById(target.appAccountId)) return;
await auth.switchAccount(target.appAccountId);
if (useAuthStore.getState().activeAccountId !== target.appAccountId) return;
}
if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return;
setPendingCalendarOpen(target);
// On a cold start the navigator mounts right after the auth gate flips.
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
@@ -178,13 +181,7 @@ async function openDeepLink(link: DeepLink): Promise<void> {
return false;
}
},
switchAccount: async (accountId) => {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
if (!useAccountStore.getState().getAccountById(accountId)) return false;
await auth.switchAccount(accountId);
return useAuthStore.getState().activeAccountId === accountId;
},
switchAccount: switchToAccount,
});
// A widget or a link elsewhere brought the app up for this; say it failed
// rather than leave the user wondering why the app just opened.
+36 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
import { acceptSignInLink, enterLinkAccount, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
import { usePendingMailFolder } from '../pending-mail-folder';
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open';
@@ -168,6 +169,40 @@ describe('handleDeepLink', () => {
})).toBe(false);
});
it('starts the stack over on the inbox of the account a link switched to', async () => {
const calls: unknown[][] = [];
const navigation = {
isReady: () => true,
navigate: vi.fn((...args: unknown[]) => { calls.push(['navigate', ...args]); }),
reset: vi.fn((state: unknown) => { calls.push(['reset', state]); }),
};
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'b@example.org@mail' },
{ navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => 'switched' },
);
expect(ok).toBe(true);
// The old account's screens go, the message opens over the new inbox.
expect(calls).toEqual([
['reset', { index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] }],
['navigate', 'EmailThread', { emailId: 'M1', threadId: 'T1' }],
]);
expect(usePendingMailFolder.getState().consume()).toBe('inbox');
});
it('leaves the stack alone when the link names the active account', async () => {
const navigation = { isReady: () => true, navigate: vi.fn(), reset: vi.fn() };
await handleDeepLink(
{ kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'a@example.org@mail' },
{ navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => true },
);
expect(navigation.reset).not.toHaveBeenCalled();
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1' });
// A notification tap goes the same way (App navigateToNotificationTap).
expect(await enterLinkAccount(navigation as never, 'gone@example.org@mail', async () => false)).toBe(false);
expect(await enterLinkAccount(navigation as never, undefined, async () => 'switched')).toBe(true);
expect(navigation.reset).not.toHaveBeenCalled();
});
it('parks the settings tab and opens the Settings tab', async () => {
const navigation = nav();
await handleDeepLink(
+38 -5
View File
@@ -276,6 +276,40 @@ export function routeParkedSignInLink(accountCount: number): 'add-account' | 'ac
return 'add-account';
}
/**
* What switching to the account a link or notification names came to: false
* when that account is not signed in here (or the switch failed), true when
* it was the active one already, 'switched' when the app moved to it.
*/
export type AccountSwitchResult = boolean | 'switched';
/**
* After a link or notification switched accounts, the screens on the stack
* are the old account's. Start the stack over on the new account's inbox, so
* whatever the link opens goes on top of it and Back lands there.
*/
export function resetToAccountInbox(navigation: NavigationContainerRefWithCurrent<RootStackParamList>): void {
setPendingMailFolder('inbox');
navigation.reset({ index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] } as never);
}
/**
* Move to the account `accountId` names, if it is not the active one, and
* start the stack over when the app moved (resetToAccountInbox). False when
* the account is not signed in here: nothing may be opened then.
*/
export async function enterLinkAccount(
navigation: NavigationContainerRefWithCurrent<RootStackParamList>,
accountId: string | undefined,
switchAccount: ((accountId: string) => Promise<AccountSwitchResult>) | undefined,
): Promise<boolean> {
if (!accountId || !switchAccount) return true;
const result = await switchAccount(accountId);
if (!result) return false;
if (result === 'switched' && navigation.isReady()) resetToAccountInbox(navigation);
return true;
}
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
@@ -286,8 +320,9 @@ export interface DeepLinkNavigator {
// be loaded.
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device.
switchAccount?: (accountId: string) => Promise<boolean>;
// when that account is not signed in on this device, 'switched' when the
// app moved to it (see AccountSwitchResult).
switchAccount?: (accountId: string) => Promise<AccountSwitchResult>;
}
/** Navigate for a parsed link. Returns false when nothing could be opened. */
@@ -295,9 +330,7 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
const { navigation } = nav;
if (!navigation.isReady()) return false;
if ('accountId' in link && link.accountId && nav.switchAccount) {
if (!(await nav.switchAccount(link.accountId))) return false;
}
if ('accountId' in link && !(await enterLinkAccount(navigation, link.accountId, nav.switchAccount))) return false;
switch (link.kind) {
case 'message': {