From 8fbc8a1c1dc3fb768bdedfb572282a0f04c6fbd2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:22 +0200 Subject: [PATCH 1/3] feat: let plugins add frames above and in an empty message list and buttons to message rows and the selection toolbar --- .../mail-list-plugin-surfaces.test.tsx | 255 ++++++++++++++++++ components/email/email-context-menu.tsx | 20 ++ components/email/email-hover-actions.tsx | 23 +- components/email/email-list.tsx | 26 ++ components/email/mv-list-toolbar.tsx | 8 + components/email/thread-list-item.tsx | 2 + .../plugins/__tests__/fake-plugin-offer.ts | 43 +++ 7 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/mail-list-plugin-surfaces.test.tsx create mode 100644 components/plugins/__tests__/fake-plugin-offer.ts diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx new file mode 100644 index 000000000..e228eb913 --- /dev/null +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -0,0 +1,255 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer'; + +// Plugin surfaces of the message list: frames above the list and in the empty +// list, buttons on each row (hover, and the row menu), and buttons in the +// toolbar shown while messages are selected. + +vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({ + PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot, +})); +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ + client: {}, identities: [], username: null, activeAccountId: 'acct-1', + getAllConnectedClients: () => new Map(), getClientForAccount: () => null, + })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined, accounts: [] }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const { EmailList } = await import('../email-list'); +const { MvListToolbar } = await import('../mv-list-toolbar'); +const { ThreadListItem } = await import('../thread-list-item'); +const { EmailContextMenu } = await import('../email-context-menu'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useUIStore } = await import('@/stores/ui-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { groupEmailsByThread } = await import('@/lib/thread-utils'); + +const mail = (id: string): Email => ({ + id, + threadId: `thread-${id}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2026-09-30T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${id}`, + hasAttachment: false, +}); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const dispatch = vi.fn().mockResolvedValue(undefined); +const initialEmailState = useEmailStore.getState(); + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set() }); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never); +}); + +afterEach(() => { + clearOffers(); + setActionDispatcher(null); +}); + +describe('message-list-header', () => { + it('shows the plugin frame above the list with the folder', () => { + offerSlots('p1', ['message-list-header']); + render(); + + const frame = screen.getByTestId('slot-message-list-header'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' }); + // Above the scrolling list, not inside it. + expect(frame.closest('[data-tour="email-list"]')).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull(); + }); +}); + +describe('message-list-empty', () => { + it('shows the frame under the "no messages" text of an empty list', () => { + offerSlots('p1', ['message-list-empty']); + render(); + + const frame = screen.getByTestId('slot-message-list-empty'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false }); + const text = screen.getByText('no_emails_description'); + expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('says when the empty list is a search', () => { + offerSlots('p1', ['message-list-empty']); + useEmailStore.setState({ searchQuery: 'invoice' }); + render(); + + expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true); + }); + + it('is not there when the list has messages, or without a plugin', () => { + offerSlots('p1', ['message-list-empty']); + const { unmount } = render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + unmount(); + clearOffers(); + render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + }); +}); + +describe('message-bulk-actions', () => { + it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => { + const emails = [mail('a'), mail('b'), mail('c')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render(); + + const button = screen.getByRole('button', { name: 'Make tasks' }); + // In the toolbar that holds the app's own batch buttons. + const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!; + expect(toolbar).toContainElement(button); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] }); + }); + + it('has no plugin buttons without a selection or without a plugin', () => { + const emails = [mail('a')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) }); + const { unmount } = render(); + expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull(); + unmount(); + + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + useEmailStore.setState({ selectedEmailKeys: new Set() }); + render(); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); + + it('adds them to the Mountain View toolbar too', async () => { + const emails = [mail('a'), mail('b')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] }); + }); + + it('leaves the Mountain View toolbar alone without a selection', () => { + useEmailStore.setState({ emails: [mail('a')] }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); +}); + +describe('message-row-actions', () => { + const row = (email: Email) => { + const [thread] = groupEmailsByThread([email]); + return {}} onEmailSelect={() => {}} />; + }; + + it('adds the plugin buttons after the row\'s own hover buttons', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }])); + const onSelect = vi.fn(); + const [thread] = groupEmailsByThread([mail('a')]); + render( {}} onEmailSelect={onSelect} />); + + const button = screen.getByRole('button', { name: 'Later' }); + const star = screen.getByTestId('hover-action-star'); + expect(star.parentElement).toContainElement(button); + expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + // The click is the plugin's, not a row click that opens the message. + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('shows them even when the user turned off every quick action', () => { + useSettingsStore.setState({ hoverActions: [] } as never); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + render(row(mail('a'))); + expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument(); + }); + + it('honours the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }])); + render(row(mail('a'))); + expect(screen.queryByRole('button', { name: 'Later' })).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + render(row(mail('a'))); + expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull(); + }); + + const menu = (props: Partial> = {}) => render( + {})} + menuRef={{ current: null }} + mailboxes={mailboxes} + selectedMailbox="inbox" + currentMailboxRole="inbox" + {...props} + />, + ); + + it('puts them in the row menu, which is what a long press opens on phones', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + const onClose = vi.fn(); + menu({ onClose }); + + const item = screen.getByRole('menuitem', { name: 'Later' }); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => { + const { unmount } = menu(); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + unmount(); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + menu({ isMultiSelect: true, selectedCount: 3 }); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); + + it('keeps the menu entries to the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }])); + menu(); + expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3c732be1a..26ee9d45a 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -12,6 +12,7 @@ import { ContextMenuHeader, } from "@/components/ui/context-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; import { Reply, ReplyAll, @@ -266,6 +267,23 @@ export function EmailContextMenu({ // Stable, so the Rules entry does not re-resolve its account every render. const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]); + // The row's plugin buttons (message-row-actions). On phones the long press + // opens this menu, so it is where those buttons live there. + const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0; + const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); + const rowPluginBlock = !showBatchActions && hasRowPluginActions ? ( + <> + + + + ) : null; + // The menu's entries, composed below in the default layout's order or, // under Mountain View, in Gmail's. const replyBlock = ( @@ -564,6 +582,7 @@ export function EmailContextMenu({ {findBlock} {permalinkBlock} + {rowPluginBlock} ) : ( <> @@ -581,6 +600,7 @@ export function EmailContextMenu({ {spamBlock} {readItem} + {rowPluginBlock} )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 6b1e835fe..82fd8c29c 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; import { TagPicker } from "@/components/email/tag-picker"; import { getEmailTagIds } from "@/lib/thread-utils"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; interface EmailHoverActionsProps { email: Email; @@ -27,6 +28,8 @@ interface EmailHoverActionsProps { onUndoSpam?: () => void; // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). spamApplicable?: boolean; + /** Role of the folder the row is listed in; plugin buttons can be limited to some. */ + mailboxRole?: string | null; } const ACTION_CONFIG: Record state.hoverActions); const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); @@ -113,6 +117,9 @@ export function EmailHoverActions({ const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const t = useTranslations("settings.email_behavior.hover_actions"); const isMobile = useIsMobile(); + // Buttons plugins add to the row (message-row-actions), after the app's own. + const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length; + const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); // The tag quick-action opens the same picker the context menu and the reading // pane use. It is portalled to the body because every row sits inside a @@ -159,7 +166,7 @@ export function EmailHoverActions({ const hoverBackgroundClassName = backgroundClassName; if (isMobile) return null; - if (hoverActions.length === 0) return null; + if (hoverActions.length === 0 && pluginActionCount === 0) return null; const handleAction = (e: React.MouseEvent, action: HoverAction) => { e.stopPropagation(); @@ -251,6 +258,18 @@ export function EmailHoverActions({ ); }); + if (pluginActionCount > 0) { + actionButtons.push( + , + ); + } const tagPicker = tagPickerPos && onSetTag diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 25bb80857..3c974a155 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -33,6 +33,8 @@ import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { getOwnAddresses } from "@/lib/filters/quick-rule-target"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions } from "@/components/plugins/plugin-actions"; interface EmailListProps { emails: Email[]; @@ -381,6 +383,19 @@ export function EmailList({ ); const hasSelection = selectedEmailKeys.size > 0; + // What plugin buttons in the selection toolbar act on: the JMAP ids of the + // selected messages, thread members included. + const selectedEmailIds = useMemo( + () => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id), + [emails, threadEmailsCache, selectedEmailKeys], + ); + const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]); + const listSlotProps = useMemo( + () => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }), + [selectedMailbox, effectiveMailboxRole], + ); + const isSearching = !!searchQuery || !isFilterEmpty(searchFilters); + const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]); const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -653,6 +668,14 @@ export function EmailList({ )} + {hasSelection && ( + + )}
)} + email.id) }} + mailboxRole={currentRole ?? null} + compact + className="shrink-0" + /> {tBatch("selected_messages", { count: selectionCount })} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 48697c5b2..5ed9264fb 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef( onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )} @@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )} diff --git a/components/plugins/__tests__/fake-plugin-offer.ts b/components/plugins/__tests__/fake-plugin-offer.ts new file mode 100644 index 000000000..a11d8c685 --- /dev/null +++ b/components/plugins/__tests__/fake-plugin-offer.ts @@ -0,0 +1,43 @@ +// Test helpers: pretend a running plugin offers a frame slot, without booting +// a sandbox. Pair with a vi.mock of '@/components/plugins/plugin-iframe-slot' +// (FakeIframeSlot below) so the "iframe" shows the props it would receive. + +import React from 'react'; +import { register, deregister } from '@/lib/plugin-sandbox/registry'; +import type { ActivePlugin } from '@/lib/plugin-sandbox/registry'; +import type { SlotName } from '@/lib/plugin-types'; + +const offered = new Set(); + +/** Registers a fake plugin that offers the given slots. */ +export function offerSlots(pluginId: string, slots: string[]): void { + register({ + plugin: { id: pluginId, name: pluginId }, + code: '', + tier: 'untrusted', + background: {}, + slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false })), + hookDisposables: [], + } as unknown as ActivePlugin); + offered.add(pluginId); +} + +/** Removes every fake plugin registered with offerSlots. */ +export function clearOffers(): void { + for (const id of offered) deregister(id); + offered.clear(); +} + +/** Stand-in for PluginIframeSlot: a div carrying the slot name and its props as JSON. */ +export function FakeIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record }) { + return React.createElement('div', { + 'data-testid': `slot-${slot}`, + 'data-plugin': pluginId, + 'data-props': JSON.stringify(extraProps ?? {}), + }); +} + +/** The props the fake iframe of a slot received. */ +export function slotProps(el: HTMLElement): Record { + return JSON.parse(el.getAttribute('data-props') ?? '{}'); +} From 69019781094b8ee0af5dabfaaef3ae622a2f3bd9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:56:55 +0200 Subject: [PATCH 2/3] feat: let plugins add frames above the subject, under the sender, above the quote and above a conversation, and buttons to the reader toolbar and attachment menus --- .../__tests__/reader-plugin-surfaces.test.tsx | 273 ++++++++++++++++++ components/email/email-viewer.tsx | 69 ++++- components/email/reader-plugin-surfaces.tsx | 251 ++++++++++++++++ components/email/thread-conversation-view.tsx | 43 ++- 4 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 components/email/__tests__/reader-plugin-surfaces.test.tsx create mode 100644 components/email/reader-plugin-surfaces.tsx diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx new file mode 100644 index 000000000..588f2ee90 --- /dev/null +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -0,0 +1,273 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer'; + +// Plugin surfaces of the open message: frames above the subject, under the +// sender, between new text and quote, above a conversation; buttons in the +// reader toolbar (More panel on phones); entries in an attachment's menu. + +const device = vi.hoisted(() => ({ isMobile: false })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), + useMediaQuery: () => false, + useIsMobile: () => device.isMobile, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({ + PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot, +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); +const { ReaderAboveQuoteSlot, QUOTE_ANCHOR_ATTR } = await import('../reader-plugin-surfaces'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function mail(overrides: Partial = {}): Email { + return { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [], attachments: [], bodyValues: {}, + ...overrides, + } as unknown as Email; +} + +function textMail(text: string, overrides: Partial = {}): Email { + return mail({ + textBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { '1': { value: text, isTruncated: false } }, + ...overrides, + } as Partial); +} + +const REPLY = 'Sounds good, see you then.\n\nOn Monday Bob wrote:\n> Shall we meet at ten?\n> Bob'; + +function viewer(email: Email, props: Partial> = {}) { + return render( + , + ); +} + +const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +const dispatch = vi.fn().mockResolvedValue(undefined); +const initialSettings = useSettingsStore.getState(); + +beforeEach(() => { + device.isMobile = false; + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); + useSettingsStore.setState(initialSettings, true); +}); + +afterEach(() => { + clearOffers(); + setActionDispatcher(null); +}); + +describe('reader-header and reader-below-subject', () => { + it('put the frames above the subject and under the sender, with the message', () => { + offerSlots('p1', ['reader-header', 'reader-below-subject']); + const { container } = viewer(textMail('Hi')); + + const header = screen.getByTestId('slot-reader-header'); + const below = screen.getByTestId('slot-reader-below-subject'); + const subject = screen.getByRole('heading', { name: 'Hello there' }); + const body = container.querySelector('.email-content-wrapper')!; + expect(follows(header, subject)).toBe(true); + expect(follows(subject, below)).toBe(true); + expect(follows(below, body)).toBe(true); + expect((slotProps(header).email as { id: string }).id).toBe('e1'); + expect((slotProps(below).email as { subject: string }).subject).toBe('Hello there'); + }); + + it('draw nothing without a plugin', () => { + const { container } = viewer(textMail('Hi')); + expect(container.querySelector('[data-plugin-slot="reader-header"]')).toBeNull(); + expect(container.querySelector('[data-plugin-slot="reader-below-subject"]')).toBeNull(); + }); +}); + +describe('reader-above-quote', () => { + it('sits between the new text of a plain-text reply and its folded quote', () => { + offerSlots('p1', ['reader-above-quote']); + const { container } = viewer(textMail(REPLY)); + + const frame = screen.getByTestId('slot-reader-above-quote'); + const quote = container.querySelector('.email-content-text details')!; + expect(quote).not.toBeNull(); + expect(follows(frame, quote)).toBe(true); + expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true); + expect((slotProps(frame).email as { id: string }).id).toBe('e1'); + }); + + it('is not mounted for a message without a quote, or without a plugin', () => { + offerSlots('p1', ['reader-above-quote']); + const { unmount } = viewer(textMail('Just some text.')); + expect(screen.queryByTestId('slot-reader-above-quote')).toBeNull(); + unmount(); + clearOffers(); + const { container } = viewer(textMail(REPLY)); + expect(container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull(); + }); + + it('lays the frame over an HTML body and makes room for it before the quote toggle', () => { + offerSlots('p1', ['reader-above-quote']); + const host = document.createElement('div'); + document.body.appendChild(host); + const iframe = document.createElement('iframe'); + host.appendChild(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = '

New text

Old
'; + + const { unmount } = render( + , + { container: host.appendChild(document.createElement('div')) }, + ); + + const anchor = doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!; + expect(anchor.nextElementSibling).toBe(doc.querySelector('[data-quote-toggle]')); + const overlay = document.querySelector('[data-plugin-quote-overlay]')!; + expect(within(overlay as HTMLElement).getByTestId('slot-reader-above-quote')).toBeInTheDocument(); + unmount(); + expect(doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull(); + host.remove(); + }); + + it('shows in a conversation message on phones too', () => { + offerSlots('p1', ['reader-above-quote']); + const email = textMail(REPLY, { keywords: {} } as Partial); + const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup; + render( {}} />); + + expect(screen.getByTestId('slot-reader-above-quote')).toBeInTheDocument(); + }); +}); + +describe('thread-summary', () => { + const conversation = [ + textMail('First', { id: 'm1', receivedAt: '2026-01-01T00:00:00Z' } as Partial), + textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial), + ]; + + it('sits above the messages of the phone conversation view', () => { + offerSlots('p1', ['thread-summary']); + const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; + render( {}} />); + + const frame = screen.getByTestId('slot-thread-summary'); + expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] }); + expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true); + }); + + it('sits above a Mountain View conversation', () => { + offerSlots('p1', ['thread-summary']); + useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 }); + viewer(conversation[1], { mvConversation: conversation }); + + expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] }); + }); + + it('draws nothing without a plugin, and nothing for a single message', () => { + const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; + const { unmount } = render( {}} />); + expect(screen.queryByTestId('slot-thread-summary')).toBeNull(); + unmount(); + offerSlots('p1', ['thread-summary']); + viewer(conversation[1]); + expect(screen.queryByTestId('slot-thread-summary')).toBeNull(); + }); +}); + +describe('reader-actions', () => { + it('adds the plugin buttons to the reader toolbar', async () => { + act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }])); + viewer(textMail('Hola')); + + const button = screen.getByRole('button', { name: 'Translate' }); + expect(button.closest('[data-plugin-actions="reader-actions"]')).not.toBeNull(); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' }); + }); + + it('keeps to the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['junk'] }])); + viewer(textMail('Hola')); + expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull(); + }); + + it('puts them in the More panel on phones', async () => { + device.isMobile = true; + act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }])); + viewer(textMail('Hola')); + + expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull(); + const item = screen.getByRole('menuitem', { name: 'Translate', hidden: true }); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' }); + }); + + it('draws nothing without a plugin', () => { + const { container } = viewer(textMail('Hola')); + expect(container.querySelector('[data-plugin-actions="reader-actions"]')).toBeNull(); + }); +}); + +describe('attachment-menu', () => { + const withAttachments = () => textMail('See attached', { + hasAttachment: true, + attachments: [ + { partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }, + { partId: '3', blobId: 'blob-png', name: 'photo.png', type: 'image/png', size: 99, disposition: 'attachment' }, + ], + } as Partial); + + it('opens the plugin entries from a menu button on the attachment and sends the attachment', async () => { + act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud', mimeTypes: ['application/pdf'] }])); + viewer(withAttachments()); + + // Only the PDF gets the menu (desktop chip and phone chip are both in the DOM). + const buttons = document.querySelectorAll('[data-attachment-plugin-menu]'); + expect(buttons.length).toBeGreaterThan(0); + for (const b of buttons) { + expect(b.closest('[data-attachment-name]')?.getAttribute('data-attachment-name')).toBe('invoice.pdf'); + } + fireEvent.click(buttons[0]); + const item = await screen.findByRole('menuitem', { name: 'Save to cloud' }); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'attachment-menu', 'save', { + emailId: 'e1', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, + }); + }); + + it('adds the menu to attachments in a conversation message', () => { + act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud' }])); + const email = withAttachments(); + const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup; + render( {}} />); + + expect(document.querySelectorAll('[data-attachment-plugin-menu]')).toHaveLength(2); + }); + + it('draws no menu button without a plugin entry', () => { + viewer(withAttachments()); + expect(document.querySelector('[data-attachment-plugin-menu]')).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e77c02b0f..150257185 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -122,6 +122,8 @@ import { VerificationCodeChip } from "./verification-code-chip"; import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view"; import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions } from "@/components/plugins/plugin-actions"; +import { AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; import { RulesPanel, useRulesAvailability } from "./rules-menu"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -1122,6 +1124,11 @@ export function EmailViewer({ const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1; const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : []; const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : []; + const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : ''; + const threadSummaryProps = useMemo( + () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }), + [email?.threadId, mvConversationIds], + ); // Mountain View: which other messages of the conversation are unfolded. A // conversation opens with its unread messages unfolded, as Gmail does, so // reading the thread reads all of them. @@ -1416,15 +1423,20 @@ export function EmailViewer({ // bottom plugin category wrapper when something will fill it. const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0; const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0; + const hasHeaderOffers = usePluginSlotOffers('reader-header').length > 0; + const hasBelowSubjectOffers = usePluginSlotOffers('reader-below-subject').length > 0; + const hasAboveQuoteOffers = usePluginSlotOffers('reader-above-quote').length > 0; // One projected view per message for all plugin slots on it, built only // when a plugin is there to see it. A fresh object on every render made // each slot post its props to the plugin again and re-run shouldShow. - const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers; + const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers + || hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers; const pluginEmailView = useMemo( () => (email && wantsPluginEmailView ? emailToReadView(email) : null), [email, wantsPluginEmailView], ); const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]); + const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]); const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { if (typeof window === 'undefined') return false; try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } @@ -2779,6 +2791,15 @@ export function EmailViewer({ // Gates the quick reply on the iframe having loaded the current srcDoc, so // it doesn't flash in below a still-resizing iframe. const [iframeReady, setIframeReady] = useState(false); + // The plain-text body, where reader-above-quote mounts between text and quote. + const [plainBodyEl, setPlainBodyEl] = useState(null); + // One object per body: React writes innerHTML again whenever this object + // changes, which would drop what was mounted in the body and fold the quote + // back up on every render. + const plainBodyHtml = useMemo( + () => ({ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }), + [effectiveEmailContent.html], + ); // Tracks which parsed document we've already wired up, so setup runs exactly // once per srcDoc even though both the readiness poll below and the iframe // 'load' event can trigger it. @@ -3520,6 +3541,15 @@ export function EmailViewer({ )} + {/* Phones get these in the More panel. */} + {!isMobile && email && ( + + )} {/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */} @@ -4265,6 +4295,14 @@ export function EmailViewer({ {t('keyboard_shortcuts')} )} + {email && ( + setMoreMenuOpen(false)} + /> + )} )} {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { @@ -4337,6 +4375,8 @@ export function EmailViewer({ )} + + {/* === SUBJECT BLOCK === */}
+ {/* Above the messages of the open conversation. */} + {mvOpenIndex >= 0 && ( + + )} + {/* Mountain View: the conversation's earlier messages, folded. */} {mvBefore.length > 0 && (
@@ -4722,6 +4767,7 @@ export function EmailViewer({ } satisfies AttachmentInfo, }} /> +
)} @@ -4795,6 +4841,7 @@ export function EmailViewer({ } satisfies AttachmentInfo, }} /> +
)} @@ -4918,6 +4965,8 @@ export function EmailViewer({ + + {/* Expandable Details (shared across mobile/tablet/desktop) */} {showFullHeaders && (() => { const translateAuthResult = (result?: string) => { @@ -5581,6 +5630,7 @@ export function EmailViewer({ } satisfies AttachmentInfo, }} /> + )} @@ -5654,6 +5704,7 @@ export function EmailViewer({ } satisfies AttachmentInfo, }} /> + )} @@ -5714,6 +5765,8 @@ export function EmailViewer({ {formatFileSize(attachment.size)} + {/* Phones have no hover: the plugin menu stays visible. */} +
{formatFileSize(attachment.size)} +
+ + + + + ); +} + +// ─── Entries in a phone slide-in panel ─────────────────────── + +/** + * Plugin actions as rows of a full-height phone menu panel (the reader's + * "More" panel), drawn like the panel's own rows. A separator leads them. + */ +export function PluginPanelEntries({ + surface, + context, + mailboxRole, + onRan, +}: { + surface: string; + context: Record; + mailboxRole?: string | null; + onRan?: () => void; +}) { + const actions = usePluginActions(surface, { mailboxRole }); + if (actions.length === 0) return null; + return ( + <> +
+ {actions.map((action) => { + const Icon = iconForName(action.icon) ?? Puzzle; + return ( + + ); + })} + + ); +} + +// ─── reader-above-quote ────────────────────────────────────── + +export const QUOTE_ANCHOR_ATTR = "data-plugin-quote-anchor"; + +/** + * Puts an empty block right before the collapsed quote of a rendered body + * and returns it: before the "•••" toggle setupQuoteCollapse added (HTML + * body in its iframe), or before the
collapsePlainTextQuotes + * wrapped the quote in (plain-text body). Null when the body has no quote + * the viewer recognised. + */ +export function insertQuoteAnchor(root: HTMLElement): HTMLElement | null { + const existing = root.querySelector(`[${QUOTE_ANCHOR_ATTR}]`); + if (existing) return existing; + const toggle = root.querySelector(`[${QUOTE_TOGGLE_ATTR}]`) + ?? root.querySelector(":scope > details"); + if (!toggle?.parentNode) return null; + const anchor = root.ownerDocument.createElement("div"); + anchor.setAttribute(QUOTE_ANCHOR_ATTR, ""); + anchor.style.cssText = "display:block;margin:0;padding:0;border:0;height:0;white-space:normal;"; + toggle.parentNode.insertBefore(anchor, toggle); + return anchor; +} + +/** + * The `reader-above-quote` frame. In a plain-text body (same document) it + * renders into the anchor. In an HTML body the anchor lives in the mail's + * sandboxed iframe, which can't host a plugin frame, so the frame is laid + * over the iframe at the anchor's place and the anchor is grown to the + * frame's height to make room for it. + * + * `root` is the body element the quote was collapsed in (null while the body + * isn't ready); `version` changes whenever that body is rendered anew. + */ +export function ReaderAboveQuoteSlot({ + root, + frame, + extraProps, + version, +}: { + root: HTMLElement | null; + /** The iframe holding `root`, for an HTML body. */ + frame?: HTMLIFrameElement | null; + extraProps: Record; + version?: unknown; +}) { + const offered = usePluginSlotOffers("reader-above-quote").length > 0; + const [anchor, setAnchor] = useState(null); + + useLayoutEffect(() => { + if (!offered || !root) { + setAnchor(null); + return; + } + const next = insertQuoteAnchor(root); + setAnchor(next); + return () => { + next?.remove(); + }; + }, [offered, root, version]); + + if (!anchor) return null; + const slot = ; + if (!frame || anchor.ownerDocument === document) { + return createPortal(slot, anchor); + } + return {slot}; +} + +function QuoteOverlay({ anchor, frame, children }: { anchor: HTMLElement; frame: HTMLIFrameElement; children: React.ReactNode }) { + const overlayRef = useRef(null); + const [box, setBox] = useState<{ top: number; left: number; width: number } | null>(null); + + useEffect(() => { + const overlay = overlayRef.current; + const host = overlay?.offsetParent as HTMLElement | null; + if (!overlay) return; + const place = () => { + // The anchor's box is in the iframe's viewport; the iframe doesn't + // scroll (it is as tall as the mail), so frame top + anchor top is + // where the anchor sits on the page. + const frameRect = frame.getBoundingClientRect(); + const hostRect = host?.getBoundingClientRect() ?? { top: 0, left: 0 }; + const anchorRect = anchor.getBoundingClientRect(); + const next = { + top: frameRect.top - hostRect.top + frame.clientTop + anchorRect.top, + left: frameRect.left - hostRect.left + frame.clientLeft, + width: frame.clientWidth, + }; + setBox((prev) => (prev && prev.top === next.top && prev.left === next.left && prev.width === next.width ? prev : next)); + }; + const fit = () => { + // Make room in the mail for the frame. A mail scaled down to fit a + // phone is drawn smaller than its layout, so undo that scale. + const scale = anchor.offsetWidth > 0 ? anchor.getBoundingClientRect().width / anchor.offsetWidth : 1; + const height = overlay.offsetHeight / (scale || 1); + anchor.style.height = `${Math.ceil(height)}px`; + place(); + }; + fit(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(fit); + observer.observe(overlay); + observer.observe(frame); + window.addEventListener("resize", place); + return () => { + observer.disconnect(); + window.removeEventListener("resize", place); + anchor.style.height = "0px"; + }; + }, [anchor, frame]); + + return ( +
+ {children} +
+ ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 72d3db894..ea585a6f7 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; @@ -42,6 +42,10 @@ import { isFilePreviewable, toInertBlob } from "@/lib/file-preview"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; +import { emailToReadView } from "@/lib/plugin-projection"; +import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; interface ThreadConversationViewProps { thread: ThreadGroup; @@ -170,6 +174,11 @@ export function ThreadConversationView({ }: ThreadConversationViewProps) { const t = useTranslations(); const externalContent = useConversationExternalContent(); + const emailIdList = emails.map((email) => email.id).join(','); + const threadSummaryProps = useMemo( + () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [] }), + [thread.latestEmail.threadId, emailIdList], + ); // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -242,6 +251,7 @@ export function ThreadConversationView({ {/* Email Cards */}
+
{emails.map((email, index) => ( >({}); const { client } = useAuthStore(); + // reader-above-quote: the body the quote was collapsed in, once it is on screen. + const [htmlBodyEl, setHtmlBodyEl] = useState(null); + const [plainBodyEl, setPlainBodyEl] = useState(null); + const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0; + const quoteSlotProps = useMemo( + () => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }), + [email, wantsQuoteSlot], + ); // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { @@ -651,6 +669,13 @@ export function ConversationEmailBody({ ${isDark ? emailDarkModeCss() : ''} ${emailContent.html}`; }, [emailContent.isHtml, emailContent.html, allowExternal, isDark]); + useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]); + // One object per body: React writes innerHTML again whenever this object + // changes, dropping whatever was mounted in the body. + const plainBodyHtml = useMemo( + () => ({ __html: sanitizePlainTextRenderedHtml(emailContent.html) }), + [emailContent.html], + ); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; @@ -679,6 +704,7 @@ export function ConversationEmailBody({ a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }); + setHtmlBodyEl(doc.body); } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } @@ -726,7 +752,7 @@ export function ConversationEmailBody({ )} {/* Email Body */} -
+
{emailContent.isHtml ? (