diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx
index c7cb0c74f..8e0c18506 100644
--- a/components/mail/__tests__/compose-plugin-hooks.test.tsx
+++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx
@@ -211,6 +211,17 @@ describe('a mail link from another program in the desktop app', () => {
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
+ it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => {
+ const { handOffDraft } = await import('@/lib/launch-handoff');
+ render();
+ const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' });
+ act(() => { handOffDraft({ files: [file], fromOs: true }); });
+ await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
+ expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] }));
+ 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'); });
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index d51bce35e..8fd320a02 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1717,7 +1717,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// 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;
+ if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false;
let prefill: ComposePrefill | null = null;
if (emailHooks.onBeforeCompose.size > 0) {
prefill = await runBeforeCompose(composeOptionsFor('compose', {
@@ -1735,6 +1735,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mode: 'compose',
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
+ // Send to and Open with: the files go along (desktop-p2-6).
+ ...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}),
});
return true;
}, [identities, t]);
@@ -1857,6 +1859,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
body: handoff.body ?? '',
initialFiles: handoff.files,
unedited: true,
+ ...(handoff.fromOs ? { fromOs: true } : {}),
});
};
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index 798e7517b..3670f4c83 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -516,6 +516,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
replyTo={payload.replyTo}
initialDraftText={payload.initialDraftText}
initialData={payload.initialData}
+ initialFiles={payload.initialFiles ?? null}
prefill={payload.prefill}
sessionKey={sessionKey}
sourceEmailId={payload.sourceEmailId ?? undefined}
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
index 730477814..cd724ed85 100644
--- a/lib/desktop/__tests__/os-commands.test.ts
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -132,6 +132,12 @@ describe('launches', () => {
expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']);
});
+ it('files sent from another program are marked so, for a compose window of their own, as a mail link', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }] });
+ expect(takeHandoff('compose')).toMatchObject({ fromOs: true });
+ });
+
it('a file opened with the app asks: new message or Files, a new message first', async () => {
const { takeHandoff } = await import('@/lib/launch-handoff');
const { listenForFileChoices } = await import('@/lib/file-drop/choice');
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
index 9df939646..3a8628fba 100644
--- a/lib/desktop/os-commands.ts
+++ b/lib/desktop/os-commands.ts
@@ -133,10 +133,13 @@ function handleFiles(command: Extract, env:
const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
const failed = [...command.failed];
if (command.reason === 'attach' && command.ask) {
- // Opened with the app ("Open with"): the same choice as dropping it on the window.
+ // An older shell's "Open with": the same choice as dropping it on the window.
requestFileChoice({ files: made, preferred: 'new-message' });
} else if (command.reason === 'attach') {
- if (made.length > 0) handOffDraft({ files: made });
+ // Send to and Open with: a compose window with the files, as a mail link gets, and the
+ // main window stays where it is. Signed out, the sign-in has to show.
+ if (!useAuthStore.getState().isAuthenticated) showWindow();
+ if (made.length > 0) handOffDraft({ files: made, fromOs: true });
} else {
for (const file of made) {
const kind = kindForFile(file);
diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts
index 8f5be39ec..3e49de11d 100644
--- a/lib/launch-handoff.ts
+++ b/lib/launch-handoff.ts
@@ -42,6 +42,8 @@ export interface Handoff {
/** New draft only. */
subject?: string;
body?: string;
+ /** From another program through the desktop app (Send to, Open with): a compose window of its own. */
+ fromOs?: boolean;
createdAt: number;
}
@@ -307,13 +309,14 @@ export function handOffFiles(files: File[], kind: HandoffKind): void {
}
/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */
-export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
+export function handOffDraft(draft: { subject?: string; body?: string; files?: File[]; fromOs?: boolean }): void {
const subject = cleanMailtoSubject(draft.subject ?? '');
const body = cleanMailtoBody(draft.body ?? '');
enqueue({
id: newId(),
kind: 'compose',
files: draft.files ?? [],
+ ...(draft.fromOs ? { fromOs: true } : {}),
...(subject ? { subject } : {}),
...(body ? { body } : {}),
createdAt: Date.now(),
diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts
index ec0fdf85b..0a4b17335 100644
--- a/lib/popout/compose-handoff.ts
+++ b/lib/popout/compose-handoff.ts
@@ -109,7 +109,9 @@ export function createComposeHandoff(): ComposeHandoff | null {
/** Keeps the draft with this window, so a reload starts from it. */
export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
try {
- sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
+ // A File doesn't go into JSON: once attached, the draft holds it.
+ const { initialFiles: _files, ...kept } = payload;
+ sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(kept));
} catch {
// Full or unavailable: a reload then starts from the saved draft's first state.
}
diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts
index 714bfc1ea..25057c441 100644
--- a/lib/popout/compose-payload.ts
+++ b/lib/popout/compose-payload.ts
@@ -20,4 +20,9 @@ export interface ComposePopoutPayload {
sourceEmailSource?: EmailWriteSource | null;
/** The mailbox a new message was started from; picks the identity (see the composer's prop). */
composeFromAccountEmail?: string;
+ /**
+ * Files to attach as it opens: Send to and Open with in the desktop app. They cross to the
+ * window as they are (BroadcastChannel clones a File) and aren't kept for a reload.
+ */
+ initialFiles?: File[];
}