diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx new file mode 100644 index 000000000..34531bae9 --- /dev/null +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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: '

Hello there

', + 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(); + + 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: '

Hello there

', 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(); + 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(); + 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(); + + // 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(); + + 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(); + 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(); + + 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(); + expect(screen.queryByTestId('composer-attach-menu')).toBeNull(); + expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument(); + }); +}); 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/__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/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index bba4b502c..ffa1f510e 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -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 ); } + +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 | (() => Record); + /** 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 &&
} + {actions.map((action) => { + const Icon = iconForName(action.icon) ?? Puzzle; + return ( + + ); + })} + + ); +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 21a5fe855..b3a7828cd 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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 | 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({
{/* Mobile: keep scheduled send reachable without consuming footer space. */} - {composerClient?.hasDelayedSend() ? ( + {composerClient?.hasDelayedSend() || hasSendMenuActions ? (
{showSendMenu && (
- + {composerClient?.hasDelayedSend() && ( + + )} + setShowSendMenu(false)} + separator={!!composerClient?.hasDelayedSend()} + />
)}
@@ -3626,6 +3671,9 @@ export function EmailComposer({
+ {wantsPluginDraft && ( + + )} {/* Body */} {plainTextMode ? (