fix: let a link wait for a mailbox that is still reconnecting

A notification tapped with the app closed reopens it on a link that names
its mailbox (`?slot=`). The browser reconnects its logins one at a time,
so that mailbox is often not connected yet when the link is read: with ten
logins, one on the Gmail bridge came back at 3.3 s and the link was judged
at 1.8 s. It was declared unavailable - "sign in to that account to open
this link" - and the app stayed on whichever mailbox was active.

Wait for the login instead, and give up only when it is not one of this
browser's logins, its restore fails, or 20 s go by.
This commit is contained in:
lucamzanon
2026-09-30 19:01:14 +02:00
parent 7f5728fea2
commit 1c5dbbcd0b
3 changed files with 109 additions and 5 deletions
+5 -5
View File
@@ -26,7 +26,7 @@ import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-pi
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
@@ -1375,10 +1375,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: undefined);
const switchesAccount = !!linkAccountId && linkAccountId !== useAuthStore.getState().activeAccountId;
if (switchesAccount) {
const target = useAccountStore.getState().accounts.find(
(a) => a.id === linkAccountId && a.isConnected,
);
if (!target) {
// The login may still be reconnecting - a tapped notification reopens
// the app and the logins come back one at a time - so wait for it
// rather than calling a late mailbox unavailable.
if (!(await waitForConnectedAccount(linkAccountId))) {
toast.error(t('deep_link.account_unavailable'));
return;
}
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAccountStore, waitForConnectedAccount } from '../account-store';
/**
* A tapped notification reopens the app and names a mailbox; the logins come
* back one at a time, so that mailbox is often not connected yet when the
* link is read. The wait must tell "late" from "never".
*/
function seed(accounts: Array<{ id: string; isConnected: boolean; hasError?: boolean }>) {
useAccountStore.setState({
accounts: accounts.map((a) => ({
id: a.id,
cookieSlot: 0,
username: a.id,
serverUrl: 'https://mail.example.org',
displayName: a.id,
email: a.id,
avatarColor: '#123456',
rememberMe: true,
isConnected: a.isConnected,
hasError: a.hasError ?? false,
isDefault: false,
lastLoginAt: 0,
label: a.id,
authMode: 'basic',
})) as never,
});
}
const connect = (id: string, patch: Record<string, unknown>) =>
useAccountStore.setState((s) => ({
accounts: s.accounts.map((a) => (a.id === id ? { ...a, ...patch } : a)),
}));
describe('waitForConnectedAccount', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('answers at once for a login already connected', async () => {
seed([{ id: 'a', isConnected: true }]);
await expect(waitForConnectedAccount('a')).resolves.toBe(true);
});
it('waits for a login that is still reconnecting', async () => {
seed([{ id: 'a', isConnected: true }, { id: 'late', isConnected: false }]);
const waiting = waitForConnectedAccount('late');
connect('late', { isConnected: true });
await expect(waiting).resolves.toBe(true);
});
it('gives up on a login this browser does not have', async () => {
seed([{ id: 'a', isConnected: true }]);
await expect(waitForConnectedAccount('stranger')).resolves.toBe(false);
});
it('gives up when the login fails to reconnect', async () => {
seed([{ id: 'broken', isConnected: false }]);
const waiting = waitForConnectedAccount('broken');
connect('broken', { hasError: true });
await expect(waiting).resolves.toBe(false);
});
it('gives up after the timeout rather than waiting forever', async () => {
seed([{ id: 'stuck', isConnected: false }]);
const waiting = waitForConnectedAccount('stuck', 5_000);
vi.advanceTimersByTime(5_001);
await expect(waiting).resolves.toBe(false);
});
});
+35
View File
@@ -251,3 +251,38 @@ export const useAccountStore = create<AccountState>()(
}
)
);
/**
* Resolves once the login `accountId` is connected, or with `false` when it
* never will be: not one of this browser's logins, its restore failed, or
* `timeoutMs` went by.
*
* A link can arrive while the browser is still reconnecting its logins - a
* notification tapped with the app closed does exactly that - and they come
* back one at a time, so the mailbox a link names is often not connected yet
* when the link is read. Treating "not yet" as "unavailable" sent the user to
* the wrong mailbox with an error; this waits for the login instead.
*/
export function waitForConnectedAccount(accountId: string, timeoutMs = 20_000): Promise<boolean> {
const settled = (account: AccountEntry | undefined): boolean | null => {
if (!account) return false;
if (account.isConnected) return true;
if (account.hasError) return false;
return null;
};
const now = settled(useAccountStore.getState().accounts.find((a) => a.id === accountId));
if (now !== null) return Promise.resolve(now);
return new Promise((resolve) => {
const finish = (value: boolean) => {
clearTimeout(timer);
unsubscribe();
resolve(value);
};
const timer = setTimeout(() => finish(false), timeoutMs);
const unsubscribe = useAccountStore.subscribe((state) => {
const result = settled(state.accounts.find((a) => a.id === accountId));
if (result !== null) finish(result);
});
});
}