diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx
index 61f8a3b8d..cb126f335 100644
--- a/components/desktop/__tests__/desktop-host.test.tsx
+++ b/components/desktop/__tests__/desktop-host.test.tsx
@@ -220,6 +220,42 @@ describe('DesktopHost', () => {
});
});
+ // mail-25: "Search mail for '…'" on selected text in the native menu.
+ describe('Search mail for selected text', () => {
+ const searchFor = (text: string) => `mail.searchText:${JSON.stringify(text)}`;
+
+ it('runs on the mail page', async () => {
+ const shell = fakeBridge();
+ render();
+ const heard: unknown[] = [];
+ const listen = (e: Event) => { e.preventDefault(); heard.push((e as CustomEvent).detail); };
+ window.addEventListener('bulwark:mail-search', listen);
+ try {
+ shell.run(searchFor(' quarterly report \n'));
+ } finally {
+ window.removeEventListener('bulwark:mail-search', listen);
+ }
+ expect(heard).toEqual([{ text: 'quarterly report' }]);
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it('goes to Mail first from another app', async () => {
+ const shell = fakeBridge();
+ render();
+ shell.run(searchFor('report'));
+ expect(push).toHaveBeenCalledWith('/');
+ });
+
+ it('is on offer to the shell when signed in, with its label in the interface language', async () => {
+ const shell = fakeBridge();
+ render();
+ await waitFor(() => expect(shell.setState).toHaveBeenCalled());
+ expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'mail.searchText': true });
+ await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
+ expect(shell.setLanguages.mock.lastCall![0].labels.contextSearchMail).toBe('desktop_mail.context_search_mail');
+ });
+ });
+
it('hands the shell its menus in the interface language', async () => {
const shell = fakeBridge();
act(() => useLocaleStore.setState({ locale: 'fr' }));
diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx
index 909ca657b..511cbd85b 100644
--- a/components/desktop/desktop-host.tsx
+++ b/components/desktop/desktop-host.tsx
@@ -19,6 +19,7 @@ import { useOfflineStore } from "@/lib/offline/state";
import { openNewComposerWindow } from "@/lib/popout/open";
import { onReturnedComposerElsewhere } from "@/lib/popout/return-to-main";
import { isPopoutWindow } from "@/lib/popout/window-role";
+import { parkMailSearch, requestMailSearch } from "@/lib/mail-search-request";
import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -89,6 +90,22 @@ export function DesktopHost() {
}
return;
}
+ // Search mail for the text selected under a right-click.
+ if (id.startsWith("mail.searchText:")) {
+ try {
+ const text = JSON.parse(id.slice("mail.searchText:".length)) as unknown;
+ if (typeof text === "string" && text.trim()) {
+ const request = { text: text.trim() };
+ if (!requestMailSearch(request)) {
+ parkMailSearch(request);
+ routerRef.current.push("/");
+ }
+ }
+ } catch {
+ // Only the shell sends this, and it sends JSON.
+ }
+ return;
+ }
if (!runDesktopCommand(id)) fallback[id]?.();
});
}, []);
@@ -101,6 +118,7 @@ export function DesktopHost() {
const popout = isPopoutWindow();
setDesktopBaseState({
"link.check": true,
+ "mail.searchText": signedIn && !popout,
"shortcuts.singleKeys": singleKeys,
"policy.download": canDownloadAnywhere(),
"file.print": canPrint,
diff --git a/components/email/__tests__/address-context-menu.test.tsx b/components/email/__tests__/address-context-menu.test.tsx
new file mode 100644
index 000000000..4801007c4
--- /dev/null
+++ b/components/email/__tests__/address-context-menu.test.tsx
@@ -0,0 +1,105 @@
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useContactStore } from '@/stores/contact-store';
+import { useToastStore } from '@/stores/toast-store';
+import { useMailSearchRequests, type MailSearchRequest } from '@/lib/mail-search-request';
+
+// mail-25: a right-click on the sender in a message header did nothing (the
+// desktop app showed no menu at all).
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) => (values ? `${key} ${JSON.stringify(values)}` : key),
+}));
+vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => (email: string) => email }));
+const requestInternalMailto = vi.hoisted(() => vi.fn((_href: string) => true));
+vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto }));
+const added = vi.hoisted(() => vi.fn(async (_email: string, _name?: string) => true));
+vi.mock('@/lib/contacts/add-from-address', () => ({ addAddressToContacts: added }));
+
+const { RecipientPopover } = await import('../recipient-popover');
+
+const original = useContactStore.getState();
+let writeText: ReturnType;
+
+function MailPage({ onSearch }: { onSearch: (request: MailSearchRequest) => void }) {
+ useMailSearchRequests(onSearch, true);
+ return null;
+}
+
+function rightClickSender() {
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'Sam Lee' }), { clientX: 40, clientY: 30 });
+}
+
+beforeEach(() => {
+ writeText = vi.fn(async () => {});
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+ useContactStore.setState({ contacts: [] });
+ useToastStore.getState().clearToasts();
+ requestInternalMailto.mockClear();
+ added.mockClear();
+});
+
+afterEach(() => {
+ cleanup();
+ useContactStore.setState(original);
+});
+
+describe('right-click on an address in a message header', () => {
+ it('offers Copy address, New message, Add to contacts and Find emails from', () => {
+ render(<> {}} />>);
+ rightClickSender();
+ const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
+ expect(items).toEqual([
+ 'desktopUi.context.copy_address',
+ 'email_composer.new_message',
+ 'email_viewer.contact_sidebar.add_to_contacts',
+ 'context_menu.find_emails_from {"name":"Sam Lee"}',
+ ]);
+ });
+
+ it('copies the address', async () => {
+ render();
+ rightClickSender();
+ fireEvent.click(screen.getByTestId('address-menu-copy'));
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith('sam@example.org'));
+ });
+
+ it('writes to it in the app\'s composer', () => {
+ render();
+ rightClickSender();
+ fireEvent.click(screen.getByTestId('address-menu-write'));
+ expect(requestInternalMailto).toHaveBeenCalledWith('mailto:sam@example.org');
+ });
+
+ it('adds it to the contacts, with the name from the header', async () => {
+ render();
+ rightClickSender();
+ fireEvent.click(screen.getByTestId('address-menu-contact'));
+ await waitFor(() => expect(added).toHaveBeenCalledWith('sam@example.org', 'Sam Lee'));
+ });
+
+ it('a saved contact opens instead of being added again', () => {
+ useContactStore.setState({ contacts: [{ id: 'c1', emails: { e: { address: 'SAM@example.org' } } }] as never });
+ const onViewContact = vi.fn();
+ render();
+ rightClickSender();
+ expect(screen.getByTestId('address-menu-contact').textContent).toBe('email_viewer.contact_sidebar.view_contact');
+ fireEvent.click(screen.getByTestId('address-menu-contact'));
+ expect(onViewContact).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' }), 'sam@example.org');
+ expect(added).not.toHaveBeenCalled();
+ });
+
+ it('finds the mail from this sender on the mail page', () => {
+ const onSearch = vi.fn();
+ render(<>>);
+ rightClickSender();
+ act(() => fireEvent.click(screen.getByTestId('address-menu-search')));
+ expect(onSearch).toHaveBeenCalledWith({ from: 'sam@example.org' });
+ });
+
+ it('leaves out the search where there is no mail page (a message window)', () => {
+ render();
+ rightClickSender();
+ expect(screen.queryByTestId('address-menu-search')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/conversation-frame-keys.test.tsx b/components/email/__tests__/conversation-frame-keys.test.tsx
new file mode 100644
index 000000000..a39dd5012
--- /dev/null
+++ b/components/email/__tests__/conversation-frame-keys.test.tsx
@@ -0,0 +1,64 @@
+// mail-3: a key pressed after a click into an HTML message of a conversation
+// stayed in the message's frame, so the mail shortcuts did nothing.
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render } from '@testing-library/react';
+
+const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k });
+vi.mock('next-intl', () => ({
+ useTranslations: () => t,
+ useLocale: () => 'en',
+}));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
+vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
+
+function renderHtmlMessage() {
+ const email = {
+ id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
+ from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
+ subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false,
+ htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [],
+ bodyValues: { '1': { value: 'Newsletter
' } },
+ };
+ const { container } = render(
+ {}}
+ />,
+ );
+ const iframe = container.querySelector('iframe')!;
+ const srcdoc = iframe.getAttribute('srcdoc') ?? '';
+ const doc = iframe.contentDocument!;
+ doc.body.innerHTML = /]*>([\s\S]*)<\/body>/.exec(srcdoc)![1];
+ fireEvent.load(iframe);
+ return doc;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('keys in a conversation message', () => {
+ it('reach the page, where the shortcut handlers listen', () => {
+ const doc = renderHtmlMessage();
+ const heard: string[] = [];
+ const listen = (e: KeyboardEvent) => heard.push(e.key);
+ window.addEventListener('keydown', listen);
+ try {
+ const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent;
+ doc.getElementById('p')!.dispatchEvent(new FrameKeyboardEvent('keydown', { key: 'j', code: 'KeyJ', bubbles: true, cancelable: true }));
+ } finally {
+ window.removeEventListener('keydown', listen);
+ }
+ expect(heard).toEqual(['j']);
+ });
+});
diff --git a/components/email/address-context-menu.tsx b/components/email/address-context-menu.tsx
new file mode 100644
index 000000000..fe4ee8fbb
--- /dev/null
+++ b/components/email/address-context-menu.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+// The right-click menu on an address in a message header: copy it, write to
+// it, keep it as a contact, find the mail it sent. In the desktop app the
+// menu shows natively (components/ui/context-menu).
+
+import type { MouseEvent, ReactNode } from "react";
+import { useTranslations } from "next-intl";
+import { ContextMenu, ContextMenuItem, ContextMenuSeparator, type ContextMenuPosition } from "@/components/ui/context-menu";
+import { Copy, ExternalLink, Search, Send, UserPlus } from "@/components/icons";
+import { useContextMenu } from "@/hooks/use-context-menu";
+import { addAddressToContacts } from "@/lib/contacts/add-from-address";
+import { mailSearchAvailable, requestMailSearch } from "@/lib/mail-search-request";
+import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
+import type { ContactCard } from "@/lib/jmap/types";
+import { useContactStore } from "@/stores/contact-store";
+import { toast } from "@/stores/toast-store";
+
+export interface MenuAddress {
+ name?: string;
+ email: string;
+}
+
+interface AddressContextMenuProps {
+ address: MenuAddress | null;
+ isOpen: boolean;
+ position: ContextMenuPosition;
+ onClose: () => void;
+ /** Shows a saved contact; without it a saved address offers nothing more. */
+ onViewContact?: (contact: ContactCard | null, email: string) => void;
+}
+
+function contactFor(contacts: ContactCard[], email: string): ContactCard | undefined {
+ const wanted = email.toLowerCase();
+ return contacts.find((c) => c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase() === wanted));
+}
+
+export function AddressContextMenu({ address, isOpen, position, onClose, onViewContact }: AddressContextMenuProps) {
+ const t = useTranslations();
+ const contacts = useContactStore((s) => s.contacts);
+ if (!address) return null;
+ const { email, name } = address;
+ const contact = contactFor(contacts, email);
+ const run = (action: () => void) => () => {
+ onClose();
+ action();
+ };
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(email);
+ toast.success(t("email_viewer.contact_sidebar.copied"));
+ } catch {
+ toast.error(t("email_viewer.contact_sidebar.copy_failed"));
+ }
+ };
+ const write = () => {
+ const href = `mailto:${email}`;
+ // No mail page here to take it (a message window): the system's handler.
+ if (!requestInternalMailto(href)) window.location.href = href;
+ };
+ const save = () => {
+ void addAddressToContacts(email, name).then((created) => {
+ if (created) toast.success(t("email_viewer.contact_added"));
+ });
+ };
+
+ return (
+
+ void copy())} testId="address-menu-copy" />
+
+ {contact ? (
+ onViewContact && (
+ onViewContact(contact, email))}
+ testId="address-menu-contact"
+ />
+ )
+ ) : (
+
+ )}
+ {mailSearchAvailable() && (
+ <>
+
+ requestMailSearch({ from: email }))}
+ testId="address-menu-search"
+ />
+ >
+ )}
+
+ );
+}
+
+/** A right-click menu for one address, for the element that shows it. */
+export function useAddressContextMenu(onViewContact?: (contact: ContactCard | null, email: string) => void): {
+ onContextMenu: (e: MouseEvent, address: MenuAddress) => void;
+ menu: ReactNode;
+} {
+ const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu();
+ return {
+ onContextMenu: (e, address) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openContextMenu(e, address);
+ },
+ menu: (
+
+ ),
+ };
+}
+
+/** An address as text, with the address menu on a right-click. */
+export function AddressText({ name, email, onViewContact }: MenuAddress & { onViewContact?: (contact: ContactCard | null, email: string) => void }) {
+ const { onContextMenu, menu } = useAddressContextMenu(onViewContact);
+ return (
+ <>
+ onContextMenu(e, { name, email })}>{email}
+ {menu}
+ >
+ );
+}
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 7b3d245d7..e426a956d 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -23,6 +23,8 @@ import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, re
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { measureEmailFrameHeight } from "@/lib/email-frame-height";
+import { forwardFrameKeys } from "@/lib/email-frame-keys";
+import { addAddressToContacts } from "@/lib/contacts/add-from-address";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
import { fitEmailBodyWidth, type FitOptions } from "@/lib/email-fit-width";
import { hasFullBleedCanvas } from "@/lib/email-full-bleed";
@@ -47,7 +49,7 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
-import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
+import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -158,6 +160,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
+import { AddressText } from "./address-context-menu";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -3775,6 +3778,8 @@ export function EmailViewer({
onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); },
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }),
});
+ // j, k, e and the other shortcuts keep working after a click into the body.
+ forwardFrameKeys(doc, iframe);
}
} catch {
@@ -5226,7 +5231,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Email address under name (on the name's line under Mountain View) */}
{!mvViewer && sender?.email && sender?.name && (
- {sender.email}
+
)}
@@ -6383,24 +6388,7 @@ ${printWatermark ? printWatermark.html : ''}
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
- const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
- const client = useAuthStore.getState().client;
- const contactData: Partial = {
- emails: { email: { address: addr } },
- // `full` feeds the mandatory vCard FN — strict CardDAV clients
- // (Apple Contacts) drop cards without it (#430).
- ...(name ? { name: { components: name.includes(' ')
- ? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
- : [{ kind: 'given' as const, value: name }],
- isOrdered: true, full: name,
- }} : {}),
- };
- if (client && supportsSync) {
- createContact(client, contactData).then((created) => { if (created) toast.success(t('contact_added')); });
- } else {
- void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
- .then((created) => { if (created) toast.success(t('contact_added')); });
- }
+ void addAddressToContacts(addr, name).then((created) => { if (created) toast.success(t('contact_added')); });
}}
/>
)}
diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx
index caaf8bc78..dad7efedb 100644
--- a/components/email/recipient-popover.tsx
+++ b/components/email/recipient-popover.tsx
@@ -11,6 +11,7 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import type { ContactCard } from "@/lib/jmap/types";
+import { useAddressContextMenu } from "./address-context-menu";
const CARD_WIDTH = 300;
@@ -260,6 +261,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
const [anchor, setAnchor] = useState(null);
const triggerRef = useRef(null);
const shownEmail = useOwnDomainAddress()(email);
+ const addressMenu = useAddressContextMenu(onViewContact);
const handleToggle = () => {
if (anchor) {
@@ -275,6 +277,10 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla