diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 1263433fb..11a50836e 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -115,6 +115,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { IS_LITE } from "@/lib/lite";
import { findMeetingLink } from "@/lib/event-links";
import { useDocumentTitle } from "@/hooks/use-document-title";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
@@ -161,6 +162,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const tSidebar = useTranslations("sidebar");
const tOffline = useTranslations("calendar_offline");
useDocumentTitle(tSidebar("calendar"));
+ useDesktopWindowName(tSidebar("calendar"), { withAccount: true });
const skeletonLabels = useSkeletonLabels();
const tWebcalAction = useTranslations("calendar.webcal_action");
const { demoMode } = useConfig();
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index 00e76d684..f59092a32 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -60,6 +60,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { useRegisterCommands } from "@/lib/command-registry";
@@ -110,6 +111,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const tShell = useTranslations("shell");
const tMerge = useTranslations("ux_pim_cimport");
useDocumentTitle(tSidebar("contacts"));
+ useDesktopWindowName(tSidebar("contacts"), { withAccount: true });
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx
index 2731b6a23..d6d312db5 100644
--- a/components/email/__tests__/composer-own-window.test.tsx
+++ b/components/email/__tests__/composer-own-window.test.tsx
@@ -112,6 +112,15 @@ function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; c
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
+describe('"Move to the main window" in quick compose', () => {
+ it('shows an arrow going in, not the arrows of leaving full screen', () => {
+ mount({ popOut: vi.fn(() => target()), popOutToMainLabel: 'Move to the main window' });
+ const svg = screen.getByTestId('composer-back-to-main').querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('tabler-icon-login');
+ expect(svg?.getAttribute('class')).not.toContain('minimize');
+ });
+});
+
describe('"Open in new window" on a composer', () => {
it('is not there without the host offering it: browsers stay as they are', () => {
mount({});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fc292094a..bbcff311d 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -38,6 +38,7 @@ import {
Minimize2,
Trash2,
ExternalLink,
+ LogIn,
ReplyAll,
Reply,
Forward,
@@ -4682,7 +4683,8 @@ export function EmailComposer({
title={popOutToMainLabel ?? tWindows('open_in_window')}
data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"}
>
- {popOutToMainLabel ? : }
+ {/* Into the main window: an arrow going in, not the inward arrows of leaving full screen. */}
+ {popOutToMainLabel ? : }
);
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx
index 8b8786e1a..344d33605 100644
--- a/components/files/files-app.tsx
+++ b/components/files/files-app.tsx
@@ -51,6 +51,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton";
@@ -69,6 +70,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
useDocumentTitle(tSidebar("files"));
+ useDesktopWindowName(tSidebar("files"), { withAccount: true });
const tDeepLink = useTranslations("deep_link");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx
index 1c39ff3b5..33e8f517e 100644
--- a/components/layout/window-menu-bar.tsx
+++ b/components/layout/window-menu-bar.tsx
@@ -372,7 +372,7 @@ export function WindowMenuBar() {
aria-haspopup={isSub ? "menu" : undefined}
aria-expanded={isSub ? opened : undefined}
aria-disabled={item.enabled ? undefined : true}
- aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
+ aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform, 'en') : undefined}
data-menu-line={index}
tabIndex={-1}
onMouseEnter={(event) => {
diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx
index c7cb0c74f..8645e3d3e 100644
--- a/components/mail/__tests__/compose-plugin-hooks.test.tsx
+++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx
@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -211,6 +212,40 @@ 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('with two accounts it opens from the default one, in its own window, with no question', async () => {
+ const client = useAuthStore.getState().client;
+ useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never });
+ useAccountStore.setState({
+ accounts: [
+ { id: 'login', email: 'me@example.com', isConnected: true, isDefault: false },
+ { id: 'second', email: 'work@example.com', isConnected: true, isDefault: true },
+ ],
+ defaultAccountId: 'second',
+ } as never);
+ try {
+ render();
+ await fromOs();
+ await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
+ expect(desktopWindows.open).toHaveBeenCalledWith('second');
+ expect(screen.queryByText('select_account_title')).not.toBeInTheDocument();
+ // The main window keeps the account it shows.
+ expect(useAuthStore.getState().activeAccountId).toBe('login');
+ } finally {
+ useAccountStore.setState({ accounts: [], defaultAccountId: null } as never);
+ }
+ });
+
it('a link inside the app still opens here', async () => {
render();
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
diff --git a/components/mail/__tests__/mail-app-menu-selection.test.tsx b/components/mail/__tests__/mail-app-menu-selection.test.tsx
new file mode 100644
index 000000000..9234ef35e
--- /dev/null
+++ b/components/mail/__tests__/mail-app-menu-selection.test.tsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import { act, cleanup, render, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
+import { useMoveDialogStore } from '@/components/email/move-to-dialog';
+
+// The desktop app's Message menu with several messages selected: each line acts on all
+// of them, or isn't offered. Reply to one of seven, or Move for the open one only, acts on
+// mail nobody picked (usability pass 2, desktop-p2-3).
+
+const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined }));
+
+
+vi.mock('@/components/email/email-composer', async () => {
+ const { useEffect, useRef, useState } = await import('react');
+ const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
+ return {
+ EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: {
+ initialData?: { subject?: string; mode?: string } | null;
+ onSaveState: (data: { subject: string; mode?: string }) => void;
+ onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise;
+ sourceEmailId?: string;
+ }) => {
+ seen.sourceEmailId = sourceEmailId;
+ useEffect(() => registerComposeFlush(async () => {}), []);
+ const [subject, setSubject] = useState(initialData?.subject ?? '');
+ const initial = useRef(subject);
+ const latest = useRef(subject);
+ latest.current = subject;
+ useEffect(() => () => {
+ if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return (
+ <>
+ setSubject(e.target.value)} />
+
+ >
+ );
+ },
+ };
+});
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
+vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => message viewer
}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+// A compact desktop: rail and folders beside the list, the composer in the pane.
+const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => device.current,
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+ atLeast: (px: number) => `(width >= ${px}px)`,
+ PHONE_BELOW: 600,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 3, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
+const later = { id: 'later', name: 'Read later', role: null, totalEmails: 0, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
+const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${id}`, receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } }) as unknown as Email;
+const [a, b, c] = ['a', 'b', 'c'].map(message);
+
+let reported: Record = {};
+let named: string[] = [];
+let batchMoveToMailbox: ReturnType;
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
+ reported = {};
+ named = [];
+ (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
+ version: 1,
+ platform: 'win32',
+ menu: { setState: (state: Record) => { reported = state; }, onCommand: () => () => {} },
+ windows: { setMenuName: (name: string) => { named.push(name); } },
+ };
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never);
+ batchMoveToMailbox = vi.fn().mockResolvedValue(true);
+ useEmailStore.setState({
+ emails: [a, b, c], selectedEmail: a, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, later],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
+ selectedEmailKeys: new Set(['a', 'b', 'c']), unreadSection: null, threadEmailsCache: new Map(), error: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ fetchEmailContent: vi.fn().mockResolvedValue(undefined),
+ refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
+ batchMoveToMailbox,
+ } as never);
+});
+afterEach(() => {
+ cleanup();
+ resetDesktopCommands();
+ useMoveDialogStore.setState({ request: null });
+ delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('the Message menu with three messages selected', () => {
+ it('offers no Reply, Reply all, Forward or Star, which would act on one of them', async () => {
+ render();
+ await waitFor(() => expect(reported['message.archive']).toBe(true));
+ expect(reported['message.reply']).toBe(false);
+ expect(reported['message.replyAll']).toBe(false);
+ expect(reported['message.forward']).toBe(false);
+ expect(reported['message.star']).toBe(false);
+ expect(reported['message.openWindow']).toBe(false);
+ expect(reported['message.move']).toBe(true);
+ });
+
+ it('Move to… moves all three, and the toast says how many', async () => {
+ render();
+ await waitFor(() => expect(reported['message.move']).toBe(true));
+ act(() => { runDesktopCommand('message.move'); });
+ const request = useMoveDialogStore.getState().request;
+ expect(request?.emails.map((email) => email.id)).toEqual(['a', 'b', 'c']);
+ await act(async () => { request!.onMove('later'); });
+ await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith(expect.anything(), 'later', undefined, expect.any(Array)));
+ await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('moved_count_to'));
+ });
+
+ it('with one message open and none selected, Reply and Move are for that one', async () => {
+ useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
+ render();
+ await waitFor(() => expect(reported['message.reply']).toBe(true));
+ expect(reported['message.move']).toBe(true);
+ act(() => { runDesktopCommand('message.move'); });
+ expect(useMoveDialogStore.getState().request).toBeNull();
+ expect(batchMoveToMailbox).not.toHaveBeenCalled();
+ });
+});
+
+describe('the Window menu name of the main window', () => {
+ it('is the folder, not the message open in it, so it never reads like the window of that message', async () => {
+ useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
+ render();
+ await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox'));
+ expect(named).not.toContain('Subject a');
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index bdc888cfc..ca0f59b53 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -145,7 +145,8 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
-import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
+import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { SearchResultCount } from "@/components/email/search-result-count";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
@@ -155,7 +156,7 @@ import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
-import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
+import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
@@ -1366,7 +1367,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
// from what's on screen (lib/desktop/commands.ts).
const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
- const desktopSingle = !showComposer && !!selectedEmail;
+ // With several selected, a menu line acts on all of them or isn't offered: Reply to one
+ // of seven, or Move for the open one only, would act on mail nobody picked.
+ const desktopMulti = selectedEmailKeys.size > 1;
+ const desktopSingle = !showComposer && !!selectedEmail && !desktopMulti;
+ const tListExtra = useTranslations('mail_list_extra');
+ /** Message > Move to… on the selection, as the selection bar's Move does: one toast with the count. */
+ const moveSelectionTo = async (mailboxId: string) => {
+ if (!client) return;
+ const count = selectedEmailKeys.size;
+ const restore = restoreBatchesForSelection(client);
+ const receipts: CrossAccountMoveReceipt[] = [];
+ const destination = mailboxes.find((m) => m.id === mailboxId);
+ await runBatchEmailAction(() => useEmailStore.getState().batchMoveToMailbox(client, mailboxId, undefined, receipts), {
+ success: destination
+ ? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) })
+ : tNotifications('emails_moved', { count }),
+ error: tNotifications('move_failed'),
+ undo: undoMoveAction(client, restore, undo, receipts),
+ });
+ };
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
const layoutFixed = interfaceLayout === 'mountain-view';
// A shared folder you may only read from: nothing can leave it.
@@ -1384,7 +1404,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
'message.star': () => void keyboardHandlers.onToggleStar?.(),
- 'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
+ 'message.move': () => {
+ if (shortcutsTargetSelection) {
+ const state = useEmailStore.getState();
+ const picked = resolveSelectedEmails({ emails: state.emails, threadEmailsCache: state.threadEmailsCache, selectedEmailKeys });
+ if (picked.length > 0) useMoveDialogStore.getState().open({ emails: picked, onMove: (mailboxId) => void moveSelectionTo(mailboxId) });
+ } else if (selectedEmail) {
+ setMoveDialogEmail(selectedEmail);
+ }
+ },
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
'message.delete': () => void keyboardHandlers.onDelete?.(),
@@ -1408,7 +1436,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
'message.star': desktopSingle,
- 'message.move': desktopSingle && desktopMayRemove,
+ 'message.move': desktopTarget && desktopMayRemove,
'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove,
'message.spam': desktopTarget && desktopMayRemove,
'message.delete': desktopTarget && desktopMayRemove,
@@ -1497,6 +1525,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
useDocumentTitle(titleContext);
+ // The Window menu names this window by its folder, never by the open message (desktop-p2-5).
+ const windowView = useMemo(() => {
+ const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
+ return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail');
+ }, [selectedMailbox, mailboxes, t]);
+ useDesktopWindowName(windowView, { withAccount: true });
// When this page is rendered inside the Pro shell as the Mail tab body,
// we hoist every "show composer" intent into its own Pro tab and reset
@@ -1682,8 +1716,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// 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;
+ const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise => {
+ if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false;
let prefill: ComposePrefill | null = null;
if (emailHooks.onBeforeCompose.size > 0) {
prefill = await runBeforeCompose(composeOptionsFor('compose', {
@@ -1695,12 +1729,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A plugin said no: nothing opens, here or there.
if (!prefill) return true;
}
- const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
+ // The account the link goes out from, in its own window: the main window keeps the one it shows.
+ const opened = openComposerWindow(accountId ?? 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,
+ // Send to and Open with: the files go along (desktop-p2-6).
+ ...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}),
});
return true;
}, [identities, t]);
@@ -1732,6 +1769,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [openMailtoInWindow, openMailtoHere]);
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
+ if (await openMailtoInWindow(pending, accountId)) {
+ setPendingMailtoAccountChoice(null);
+ return;
+ }
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
@@ -1742,7 +1783,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} finally {
setIsProtocolAccountSwitching(false);
}
- }, [openMailtoDraft, switchAccount]);
+ }, [openMailtoDraft, openMailtoInWindow, switchAccount]);
// onMailtoIntercept: a plugin may stop a mailto: from opening the composer.
const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise => {
@@ -1755,17 +1796,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
- // A draft that comes from outside: which account it goes out from.
+ // A draft that comes from outside goes out from the default account, as in Outlook and
+ // Thunderbird, with no question: the composer's From changes it (desktop-p2-8).
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;
- }
-
- const accountId = protocolAccounts[0]?.id ?? activeAccountId;
+ const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId);
if (accountId) {
await openMailtoForAccount(pending, accountId);
return;
@@ -1823,6 +1857,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 5f8f5433b..3670f4c83 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useDocumentTitle } from "@/hooks/use-document-title";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -313,6 +314,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`);
const mode = payload.initialData?.mode ?? payload.mode;
+ // The Window menu names a composer by its subject, or "New message" before there is one.
+ const [subject, setSubject] = useState(payload.initialData?.subject ?? "");
+ useDesktopWindowName(subject.trim() || t("email_composer.new_message"));
useDocumentTitle(
mode === "reply" ? t("email_composer.reply")
: mode === "replyAll" ? t("email_composer.reply_all")
@@ -512,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}
@@ -523,6 +528,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
ownWindow={ownWindow}
popOut={mainHoldsLogin ? backToMain : undefined}
popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined}
+ onSubjectChange={setSubject}
className="min-h-0 flex-1"
/>
diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx
index 0c68ce71a..80d81fb16 100644
--- a/components/popout/popout-message-app.tsx
+++ b/components/popout/popout-message-app.tsx
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import { useDocumentTitle } from "@/hooks/use-document-title";
+import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
const [subject, setSubject] = useState(null);
const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled"));
useDocumentTitle(subjectInTitle ? subject : null);
+ // Kept out where the organisation keeps subjects out of titles: the shell then says "Message".
+ useDesktopWindowName(subjectInTitle ? subject : null);
// Folders first: without them Delete has no Trash to move to, Archive has no
// button yet, and the toolbar moves under the pointer once they arrive.
diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx
index 9bc672a4a..bb594ce9e 100644
--- a/components/protocol/protocol-account-picker.tsx
+++ b/components/protocol/protocol-account-picker.tsx
@@ -167,7 +167,7 @@ export function ProtocolAccountPicker({
{t("switching_account")}
) : (
- {t("select_account_note")}
+
)}