From 1c5dbbcd0bb2a563d79fceca8d4e5bc34505ab39 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 24 Sep 2026 07:09:08 +0200 Subject: [PATCH 1/2] 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. --- components/mail/mail-app.tsx | 10 +-- .../account-store-wait-connected.test.ts | 69 +++++++++++++++++++ stores/account-store.ts | 35 ++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 stores/__tests__/account-store-wait-connected.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..2a91dbcc4 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; } diff --git a/stores/__tests__/account-store-wait-connected.test.ts b/stores/__tests__/account-store-wait-connected.test.ts new file mode 100644 index 000000000..45380877f --- /dev/null +++ b/stores/__tests__/account-store-wait-connected.test.ts @@ -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) => + 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); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index 7c8082a0c..c903e1800 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -251,3 +251,38 @@ export const useAccountStore = create()( } ) ); + +/** + * 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 { + 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); + }); + }); +} From 1d82c594693ac092bac7e9242dfce6fce3552833 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 24 Sep 2026 07:14:48 +0200 Subject: [PATCH 2/2] fix: resolve a folder link against the mailbox it switched to Once a link had waited for its login and switched to it, the folder was looked up at once, in a store that still held the previous mailbox's folders or none at all: an inbox link opened on a mailbox that was just switched to reported "this folder is no longer available". Wait for the new mailbox's folder list to arrive before resolving the link against it. --- components/mail/mail-app.tsx | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2a91dbcc4..24cc30190 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -134,6 +134,30 @@ export interface MailAppProps { linkSegments?: string[]; } + +/** + * The folder list once it has been reloaded after an account switch. Until + * then the store holds the previous mailbox's folders, or none, and resolving + * a folder link against them reports a folder that is merely not loaded yet + * as gone - or, worse, finds the previous mailbox's folder of that name. + */ +function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise { + const ready = (mailboxes: Mailbox[]) => mailboxes !== previous && mailboxes.length > 0; + const now = useEmailStore.getState().mailboxes; + if (ready(now)) return Promise.resolve(now); + return new Promise((resolve) => { + const finish = (mailboxes: Mailbox[]) => { + clearTimeout(timer); + unsubscribe(); + resolve(mailboxes); + }; + const timer = setTimeout(() => finish(useEmailStore.getState().mailboxes), timeoutMs); + const unsubscribe = useEmailStore.subscribe((state) => { + if (ready(state.mailboxes)) finish(state.mailboxes); + }); + }); +} + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -1366,6 +1390,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // this, the URL is an output of the view (see buildMailUrl), not an input. const deepLinkHandledRef = useRef(false); const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { + // The folder list before an account switch, if one happened: until the + // new mailbox's folders arrive the store still holds these (or nothing). + let switchedFrom: Mailbox[] | null = null; // A permalink can name the account it belongs to. Ids are only meaningful // within their account, so switch first - but only to a login that is // actually connected; we can't authenticate on someone's behalf. A push @@ -1382,6 +1409,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { toast.error(t('deep_link.account_unavailable')); return; } + switchedFrom = useEmailStore.getState().mailboxes; await switchAccount(linkAccountId); } @@ -1389,8 +1417,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!activeClient) return; if (link.kind === 'folder') { + const mailboxes = switchedFrom + ? await freshMailboxes(switchedFrom) + : useEmailStore.getState().mailboxes; const state = useEmailStore.getState(); - const mailboxId = resolveFolderRef(link.ref, state.mailboxes); + const mailboxId = resolveFolderRef(link.ref, mailboxes); if (!mailboxId) { toast.error(t('deep_link.folder_not_found')); return;