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;