From 5fbc9b1f4727aba4bf79953040093449be35c8b9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:12:06 +0100 Subject: [PATCH] feat: open a mail link from another program in a compose window of its own in the desktop app --- .../__tests__/compose-plugin-hooks.test.tsx | 63 +++++++++++++++++++ components/mail/mail-app.tsx | 40 +++++++++++- lib/desktop/__tests__/os-commands.test.ts | 12 ++++ lib/desktop/os-commands.ts | 5 +- lib/protocol-handlers/mailto.ts | 5 ++ 5 files changed, 122 insertions(+), 3 deletions(-) diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx index 7ce31ed9f..c7cb0c74f 100644 --- a/components/mail/__tests__/compose-plugin-hooks.test.tsx +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -50,6 +50,20 @@ vi.mock('@/hooks/use-media-query', () => ({ })); vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); +// The desktop app's compose windows, for a mail link from another program. +const desktopWindows = vi.hoisted(() => ({ on: false, handOver: vi.fn(async () => true), open: vi.fn() })); +vi.mock('@/lib/popout/support', async (original) => ({ + ...(await original()), + hasDesktopWindows: () => desktopWindows.on, + popoutsAvailable: () => desktopWindows.on, +})); +vi.mock('@/lib/popout/open', async (original) => ({ + ...(await original()), + openComposerWindow: (account?: string | null) => { + desktopWindows.open(account); + return { handOver: desktopWindows.handOver, cancel: vi.fn() }; + }, +})); const { MailApp } = await import('../mail-app'); @@ -164,3 +178,52 @@ describe('onMailtoIntercept', () => { expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); }); + +describe('a mail link from another program in the desktop app', () => { + beforeEach(() => { + clearAllHooks(); + composerProps.last = null; + desktopWindows.on = true; + desktopWindows.open.mockClear(); + desktopWindows.handOver.mockClear(); + }); + afterEach(() => { + desktopWindows.on = false; + }); + + const fromOs = async () => { + const { savePendingMailto, notifyPendingMailto } = await import('@/lib/protocol-handlers/session'); + act(() => { + savePendingMailto({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Text', fromOs: true }); + notifyPendingMailto(); + }); + }; + + it('opens in a compose window of its own, not in the main window', async () => { + render(); + await fromOs(); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'compose', + initialData: expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' }), + })); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); + + it('a link inside the app still opens here', async () => { + render(); + act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); + await screen.findByText('stub composer', {}, { timeout: 5000 }); + expect(desktopWindows.open).not.toHaveBeenCalled(); + }); + + it('a plugin that says no keeps both closed', async () => { + emailHooks.onBeforeCompose.register('test', () => false); + render(); + await fromOs(); + await act(() => new Promise((r) => setTimeout(r, 100))); + expect(desktopWindows.open).not.toHaveBeenCalled(); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 25454c2a6..194ca306d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -65,7 +65,8 @@ import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context"; import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts"; import { useDesktopCommands } from "@/lib/desktop/commands"; import { useDesktopPrefs } from "@/lib/desktop/preferences"; -import { hasDesktopWindows } from "@/lib/popout/support"; +import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; import { composerDataFromDraft } from "@/lib/draft-composer-data"; import { useUndoShortcut } from "@/hooks/use-undo-shortcut"; import { confirmMailRemoval } from "@/lib/mail-removal-confirm"; @@ -1644,7 +1645,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isMobile) setActiveView("viewer"); }, [composerSessionId, isMobile, setActiveView]); - const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', { + // A mail link from another program, in the desktop app: a compose window of its own, + // as Outlook and Thunderbird do, and the main window stays where it was. Plugins get + // their say first. False when it has to open here instead. + const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => { + if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false; + let prefill: ComposePrefill | null = null; + if (emailHooks.onBeforeCompose.size > 0) { + prefill = await runBeforeCompose(composeOptionsFor('compose', { + to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body, + ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)), + replyPrefix: t('email_composer.prefix.reply'), + forwardPrefix: t('email_composer.prefix.forward'), + })); + // A plugin said no: nothing opens, here or there. + if (!prefill) return true; + } + const opened = openComposerWindow(useAuthStore.getState().activeAccountId); + if (!opened) return false; + void opened.handOver({ + mode: 'compose', + initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), + prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null, + }); + return true; + }, [identities, t]); + + const openMailtoHere = useCallback((pending: DraftRequest) => withComposeGate('compose', { to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body, }, () => { // A live composer owns text the user has not sent. Replacing it outright @@ -1663,6 +1690,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { applyMailtoDraft(pending, showComposer); }), [applyMailtoDraft, showComposer, withComposeGate]); + const openMailtoDraft = useCallback(async (pending: DraftRequest): Promise => { + if (await openMailtoInWindow(pending)) return true; + // Opening here after all: the shell left this window where it was. + if (pending.fromOs) getDesktopBridge()?.windows?.focus?.(); + return openMailtoHere(pending); + }, [openMailtoInWindow, openMailtoHere]); + const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => { setIsProtocolAccountSwitching(true); try { @@ -1691,6 +1725,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { + // The question is asked in this window: bring it up. + if (pending.fromOs) getDesktopBridge()?.windows?.focus?.(); setPendingMailtoAccountChoice(pending); return; } diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 35338c572..dd7040657 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -77,6 +77,18 @@ describe('launches', () => { expect(push).toHaveBeenCalledWith('/'); }); + it('a mailto from the OS is marked so, opens in its own window, and leaves the main window be', async () => { + await run({ type: 'mailto', url: 'mailto:bob@example.org' }); + expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ fromOs: true }); + expect(focus).not.toHaveBeenCalled(); + }); + + it('signed out, the main window comes up for the sign-in', async () => { + authenticated = false; + await run({ type: 'mailto', url: 'mailto:bob@example.org' }); + expect(focus).toHaveBeenCalled(); + }); + it('a mailto that is not one does nothing', async () => { await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) }); expect(push).not.toHaveBeenCalledWith('/calendar'); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index c52c1baad..317e447c9 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -164,7 +164,10 @@ export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): v case 'mailto': { const parsed = parseMailto(command.url); if (!parsed) return; - savePendingMailto(parsed); + // The shell leaves the main window where it is for a mail link. Signed out, the + // sign-in has to show. + if (!useAuthStore.getState().isAuthenticated) getDesktopBridge()?.windows?.focus?.(); + savePendingMailto({ ...parsed, fromOs: true }); notifyPendingMailto(); env.push('/'); return; diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index e58385708..fcc60a363 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -19,6 +19,11 @@ export interface ParsedMailto { * written from the account in use, without asking which one. */ inApp?: boolean; + /** + * Handed over by the OS in the desktop app: a link clicked in another program. It + * opens in a compose window of its own, and the main window stays where it was. + */ + fromOs?: boolean; } const MAX_RECIPIENTS = 200;