From ec4fd9c529af27afa05854c5570e58dc4eec837c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:03:05 +0100
Subject: [PATCH 1/5] fix: keep mail shortcuts working after a click into an
HTML message
---
.../conversation-frame-keys.test.tsx | 64 +++++++++++
components/email/email-viewer.tsx | 3 +
components/email/thread-conversation-view.tsx | 3 +
lib/__tests__/email-frame-keys.test.ts | 108 ++++++++++++++++++
lib/email-frame-keys.ts | 90 +++++++++++++++
5 files changed, 268 insertions(+)
create mode 100644 components/email/__tests__/conversation-frame-keys.test.tsx
create mode 100644 lib/__tests__/email-frame-keys.test.ts
create mode 100644 lib/email-frame-keys.ts
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/email-viewer.tsx b/components/email/email-viewer.tsx
index 0b1b5fac7..dc1ed92b0 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -23,6 +23,7 @@ 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 { 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";
@@ -3733,6 +3734,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 {
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 0977c9136..328f64a97 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -20,6 +20,7 @@ import { ReceivedLabelChip } from "./received-label-chip";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { measureEmailFrameHeight } from "@/lib/email-frame-height";
+import { forwardFrameKeys } from "@/lib/email-frame-keys";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { mailLanguage } from "@/lib/mail-language";
import { htmlToReadableText } from "@/lib/html-to-readable-text";
@@ -926,6 +927,8 @@ export function ConversationEmailBody({
emailId: email.id,
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }),
});
+ // j, k, e and the other shortcuts keep working after a click into the body.
+ forwardFrameKeys(doc, iframe);
setHtmlBodyEl(doc.body);
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
diff --git a/lib/__tests__/email-frame-keys.test.ts b/lib/__tests__/email-frame-keys.test.ts
new file mode 100644
index 000000000..e8cf4da93
--- /dev/null
+++ b/lib/__tests__/email-frame-keys.test.ts
@@ -0,0 +1,108 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
+import { forwardFrameKeys } from '../email-frame-keys';
+
+// mail-3: after a click into an HTML message, j, k, e, # and the rest did
+// nothing, since the key presses went to the body's frame and never reached
+// the page's shortcut handlers.
+
+const email = { id: 'm1' } as never;
+
+function frameWith(html: string) {
+ const frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ const doc = frame.contentDocument!;
+ doc.body.innerHTML = html;
+ forwardFrameKeys(doc, frame);
+ return { frame, doc };
+}
+
+/** A key press as the frame's own window makes it. */
+function pressIn(doc: Document, target: Element, key: string, code: string, init: KeyboardEventInit = {}) {
+ const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent;
+ const event = new FrameKeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init });
+ target.dispatchEvent(event);
+ return event;
+}
+
+function mountShortcuts() {
+ const handlers = { onNextEmail: vi.fn(), onArchive: vi.fn(), onDelete: vi.fn(), onCloseEmail: vi.fn(), onSelectAll: vi.fn() };
+ renderHook(() => useKeyboardShortcuts({ emails: [email], selectedEmailId: 'm1', handlers }));
+ return handlers;
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('keys pressed in a message body', () => {
+ it('reach the app shortcuts: j, e and #', () => {
+ const handlers = mountShortcuts();
+ const { doc } = frameWith('Newsletter
');
+ const p = doc.querySelector('p')!;
+ const j = pressIn(doc, p, 'j', 'KeyJ');
+ pressIn(doc, p, 'e', 'KeyE');
+ pressIn(doc, p, '#', 'Digit3', { shiftKey: true });
+ expect(handlers.onNextEmail).toHaveBeenCalledTimes(1);
+ expect(handlers.onArchive).toHaveBeenCalledTimes(1);
+ expect(handlers.onDelete).toHaveBeenCalledTimes(1);
+ // The frame doesn't act on a key a shortcut took.
+ expect(j.defaultPrevented).toBe(true);
+ });
+
+ it('stay in a form inside the mail', () => {
+ const handlers = mountShortcuts();
+ const { doc } = frameWith('');
+ pressIn(doc, doc.querySelector('input')!, 'j', 'KeyJ');
+ pressIn(doc, doc.querySelector('textarea')!, 'e', 'KeyE');
+ pressIn(doc, doc.querySelector('b')!, 'Backspace', 'Backspace');
+ expect(handlers.onNextEmail).not.toHaveBeenCalled();
+ expect(handlers.onArchive).not.toHaveBeenCalled();
+ expect(handlers.onDelete).not.toHaveBeenCalled();
+ });
+
+ it('leave Ctrl+A, Ctrl+C and the scrolling keys to the body', () => {
+ const handlers = mountShortcuts();
+ const { doc } = frameWith('Text
');
+ const p = doc.querySelector('p')!;
+ const selectAll = pressIn(doc, p, 'a', 'KeyA', { ctrlKey: true });
+ const copy = pressIn(doc, p, 'c', 'KeyC', { ctrlKey: true });
+ const space = pressIn(doc, p, ' ', 'Space');
+ const down = pressIn(doc, p, 'ArrowDown', 'ArrowDown');
+ // Ctrl+A in the list selects every message; in the body it selects the text.
+ expect(handlers.onSelectAll).not.toHaveBeenCalled();
+ expect(handlers.onNextEmail).not.toHaveBeenCalled();
+ for (const event of [selectAll, copy, space, down]) expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('Escape puts the focus back on the open message in the list', () => {
+ const handlers = mountShortcuts();
+ const list = document.createElement('div');
+ list.id = 'message-list';
+ list.innerHTML = 'A
B
';
+ document.body.appendChild(list);
+ const { doc } = frameWith('Text
');
+ pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape');
+ expect(document.activeElement?.textContent).toBe('B');
+ // Only the focus moves: the message stays open.
+ expect(handlers.onCloseEmail).not.toHaveBeenCalled();
+ });
+
+ it('Escape without a list (a message window) is the app\'s Escape', () => {
+ const handlers = mountShortcuts();
+ const { doc } = frameWith('Text
');
+ pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape');
+ expect(handlers.onCloseEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it('stop while a menu is open on the page', () => {
+ const handlers = mountShortcuts();
+ const menu = document.createElement('div');
+ menu.setAttribute('role', 'menu');
+ document.body.appendChild(menu);
+ const { doc } = frameWith('Text
');
+ pressIn(doc, doc.querySelector('p')!, 'e', 'KeyE');
+ expect(handlers.onArchive).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/email-frame-keys.ts b/lib/email-frame-keys.ts
new file mode 100644
index 000000000..7ae575e3a
--- /dev/null
+++ b/lib/email-frame-keys.ts
@@ -0,0 +1,90 @@
+/**
+ * An HTML message renders in a frame, and a key pressed after a click into it
+ * goes to the frame's own window: j, k, e, # and the rest stopped working
+ * until the user clicked back into the list. This hands those keys on to the
+ * page as a copy of the key press, sent from the frame element, so the app's
+ * shortcut handlers (hooks/use-keyboard-shortcuts, hooks/use-undo-shortcut)
+ * take it with their usual checks: an open menu or dialog, single-key
+ * shortcuts turned off. Escape puts the focus back on the message's row in
+ * the list, as Escape in a dialog gives the focus back.
+ *
+ * Left to the frame: anything typed into a form in the mail, keys with Ctrl,
+ * Alt or ⌘ (copy, select all), and the keys that scroll the body or move
+ * through its links (arrows, Space, Tab, Enter, Page Up and Down).
+ */
+
+import { focusTemporarily } from "@/lib/keyboard";
+
+/** Keys that aren't one character but are mail shortcuts. */
+const NAMED_KEYS = new Set(["Delete", "Backspace", "Escape"]);
+
+const EDITABLE =
+ 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
+
+/**
+ * Whether a key press in the frame came from a place that takes text. By tag
+ * and attribute: the frame's elements belong to another window, so
+ * `instanceof HTMLElement` (lib/keyboard) is false for all of them.
+ */
+function typedInFrame(event: KeyboardEvent, doc: Document): boolean {
+ if (doc.designMode === "on") return true;
+ const target = event.target as Node | null;
+ const el = target && target.nodeType === 1 ? (target as Element) : target?.parentElement ?? null;
+ if (!el) return false;
+ if (el.closest(EDITABLE)) return true;
+ return (el as HTMLElement).isContentEditable === true;
+}
+
+/** Whether this key press in the frame belongs to the app. */
+export function isFrameKeyForApp(event: KeyboardEvent, doc: Document): boolean {
+ if (event.defaultPrevented || event.isComposing) return false;
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
+ if (event.key === " " || (event.key.length !== 1 && !NAMED_KEYS.has(event.key))) return false;
+ return !typedInFrame(event, doc);
+}
+
+/**
+ * The open message's row in the message list, or the list itself when no row
+ * is open. Null where there is no list (a message window).
+ */
+function listFocusTarget(doc: Document): HTMLElement | null {
+ const list = doc.getElementById("message-list");
+ if (!list) return null;
+ return (
+ list.querySelector('[role="row"][aria-current="true"]') ??
+ list.querySelector('[role="row"][tabindex="0"]') ??
+ list
+ );
+}
+
+/**
+ * Hands the app's keys from a message frame's document to the page. Returns a
+ * function that stops it; a frame whose document is replaced stops by itself.
+ */
+export function forwardFrameKeys(doc: Document, frame: HTMLIFrameElement): () => void {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!isFrameKeyForApp(event, doc)) return;
+ if (event.key === "Escape") {
+ const target = listFocusTarget(frame.ownerDocument);
+ if (target) {
+ event.preventDefault();
+ focusTemporarily(target, { preventScroll: true });
+ return;
+ }
+ }
+ const copy = new KeyboardEvent("keydown", {
+ key: event.key,
+ code: event.code,
+ shiftKey: event.shiftKey,
+ repeat: event.repeat,
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ frame.dispatchEvent(copy);
+ // A shortcut ran: the frame doesn't act on the key as well.
+ if (copy.defaultPrevented) event.preventDefault();
+ };
+ doc.addEventListener("keydown", onKeyDown);
+ return () => doc.removeEventListener("keydown", onKeyDown);
+}
From cd3005e03665c5805efde18f274446672c58c62e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:03:07 +0100
Subject: [PATCH 2/5] fix: offer undo in the main window after archiving or
deleting in a message window
---
.../popout/__tests__/popout-host.test.tsx | 59 +++++++-
components/popout/popout-host.tsx | 9 ++
.../__tests__/pro-email-move-undo.test.tsx | 142 ++++++++++++++++++
components/pro/pro-email-tab-body.tsx | 24 ++-
lib/popout/__tests__/moved-undo.test.ts | 43 ++++++
lib/popout/channel.ts | 28 +++-
lib/popout/moved-undo.ts | 60 ++++++++
7 files changed, 355 insertions(+), 10 deletions(-)
create mode 100644 components/pro/__tests__/pro-email-move-undo.test.tsx
create mode 100644 lib/popout/__tests__/moved-undo.test.ts
create mode 100644 lib/popout/moved-undo.ts
diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx
index e8a0dd03e..eaf970c12 100644
--- a/components/popout/__tests__/popout-host.test.tsx
+++ b/components/popout/__tests__/popout-host.test.tsx
@@ -2,7 +2,8 @@ import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { setLockedHere } from '@/lib/app-lock/state';
-import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
+import { announceMovedToMainWindow, openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type PopoutMovedNotice, type RescuedDraft } from '@/lib/popout/channel';
+import { clearUndoHistory, undoHistory, undoLatest } from '@/lib/undo-history';
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
import { takeReturnedComposer } from '@/lib/popout/return-to-main';
import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed';
@@ -458,3 +459,59 @@ describe('a composer coming back', () => {
expect(takeReturnedComposer()).toBeNull();
});
});
+
+// mail-4: Archive or Delete in a message window closed the window and its
+// toast with it, and Ctrl+Z in the main window didn't reach the move.
+describe('a move a message window made', () => {
+ const NOTICE: PopoutMovedNotice = {
+ kind: 'archive',
+ batches: [{ login: 'alice', points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }],
+ };
+
+ it('gets its toast with Undo here, and Ctrl+Z puts the message back through this window\'s login', async () => {
+ const restoreEmails = vi.fn(async () => {});
+ Object.assign(client, { restoreEmails });
+ clearUndoHistory();
+ render();
+ await settle();
+ let taken = false;
+ await act(async () => {
+ taken = await announceMovedToMainWindow(NOTICE, 2000);
+ });
+ expect(taken).toBe(true);
+ const toasts = useToastStore.getState().toasts;
+ expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_archived']);
+ expect(toasts[0].action?.label).toBe('notifications.undo');
+ expect(undoHistory()).toHaveLength(1);
+ await act(async () => {
+ undoLatest();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ });
+ expect(restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: true }], undefined);
+ });
+
+ it('is taken by one main window of two', async () => {
+ clearUndoHistory();
+ render(<>>);
+ await settle();
+ await act(async () => {
+ await announceMovedToMainWindow(NOTICE, 2000);
+ });
+ await settle();
+ expect(useToastStore.getState().toasts).toHaveLength(1);
+ expect(undoHistory()).toHaveLength(1);
+ });
+
+ it('a delete for good still says so, without Undo', async () => {
+ clearUndoHistory();
+ render();
+ await settle();
+ await act(async () => {
+ await announceMovedToMainWindow({ kind: 'delete', batches: [] }, 2000);
+ });
+ const toasts = useToastStore.getState().toasts;
+ expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_deleted']);
+ expect(toasts[0].action).toBeUndefined();
+ expect(undoHistory()).toHaveLength(0);
+ });
+});
diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx
index 4239c73b6..b2b154772 100644
--- a/components/popout/popout-host.tsx
+++ b/components/popout/popout-host.tsx
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
+import { takeOverMoved } from "@/lib/popout/moved-undo";
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
import { commitReturnedComposer, dropReturnedComposer, offerReturnedComposer } from "@/lib/popout/return-to-main";
import { credentialFingerprint, sealFor } from "@/lib/popout/sealed";
@@ -164,6 +165,14 @@ export function PopoutHost() {
takeOverSent(message.notice);
});
break;
+ case "moved":
+ // Undo for what a message window archived, deleted or moved: it has closed.
+ claimOnce(`bulwark-popout-moved:${message.requestId}`, () => {
+ channel?.post({ type: "moved-ack", requestId: message.requestId });
+ takeOverMoved(message.notice, (key, values) => tRef.current(`notifications.${key}`, values));
+ refreshList();
+ });
+ break;
case "draft-rescue":
startRescue(message.draft);
break;
diff --git a/components/pro/__tests__/pro-email-move-undo.test.tsx b/components/pro/__tests__/pro-email-move-undo.test.tsx
new file mode 100644
index 000000000..5fe5f2499
--- /dev/null
+++ b/components/pro/__tests__/pro-email-move-undo.test.tsx
@@ -0,0 +1,142 @@
+import { render, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+
+/**
+ * mail-4: Archive or Delete in a message window closed the window, its toast
+ * went with it, and nothing could undo the move. The view now hands the
+ * restore points to offerMoveUndo (lib/popout/moved-undo), which gives them
+ * to the main window, before the window closes.
+ */
+
+const viewerProps = vi.hoisted(() => ({ current: null as Record | null }));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: (props: Record) => {
+ viewerProps.current = props;
+ return null;
+ },
+}));
+vi.mock('@/components/error', () => ({
+ ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
+ EmailViewerErrorFallback: () => null,
+}));
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
+vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
+vi.mock('@/lib/mail-removal-confirm', () => ({ confirmMailRemoval: async () => true }));
+
+const calls = vi.hoisted(() => ({ order: [] as string[], offer: [] as unknown[][] }));
+vi.mock('@/lib/popout/moved-undo', () => ({
+ offerMoveUndo: async (...args: unknown[]) => {
+ calls.order.push(`offer:${args[0]}`);
+ calls.offer.push(args);
+ },
+}));
+const role = vi.hoisted(() => ({ popout: true }));
+vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
+ ...await importOriginal(),
+ isPopoutWindow: () => role.popout,
+}));
+
+function selectorStore(state: T) {
+ const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
+ hook.getState = () => state;
+ return hook;
+}
+
+const auth = vi.hoisted(() => ({ state: { client: null as unknown, activeAccountId: 'alice', getClientForAccount: () => undefined } }));
+vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
+
+const MESSAGE = vi.hoisted(() => ({
+ id: 'm1',
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ from: [{ email: 'sam@example.org', name: 'Sam' }],
+ to: [{ email: 'me@example.org' }],
+ subject: 'Launch day!',
+ bodyValues: { '1': { value: 'Hi' } },
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [{ partId: '1', type: 'text/plain' }],
+ attachments: [],
+}));
+const permanent = vi.hoisted(() => ({ value: false }));
+vi.mock('@/stores/email-store', () => ({
+ resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }),
+ restoreBatchesFor: (emails: Array<{ id: string; mailboxIds: Record }>, client: unknown) =>
+ [{ client, points: emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds, seen: true })) }],
+ bulkDeleteIsPermanent: () => permanent.value,
+ useEmailStore: selectorStore({
+ fetchEmailContent: async () => MESSAGE,
+ deleteEmail: async () => { calls.order.push('deleted'); return true; },
+ moveToMailbox: async () => { calls.order.push('moved'); return true; },
+ markAsRead: async () => true,
+ toggleStar: async () => true,
+ setEmailKeywordsLocal: () => {},
+ mailboxes: [{ id: 'inbox', role: 'inbox', name: 'Inbox' }, { id: 'archive', role: 'archive', name: 'Archive' }],
+ emails: [],
+ selectedEmail: null,
+ selectedMailbox: 'inbox',
+ }),
+}));
+vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
+vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({}) }));
+vi.mock('@/stores/pro-tab-store', () => ({ useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }) }));
+
+import { ProEmailView } from '../pro-email-tab-body';
+
+async function open() {
+ const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'me@example.org', getAccountId: () => 'a' };
+ const onClose = vi.fn(() => calls.order.push('closed'));
+ render();
+ await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
+ return { client, onClose };
+}
+
+beforeEach(() => {
+ viewerProps.current = null;
+ calls.order = [];
+ calls.offer = [];
+ role.popout = true;
+ permanent.value = false;
+});
+
+describe('a move out of a message window', () => {
+ it('Archive hands its Undo on before the window closes', async () => {
+ const { client } = await open();
+ await (viewerProps.current!.onArchive as () => Promise)();
+ await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:archive', 'closed']));
+ const [, offeredClient, restore, , inOwnWindow] = calls.offer[0];
+ expect(offeredClient).toBe(client);
+ expect(restore).toEqual([{ client, points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }]);
+ expect(inOwnWindow).toBe(true);
+ });
+
+ it('Delete does too', async () => {
+ await open();
+ await (viewerProps.current!.onDelete as () => Promise)();
+ await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed']));
+ expect((calls.offer[0][2] as unknown[]).length).toBe(1);
+ });
+
+ it('a delete for good has nothing to undo', async () => {
+ permanent.value = true;
+ await open();
+ await (viewerProps.current!.onDelete as () => Promise)();
+ await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed']));
+ expect(calls.offer[0][2]).toBeNull();
+ });
+
+ it('Move to a folder does too', async () => {
+ await open();
+ await (viewerProps.current!.onMoveToMailbox as (id: string) => Promise)('archive');
+ await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:move', 'closed']));
+ });
+
+ it('in a tab of the main window the Undo stays in this window', async () => {
+ role.popout = false;
+ await open();
+ await (viewerProps.current!.onArchive as () => Promise)();
+ await waitFor(() => expect(calls.offer).toHaveLength(1));
+ expect(calls.offer[0][4]).toBe(false);
+ });
+});
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 569b13b0a..8f04a3cbf 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
-import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
+import { useEmailStore, resolveEmailBlobContext, bulkDeleteIsPermanent, restoreBatchesFor, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
@@ -39,6 +39,7 @@ import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
import { isPopoutWindow } from '@/lib/popout/window-role';
+import { offerMoveUndo } from '@/lib/popout/moved-undo';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
@@ -384,23 +385,30 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const handleDelete = useCallback(async () => {
if (!client || !email) return;
if (!(await confirmMailRemoval('delete', 1, confirmDialog, tRemove))) return;
+ // Where it was, for Undo; a delete for good has none.
+ const { selectedMailbox } = useEmailStore.getState();
+ const restore = bulkDeleteIsPermanent(mailboxes.find((m) => m.id === selectedMailbox)) ? null : restoreBatchesFor([email], client);
try {
// false: not deleted (a plugin stopped it), so the tab stays open.
- if (await deleteEmail(client, email.id)) onClose();
+ if (await deleteEmail(client, email.id)) {
+ await offerMoveUndo('delete', client, restore, tNotifications, isPopoutWindow());
+ onClose();
+ }
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
- }, [client, email, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
+ }, [client, email, mailboxes, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
const archiveMb = mailboxes.find((m) => m.role === 'archive');
if (!archiveMb) return;
if (!(await confirmMailRemoval('archive', 1, confirmDialog, tRemove))) return;
+ const restore = restoreBatchesFor([email], client);
try {
if (!(await moveToMailbox(client, email.id, archiveMb.id, emailKeyFor(email)))) return;
- toast.success(tNotifications('email_archived'));
+ await offerMoveUndo('archive', client, restore, tNotifications, isPopoutWindow());
onClose();
} catch (err) {
console.error('Archive failed:', err);
@@ -494,12 +502,16 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
+ const restore = restoreBatchesFor([email], client);
try {
- if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) onClose();
+ if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) {
+ await offerMoveUndo('move', client, restore, tNotifications, isPopoutWindow());
+ onClose();
+ }
} catch (err) {
console.error('Move failed:', err);
}
- }, [client, email, moveToMailbox, onClose]);
+ }, [client, email, moveToMailbox, onClose, tNotifications]);
// This view's message answers the same keys as a message in the mail tab,
// while its tab is the one in front. c comes from the mail page, as on any tab.
diff --git a/lib/popout/__tests__/moved-undo.test.ts b/lib/popout/__tests__/moved-undo.test.ts
new file mode 100644
index 000000000..67549099e
--- /dev/null
+++ b/lib/popout/__tests__/moved-undo.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
+import { clearUndoHistory, undoHistory } from '@/lib/undo-history';
+import { useAuthStore } from '@/stores/auth-store';
+import { useToastStore } from '@/stores/toast-store';
+import { movedNotice, offerMoveUndo, restoreBatchesFromNotice } from '../moved-undo';
+
+const original = useAuthStore.getState();
+const alice = { name: 'alice' };
+const bob = { name: 'bob' };
+const t = (key: string) => key;
+const POINTS = [{ id: 'm1', mailboxIds: { inbox: true }, seen: false }];
+
+beforeEach(() => {
+ FakeBroadcastChannel.reset();
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ useAuthStore.setState({
+ client: alice as never,
+ getAllConnectedClients: () => new Map([['alice', alice], ['bob', bob]]) as never,
+ getClientForAccount: (id: string) => ({ alice, bob } as Record)[id] as never,
+ });
+ useToastStore.getState().clearToasts();
+ clearUndoHistory();
+});
+
+afterEach(() => {
+ useAuthStore.setState(original);
+ vi.unstubAllGlobals();
+});
+
+describe('moved-undo', () => {
+ it('names each batch by its login, and another window finds its own connection for it', () => {
+ const notice = movedNotice('move', [{ client: bob as never, accountId: 'shared', points: POINTS }]);
+ expect(notice).toEqual({ kind: 'move', batches: [{ login: 'bob', accountId: 'shared', points: POINTS }] });
+ expect(restoreBatchesFromNotice(notice)).toEqual([{ client: bob, accountId: 'shared', points: POINTS }]);
+ });
+
+ it('with no main window to take it, the message window keeps the Undo itself', async () => {
+ await offerMoveUndo('archive', alice as never, [{ client: alice as never, points: POINTS }], t, true);
+ expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['email_archived']);
+ expect(undoHistory()).toHaveLength(1);
+ });
+});
diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts
index f2d64a312..d65144638 100644
--- a/lib/popout/channel.ts
+++ b/lib/popout/channel.ts
@@ -14,6 +14,7 @@
import type { ComposerDraftData } from '@/components/email/email-composer';
import { isAppLockedNow } from '@/lib/app-lock/state';
import type { MessagePriority } from '@/lib/message-priority';
+import type { EmailRestorePoint } from '@/stores/email-store';
import type { ComposePopoutPayload } from './compose-payload';
import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
@@ -54,6 +55,16 @@ export interface PopoutSentNotice {
} | null;
}
+/**
+ * A message a message window archived, deleted or moved, for the main window
+ * to offer Undo for (a toast and Ctrl+Z). The message window has closed by then.
+ */
+export interface PopoutMovedNotice {
+ kind: 'archive' | 'delete' | 'move';
+ /** Where the messages were (stores/email-store.ts EmailRestoreBatch), by login instead of client. */
+ batches: Array<{ login: string | null; accountId?: string; points: EmailRestorePoint[] }>;
+}
+
/**
* A draft whose window is closing, ready to save. A request started while a
* window closes doesn't survive, and saving takes several, so another window
@@ -91,6 +102,8 @@ export type PopoutMessage =
| { type: 'handoff-rendered'; handoffId: string }
| { type: 'sent'; requestId: string; notice: PopoutSentNotice }
| { type: 'sent-ack'; requestId: string }
+ | { type: 'moved'; requestId: string; notice: PopoutMovedNotice }
+ | { type: 'moved-ack'; requestId: string }
| { type: 'mail-changed'; accountId: string | null }
| { type: 'state-change' }
| { type: 'draft-rescue'; draft: RescuedDraft }
@@ -197,11 +210,20 @@ export async function requestWindowSession(
/** Tells a main window what was sent. True when one took it over. */
export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise {
+ return announceToMainWindow((requestId) => ({ type: 'sent', requestId, notice }), 'sent-ack', timeoutMs);
+}
+
+/** Hands a move's Undo to a main window. True when one took it over. */
+export function announceMovedToMainWindow(notice: PopoutMovedNotice, timeoutMs = 500): Promise {
+ return announceToMainWindow((requestId) => ({ type: 'moved', requestId, notice }), 'moved-ack', timeoutMs);
+}
+
+function announceToMainWindow(message: (requestId: string) => PopoutMessage, ack: 'sent-ack' | 'moved-ack', timeoutMs: number): Promise {
return new Promise((resolve) => {
const requestId = newId();
let timer: ReturnType | null = null;
- const channel = openPopoutChannel((message) => {
- if (message.type === 'sent-ack' && message.requestId === requestId) finish(true);
+ const channel = openPopoutChannel((answer) => {
+ if (answer.type === ack && answer.requestId === requestId) finish(true);
});
const finish = (taken: boolean) => {
if (timer) clearTimeout(timer);
@@ -213,7 +235,7 @@ export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 5
return;
}
timer = setTimeout(() => finish(false), timeoutMs);
- channel.post({ type: 'sent', requestId, notice });
+ channel.post(message(requestId));
});
}
diff --git a/lib/popout/moved-undo.ts b/lib/popout/moved-undo.ts
new file mode 100644
index 000000000..334a2de12
--- /dev/null
+++ b/lib/popout/moved-undo.ts
@@ -0,0 +1,60 @@
+import { toastWithUndo, undoLabels, undoMoveAction } from '@/lib/email-action-toast';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import type { EmailRestoreBatch } from '@/stores/email-store';
+import { announceMovedToMainWindow, type PopoutMovedNotice } from './channel';
+
+type MoveKind = PopoutMovedNotice['kind'];
+type Translate = (key: string, values?: Record) => string;
+
+/** The toast's title for each kind, in the `notifications` namespace. */
+const TITLE_KEY: Record = {
+ archive: 'email_archived',
+ delete: 'email_deleted',
+ move: 'email_moved',
+};
+
+/** A move this window made, with logins in place of clients, so another window can undo it. */
+export function movedNotice(kind: MoveKind, batches: EmailRestoreBatch[] | null): PopoutMovedNotice {
+ const clients = [...useAuthStore.getState().getAllConnectedClients()];
+ return {
+ kind,
+ batches: (batches ?? []).map((batch) => ({
+ login: clients.find(([, client]) => client === batch.client)?.[0] ?? null,
+ accountId: batch.accountId,
+ points: batch.points,
+ })),
+ };
+}
+
+/** A notice's restore batches, through this window's own connections. */
+export function restoreBatchesFromNotice(notice: PopoutMovedNotice): EmailRestoreBatch[] {
+ const auth = useAuthStore.getState();
+ return notice.batches.flatMap((batch) => {
+ const client = (batch.login ? auth.getClientForAccount(batch.login) : undefined) ?? auth.client;
+ return client && batch.points.length > 0 ? [{ client, accountId: batch.accountId, points: batch.points }] : [];
+ });
+}
+
+/**
+ * The toast with Undo after a message view archived, deleted or moved its
+ * message. A message window closes right after, and its toast would go with
+ * it: there the main window shows the toast and keeps the Undo, so Ctrl+Z in
+ * the main window reaches it. With no main window to take it, this one does.
+ */
+export async function offerMoveUndo(
+ kind: MoveKind,
+ client: IJMAPClient | null | undefined,
+ restore: EmailRestoreBatch[] | null,
+ t: Translate,
+ inOwnWindow: boolean,
+): Promise {
+ if (inOwnWindow && (await announceMovedToMainWindow(movedNotice(kind, restore)))) return;
+ toastWithUndo(t(TITLE_KEY[kind]), undoMoveAction(client, restore, undoLabels(t)));
+}
+
+/** The main window's side of offerMoveUndo. `t` is of the `notifications` namespace. */
+export function takeOverMoved(notice: PopoutMovedNotice, t: Translate): void {
+ const batches = restoreBatchesFromNotice(notice);
+ toastWithUndo(t(TITLE_KEY[notice.kind]), undoMoveAction(useAuthStore.getState().client, batches, undoLabels(t)));
+}
From 5be6154065bdd520feb8d2db53adb2daadec67b9 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:04:44 +0100
Subject: [PATCH 3/5] fix: let the desktop notice about keeping mail on this
device close by itself
---
.../__tests__/offline-default-notice.test.tsx | 38 +++++++++++++++++++
components/offline/offline-host.tsx | 6 ++-
2 files changed, 43 insertions(+), 1 deletion(-)
create mode 100644 components/offline/__tests__/offline-default-notice.test.tsx
diff --git a/components/offline/__tests__/offline-default-notice.test.tsx b/components/offline/__tests__/offline-default-notice.test.tsx
new file mode 100644
index 000000000..9d634869d
--- /dev/null
+++ b/components/offline/__tests__/offline-default-notice.test.tsx
@@ -0,0 +1,38 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { OfflineNotice } from '@/lib/offline/offline-mode';
+import { useToastStore } from '@/stores/toast-store';
+
+// mail-26: the desktop app's "Bulwark keeps your mail on this device" notice
+// stayed over the quick reply box until someone closed it.
+
+const notifier = vi.hoisted(() => ({ show: null as ((notice: OfflineNotice) => void) | null }));
+vi.mock('@/lib/offline/offline-mode', () => ({
+ setOfflineNotifier: (fn: ((notice: OfflineNotice) => void) | null) => {
+ notifier.show = fn;
+ },
+ startOfflineMode: () => () => {},
+ confirmLoss: () => {},
+ unsentCount: async () => ({ messages: 0, changes: 0 }),
+}));
+
+const { OfflineHost } = await import('../offline-host');
+
+beforeEach(() => {
+ useToastStore.getState().clearToasts();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('the notice that the desktop app keeps mail on this device', () => {
+ it('goes away by itself, and still offers Settings', () => {
+ render();
+ notifier.show!({ kind: 'default-on' });
+ const [notice] = useToastStore.getState().toasts;
+ expect(notice.title).toBe('default_on');
+ expect(notice.duration).toBeGreaterThan(0);
+ expect(notice.action?.label).toBe('open_settings');
+ });
+});
diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx
index f86d066a3..643fc02ce 100644
--- a/components/offline/offline-host.tsx
+++ b/components/offline/offline-host.tsx
@@ -11,6 +11,9 @@ import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { toast } from '@/stores/toast-store';
+/** How long the notice that the desktop app keeps mail stays: a long sentence, so twice a plain toast. */
+const DEFAULT_ON_NOTICE_MS = 10_000;
+
/**
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
* the outbox, the wipes. Its notices become toasts, and a sign-out the
@@ -74,7 +77,8 @@ export function OfflineHost() {
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
break;
case 'default-on':
- toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
+ // Said once; it goes like other notices, and Settings has the switch.
+ toast.info(tStorageRef.current('default_on'), { duration: DEFAULT_ON_NOTICE_MS, action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
From acb827bf18338ae6ff096d7be07c30244144c68d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:21:03 +0100
Subject: [PATCH 4/5] feat: add copy, write, add to contacts and find mail to
the right-click on an address in a message
---
.../__tests__/address-context-menu.test.tsx | 105 ++++++++++++++
components/email/address-context-menu.tsx | 132 ++++++++++++++++++
components/email/email-viewer.tsx | 25 +---
components/email/recipient-popover.tsx | 7 +
components/mail/mail-app.tsx | 7 +
lib/contacts/add-from-address.ts | 27 ++++
lib/mail-search-request.ts | 58 ++++++++
7 files changed, 341 insertions(+), 20 deletions(-)
create mode 100644 components/email/__tests__/address-context-menu.test.tsx
create mode 100644 components/email/address-context-menu.tsx
create mode 100644 lib/contacts/add-from-address.ts
create mode 100644 lib/mail-search-request.ts
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/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 dc1ed92b0..fa7938b8e 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -24,6 +24,7 @@ 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";
@@ -48,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 } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -156,6 +157,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";
@@ -5143,7 +5145,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Email address under name (on the name's line under Mountain View) */}
{!mvViewer && sender?.email && sender?.name && (
- {sender.email}
+
)}
@@ -6305,24 +6307,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