Merge branch 'slots/mail' into feat/plugin-system-audit

# Conflicts:
#	lib/__tests__/plugin-slot-catalog.test.ts
This commit is contained in:
Linus Rath
2026-10-14 05:07:18 +02:00
15 changed files with 1475 additions and 54 deletions
@@ -0,0 +1,317 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
// Plugin surfaces of the composer: frames above and below the message text
// (fed the draft, at most every 2 seconds), and entries in the Send and
// Attach menus.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
}));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
onBeforeAttachmentUpload: { intercept: async () => true },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
plainTextBodyHasSignature: () => false,
plainTextBodyWithoutSignature: (body: string) => body,
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DRAFT = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Lunch',
body: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
const draftOf = (el: HTMLElement) => slotProps(el).draft as { subject: string; to: string[]; htmlBody: string; identityId: string };
const dispatch = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
resetActions();
dispatch.mockClear();
setActionDispatcher(dispatch);
useAuthStore.setState({ client: null });
});
afterEach(() => {
clearOffers();
setActionDispatcher(null);
vi.useRealTimers();
});
describe('composer-above-body and composer-below-body', () => {
it('put the frames around the message text with the draft', () => {
offerSlots('p1', ['composer-above-body', 'composer-below-body']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const above = screen.getByTestId('slot-composer-above-body');
const below = screen.getByTestId('slot-composer-below-body');
const editor = screen.getByTestId('rich-text-editor');
expect(follows(screen.getByTestId('composer-subject'), above)).toBe(true);
expect(follows(above, editor)).toBe(true);
expect(follows(editor, below)).toBe(true);
expect(draftOf(above)).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'], htmlBody: '<p>Hello there</p>', identityId: 'id-me' });
expect(draftOf(below).subject).toBe('Lunch');
});
it('send a changed draft only after 2 seconds without changes', () => {
offerSlots('p1', ['composer-above-body']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
vi.useFakeTimers();
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
act(() => { vi.advanceTimersByTime(1000); });
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch at 12?' } });
act(() => { vi.advanceTimersByTime(1900); });
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch');
act(() => { vi.advanceTimersByTime(200); });
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch at 12?');
});
it('draw nothing without a plugin', () => {
const { container } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
expect(container.querySelector('[data-plugin-slot="composer-below-body"]')).toBeNull();
});
});
describe('composer-send-menu', () => {
it('turns Send into a split button for plugin entries and sends the draft on click', async () => {
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
// The phone and the desktop Send button are both in the DOM.
fireEvent.click(screen.getAllByRole('button', { name: 'more_actions' })[0]);
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
await act(async () => { fireEvent.click(screen.getAllByRole('menuitem', { name: 'Send with Later' })[0]); });
expect(dispatch).toHaveBeenCalledTimes(1);
const [pluginId, surface, actionId, context] = dispatch.mock.calls[0];
expect([pluginId, surface, actionId]).toEqual(['p', 'composer-send-menu', 'later']);
expect((context as { draft: { subject: string } }).draft).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'] });
expect(screen.queryByRole('menuitem', { name: 'Send with Later' })).toBeNull();
});
it('lists the entries under Schedule send when the account has it', () => {
useAuthStore.setState({
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
});
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
expect(follows(schedule, plugin)).toBe(true);
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
expect(screen.queryByRole('button', { name: 'schedule_send' })).toBeNull();
});
});
describe('composer-attach-menu', () => {
it('turns Attach into a menu for plugin entries', async () => {
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('composer-attach-menu'));
expect(screen.getByRole('menuitem', { name: 'attach_from_device' })).toBeInTheDocument();
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'From the cloud' })); });
expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', {});
expect(screen.queryByRole('menuitem', { name: 'From the cloud' })).toBeNull();
});
it('keeps the plain Attach button without plugin entries', () => {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
});
});
@@ -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<string>() });
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[mail('a')]} />);
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
unmount();
clearOffers();
render(<EmailList emails={[]} />);
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(<EmailList emails={emails} />);
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(<EmailList emails={emails} />);
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<string>() });
render(<EmailList emails={emails} />);
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(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
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(<MvListToolbar loadedCount={1} onRefresh={() => {}} />);
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
});
describe('message-row-actions', () => {
const row = (email: Email) => {
const [thread] = groupEmailsByThread([email]);
return <ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<React.ComponentProps<typeof EmailContextMenu>> = {}) => render(
<EmailContextMenu
email={mail('a')}
position={{ x: 0, y: 0 }}
isOpen
onClose={props.onClose ?? (() => {})}
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();
});
});
@@ -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> = {}): 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> = {}): Email {
return mail({
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: text, isTruncated: false } },
...overrides,
} as Partial<Email>);
}
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<React.ComponentProps<typeof EmailViewer>> = {}) {
return render(
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...props} />,
);
}
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 = '<p>New text</p><button data-quote-toggle>...</button><blockquote type="cite">Old</blockquote>';
const { unmount } = render(
<ReaderAboveQuoteSlot root={doc.body} frame={iframe} extraProps={{ email: { id: 'e1' } }} />,
{ 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<Email>);
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
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<Email>),
textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
];
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(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
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(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
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<Email>);
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(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
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();
});
});
+47 -1
View File
@@ -2,7 +2,9 @@
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Check, ChevronDown, type AppIcon } from "@/components/icons";
import { Check, ChevronDown, iconForName, Puzzle, type AppIcon } from "@/components/icons";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { cn } from "@/lib/utils";
interface ComposerToolbarMenuProps {
@@ -138,3 +140,47 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
</button>
);
}
interface PluginItemsProps {
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
surface: string;
/** What the click is about; a function is read at click time. */
context: Record<string, unknown> | (() => Record<string, unknown>);
/** Called before the plugin gets the click, e.g. to close the menu. */
onSelect?: () => void;
/** Draw a separator before the entries (when the menu has its own above). */
separator?: boolean;
}
/** Entries plugins declared for a composer menu, drawn like the menu's own. */
export function ComposerPluginMenuItems({ surface, context, onSelect, separator }: PluginItemsProps) {
const actions = usePluginActions(surface);
if (actions.length === 0) return null;
return (
<>
{separator && <div role="separator" className="my-1 h-px bg-border" />}
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
return (
<button
key={`${action.pluginId}:${action.id}`}
type="button"
role="menuitem"
title={action.pluginName}
onClick={() => {
onSelect?.();
void runAction(action, surface, typeof context === "function" ? context() : context);
}}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground",
action.danger && "text-destructive",
)}
>
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1">{action.label}</span>
</button>
);
})}
</>
);
}
+106 -43
View File
@@ -34,7 +34,7 @@ import {
Reply,
Forward,
} from "@/components/icons";
import { ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
import { ComposerPluginMenuItems, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { schedulePresets } from "@/lib/schedule-presets";
import { debug } from "@/lib/debug";
@@ -63,6 +63,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
@@ -382,6 +383,7 @@ export function EmailComposer({
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
// Per-message format (#1022). Seeded from the "plain text only" setting -
@@ -2068,6 +2070,12 @@ export function EmailComposer({
const pluginAttachmentSourceProps = useMemo(() => ({ onAttach: handlePluginAttachmentAdd }), [handlePluginAttachmentAdd]);
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
// Plugin entries make the Send and Attach buttons menus even where the app
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
// Files from the Files app are already blobs on the server, so they are
// attached by blobId with no download or upload (#1179). Stalwart resolves a
@@ -2377,6 +2385,45 @@ export function EmailComposer({
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
// The draft as plugins see it: onDraftChange, the frames above and below
// the body, and clicks on plugin entries in the Send menu.
const draftSnapshot = () => ({
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
const draftSnapshotRef = useRef(draftSnapshot);
draftSnapshotRef.current = draftSnapshot;
// composer-above-body / composer-below-body get the draft when they appear
// and then 2 seconds after the last change, like onDraftChange: a new prop
// object on every keystroke would post the whole body into their iframes.
const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers;
const [pluginDraft, setPluginDraft] = useState<ReturnType<typeof draftSnapshot> | null>(null);
const pluginDraftShownRef = useRef(false);
useEffect(() => {
if (!wantsPluginDraft) {
pluginDraftShownRef.current = false;
return;
}
if (!pluginDraftShownRef.current) {
pluginDraftShownRef.current = true;
setPluginDraft(draftSnapshotRef.current());
return;
}
const timer = setTimeout(() => setPluginDraft(draftSnapshotRef.current()), 2000);
return () => clearTimeout(timer);
}, [wantsPluginDraft, toStr, ccStr, bccStr, subject, body, plainTextMode, selectedIdentityId, attachments]);
const pluginDraftProps = useMemo(() => ({ draft: pluginDraft }), [pluginDraft]);
const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current() }), []);
// Trigger auto-save when content changes (only if user modified something)
useEffect(() => {
// Clear existing timeout
@@ -2396,18 +2443,7 @@ export function EmailComposer({
// tracked via inflightSaveRef.
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
emailHooks.onDraftChange.emit(draftSnapshot());
saveDraft({ auto: true });
}, 2000);
@@ -3269,7 +3305,7 @@ export function EmailComposer({
</div>
</div>
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
{composerClient?.hasDelayedSend() ? (
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
<Button
onClick={() => handleSend()}
@@ -3286,7 +3322,8 @@ export function EmailComposer({
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
title={t('schedule_send')}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
size="sm"
className="h-9 rounded-s-none px-2"
aria-haspopup="menu"
@@ -3296,15 +3333,23 @@ export function EmailComposer({
</Button>
{showSendMenu && (
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
{composerClient?.hasDelayedSend() && (
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
context={sendMenuContext}
onSelect={() => setShowSendMenu(false)}
separator={!!composerClient?.hasDelayedSend()}
/>
</div>
)}
</div>
@@ -3626,6 +3671,9 @@ export function EmailComposer({
</div>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
{wantsPluginDraft && (
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
)}
{/* Body */}
{plainTextMode ? (
<textarea
@@ -3684,6 +3732,9 @@ export function EmailComposer({
dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
/>
) : null}
{wantsPluginDraft && (
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
)}
</div>
</div>
@@ -3822,7 +3873,7 @@ export function EmailComposer({
className="hidden"
accept="*/*"
/>
{filesEnabled && composerClient?.supportsFiles?.() ? (
{(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
<ComposerToolbarMenu icon={Paperclip} label={t('attach')} testId="composer-attach-menu">
{(close) => <>
<ComposerToolbarMenuItem
@@ -3831,13 +3882,16 @@ export function EmailComposer({
>
{t('attach_from_device')}
</ComposerToolbarMenuItem>
<ComposerToolbarMenuItem
icon={FolderOpen}
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
testId="composer-attach-from-files"
>
{t('attach_from_files')}
</ComposerToolbarMenuItem>
{filesEnabled && composerClient?.supportsFiles?.() && (
<ComposerToolbarMenuItem
icon={FolderOpen}
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
testId="composer-attach-from-files"
>
{t('attach_from_files')}
</ComposerToolbarMenuItem>
)}
<ComposerPluginMenuItems surface="composer-attach-menu" context={{}} onSelect={close} separator />
</>}
</ComposerToolbarMenu>
) : (
@@ -3946,7 +4000,7 @@ export function EmailComposer({
>
{mvWindow ? <Trash2 className="w-5 h-5" /> : t('discard')}
</button>
{composerClient?.hasDelayedSend() ? (
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
<div ref={sendMenuRef} data-mv-send={mvWindow ? "" : undefined} className="relative hidden md:inline-flex">
<Button
onClick={() => handleSend()}
@@ -3962,7 +4016,8 @@ export function EmailComposer({
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
title={t('schedule_send')}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
className="rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
@@ -3974,15 +4029,23 @@ export function EmailComposer({
role="menu"
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
{composerClient?.hasDelayedSend() && (
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
context={sendMenuContext}
onSelect={() => setShowSendMenu(false)}
separator={!!composerClient?.hasDelayedSend()}
/>
</div>
)}
</div>
+20
View File
@@ -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 ? (
<>
<ContextMenuSeparator />
<PluginActions
surface="message-row-actions"
variant="menu"
context={rowPluginContext}
mailboxRole={currentMailboxRole ?? null}
onRan={onClose}
/>
</>
) : 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}
<ContextMenuSeparator />
{permalinkBlock}
{rowPluginBlock}
</>
) : (
<>
@@ -581,6 +600,7 @@ export function EmailContextMenu({
{spamBlock}
<ContextMenuSeparator />
{readItem}
{rowPluginBlock}
</>
)}
</>
+21 -2
View File
@@ -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<HoverAction, {
@@ -105,6 +108,7 @@ export function EmailHoverActions({
isInJunk = false,
onUndoSpam,
spamApplicable = true,
mailboxRole = null,
}: EmailHoverActionsProps) {
const configuredHoverActions = useSettingsStore((state) => 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({
</button>
);
});
if (pluginActionCount > 0) {
actionButtons.push(
<PluginActions
key="plugin-actions"
surface="message-row-actions"
context={pluginContext}
mailboxRole={mailboxRole}
compact
className="flex-nowrap gap-0.5"
/>,
);
}
const tagPicker =
tagPickerPos && onSetTag
+26
View File
@@ -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({
<Trash2 className="w-4 h-4" />
)}
</Button>
{hasSelection && (
<PluginActions
surface="message-bulk-actions"
context={bulkActionContext}
mailboxRole={effectiveMailboxRole ?? null}
compact
/>
)}
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
@@ -708,6 +731,8 @@ export function EmailList({
</div>
)}
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
@@ -739,6 +764,7 @@ export function EmailList({
<p className="text-sm mt-1 text-muted-foreground">
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
</div>
) : (
<>
+66 -3
View File
@@ -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<boolean>(() => {
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<HTMLDivElement | null>(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({
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
{/* Phones get these in the More panel. */}
{!isMobile && email && (
<PluginActions
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
compact={!showToolbarLabels}
/>
)}
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
@@ -4265,6 +4295,14 @@ export function EmailViewer({
{t('keyboard_shortcuts')}
</button>
)}
{email && (
<PluginPanelEntries
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
onRan={() => setMoreMenuOpen(false)}
/>
)}
</>
)}
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
@@ -4337,6 +4375,8 @@ export function EmailViewer({
</div>
)}
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* === SUBJECT BLOCK === */}
<div className={cn(
"bg-background",
@@ -4468,6 +4508,11 @@ export function EmailViewer({
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* Above the messages of the open conversation. */}
{mvOpenIndex >= 0 && (
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
)}
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
<div data-mv-conversation-before="">
@@ -4722,6 +4767,7 @@ export function EmailViewer({
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
@@ -4795,6 +4841,7 @@ export function EmailViewer({
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
@@ -4918,6 +4965,8 @@ export function EmailViewer({
</div>
</div>
<PluginSlot name="reader-below-subject" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* Expandable Details (shared across mobile/tablet/desktop) */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
@@ -5581,6 +5630,7 @@ export function EmailViewer({
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
@@ -5654,6 +5704,7 @@ export function EmailViewer({
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
@@ -5714,6 +5765,8 @@ export function EmailViewer({
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
@@ -5790,6 +5843,7 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
@@ -5839,7 +5893,7 @@ export function EmailViewer({
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
"email-content-wrapper overflow-x-auto relative",
// Folded, the body stays mounted so unfolding does not reload it.
(mvOpenFolded || mvAwaitingConversation) && "hidden",
// Mountain View indents the body under the sender's name, as Gmail does.
@@ -5871,9 +5925,10 @@ export function EmailViewer({
/>
) : (
<div
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
fontSize: '14px',
@@ -5883,6 +5938,14 @@ export function EmailViewer({
}}
/>
)}
{!isBodyLoading && (
<ReaderAboveQuoteSlot
root={effectiveEmailContent.isHtml ? (iframeReady ? iframeRef.current?.contentDocument?.body ?? null : null) : plainBodyEl}
frame={effectiveEmailContent.isHtml ? iframeRef.current : null}
extraProps={pluginEmailProps}
version={effectiveEmailContent.html}
/>
)}
</div>
<PluginSlot name="email-footer" />
+8
View File
@@ -31,6 +31,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
interface MvListToolbarProps {
/** Loaded conversation count, for the right-hand range readout. */
@@ -606,6 +607,13 @@ export function MvListToolbar({
</div>
)}
</div>
<PluginActions
surface="message-bulk-actions"
context={{ emailIds: selectedEmails.map((email) => email.id) }}
mailboxRole={currentRole ?? null}
compact
className="shrink-0"
/>
<span className="ms-2 text-sm text-muted-foreground truncate">
{tBatch("selected_messages", { count: selectionCount })}
</span>
+251
View File
@@ -0,0 +1,251 @@
"use client";
// Plugin surfaces of the open message that need more than a one-line mount:
// the attachment menu, plugin entries in the phone "More" panel, and the
// frame between a reply's own text and the quote under it.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
import { ContextMenu } from "@/components/ui/context-menu";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { useContextMenu } from "@/hooks/use-context-menu";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { QUOTE_TOGGLE_ATTR } from "@/lib/quote-collapse";
import { cn } from "@/lib/utils";
// ─── attachment-menu ─────────────────────────────────────────
export interface AttachmentMenuTarget {
/** Parts without a blob (decrypted, unpacked from TNEF) get no menu: a plugin couldn't fetch them. */
blobId?: string;
name?: string | null;
type: string;
size: number;
}
/**
* A "more" button on an attachment that opens the entries plugins declared
* for `attachment-menu`, filtered by the attachment's MIME type. Nothing is
* drawn when no plugin has an entry for this type, or the part has no blob.
*/
export function AttachmentPluginMenu({
emailId,
attachment,
className,
iconClassName = "w-4 h-4",
}: {
emailId: string | undefined;
attachment: AttachmentMenuTarget;
className?: string;
iconClassName?: string;
}) {
const t = useTranslations("email_viewer");
const actions = usePluginActions("attachment-menu", { mimeType: attachment.type });
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
const context = useMemo(() => ({
emailId,
blobId: attachment.blobId,
name: attachment.name || "",
type: attachment.type,
size: attachment.size,
}), [emailId, attachment.blobId, attachment.name, attachment.type, attachment.size]);
if (actions.length === 0 || !emailId || !attachment.blobId) return null;
return (
<>
<button
type="button"
className={cn("p-1 hover:bg-accent rounded transition-colors", className)}
title={t("more_actions")}
aria-label={t("more_actions")}
aria-haspopup="menu"
aria-expanded={contextMenu.isOpen}
data-attachment-plugin-menu=""
onClick={(e) => openContextMenu(e, true)}
>
<MoreHorizontal className={cn(iconClassName, "text-foreground")} />
</button>
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<PluginActions
surface="attachment-menu"
variant="menu"
context={context}
mimeType={attachment.type}
onRan={closeContextMenu}
/>
</ContextMenu>
</>
);
}
// ─── 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<string, unknown>;
mailboxRole?: string | null;
onRan?: () => void;
}) {
const actions = usePluginActions(surface, { mailboxRole });
if (actions.length === 0) return null;
return (
<>
<div className="h-px bg-border my-1" />
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
return (
<button
key={`${action.pluginId}:${action.id}`}
role="menuitem"
title={action.pluginName}
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
className={cn(
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
action.danger ? "text-destructive" : "text-foreground",
)}
>
<Icon className="w-5 h-5" />
{action.label}
</button>
);
})}
</>
);
}
// ─── 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 <details> 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<HTMLElement>(`[${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<string, unknown>;
version?: unknown;
}) {
const offered = usePluginSlotOffers("reader-above-quote").length > 0;
const [anchor, setAnchor] = useState<HTMLElement | null>(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 = <PluginSlot name="reader-above-quote" className="w-full" extraProps={extraProps} />;
if (!frame || anchor.ownerDocument === document) {
return createPortal(slot, anchor);
}
return <QuoteOverlay anchor={anchor} frame={frame}>{slot}</QuoteOverlay>;
}
function QuoteOverlay({ anchor, frame, children }: { anchor: HTMLElement; frame: HTMLIFrameElement; children: React.ReactNode }) {
const overlayRef = useRef<HTMLDivElement>(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 (
<div
ref={overlayRef}
data-plugin-quote-overlay=""
className="absolute z-[1]"
style={box ? { top: box.top, left: box.left, width: box.width } : { top: 0, left: 0, right: 0, visibility: "hidden" }}
>
{children}
</div>
);
}
+39 -4
View File
@@ -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<Set<string>>(new Set());
@@ -242,6 +251,7 @@ export function ThreadConversationView({
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => (
<EmailCard
@@ -467,6 +477,14 @@ export function ConversationEmailBody({
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
const { client } = useAuthStore();
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(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() : ''}
</style></head><body dir="auto">${emailContent.html}</body></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 */}
<div style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
@@ -740,6 +766,7 @@ export function ConversationEmailBody({
/>
) : (
<div
ref={setPlainBodyEl}
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
@@ -749,9 +776,15 @@ export function ConversationEmailBody({
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
dangerouslySetInnerHTML={plainBodyHtml}
/>
)}
<ReaderAboveQuoteSlot
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
frame={emailContent.isHtml ? iframeRef.current : null}
extraProps={quoteSlotProps}
version={emailContent.html}
/>
</div>
{/* Attachments */}
@@ -764,8 +797,8 @@ export function ConversationEmailBody({
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<React.Fragment key={idx}>
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
@@ -784,6 +817,8 @@ export function ConversationEmailBody({
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
<AttachmentPluginMenu emailId={email.id} attachment={attachment} className="-ms-1.5 self-center p-2 rounded-lg" />
</React.Fragment>
);
})}
</div>
+2
View File
@@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
@@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
@@ -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<string>();
/** 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<string, unknown> }) {
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<string, unknown> {
return JSON.parse(el.getAttribute('data-props') ?? '{}');
}
+1 -1
View File
@@ -34,7 +34,7 @@ function isMounted(name: string): boolean {
// that finishes an area removes it here; the test then insists every
// surface in it has a mount site.
const NOT_YET_MOUNTED = new Set<SlotArea>([
'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings',
'app', 'navigation', 'settings',
]);
describe('slot catalogue', () => {