Files
webmail/components/email/__tests__/email-context-menu.test.tsx
T
daluchenandClaude Code 5dde5e00e1 feat: add batch forward-as-attachment for selected emails
Multi-select emails in the list, right-click, and pick "Forward as
attachments" to open one composer carrying every selected message as
its own message/rfc822 (.eml) attachment - the batch sibling of the
existing single-message action.

- lib/forward-as-attachment.ts: buildBatchForwardAsAttachmentPayload
  builds one payload from many emails. The subject is count-based
  (there is no single original subject), with the count localized via
  an injected label so the subject never claims more attachments than
  the composer carries. Emails without a blobId are skipped; null when
  none has one, matching the single flow's silent no-op.
- mail-app.tsx: handleBatchForwardAsAttachment resolves the selection
  through useEmailStore.getState() (fresh state, list-ordered
  attachments, stale ids drop out), runs the plugin transform and veto
  hooks per email - one veto cancels the whole compose - and opens the
  composer with the attachments seeded via replyTo. Clears the
  selection only on success.
- email-context-menu.tsx / email-list.tsx: new batch menu item shown
  when the right-clicked email belongs to a multi-selection; hidden in
  the scheduled view like the single item.
- locales: context_menu.forward_as_attachments and
  email_composer.forward_batch_count in all 27 locales.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 14:50:31 +08:00

102 lines
3.9 KiB
TypeScript

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
// The menu's only far-reaching child - the plugin host iframe slot. It is
// never what these tests are about, so stub it out.
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
// vitest.setup.ts mocks next-intl's useTranslations to return the key itself,
// so labels render as raw keys ("forward_as_attachments" etc.).
import { EmailContextMenu } from '../email-context-menu';
const email: Email = {
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
hasAttachment: false,
size: 100,
} as unknown as Email;
const mailboxes: Mailbox[] = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
type MenuProps = React.ComponentProps<typeof EmailContextMenu>;
function renderMenu(props: Partial<MenuProps> = {}) {
const onClose = vi.fn();
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={onClose}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
{...props}
/>,
);
return { onClose };
}
describe('EmailContextMenu batch forward as attachment', () => {
it('shows the batch header and batch forward item when the right-clicked email is in a multi-selection', () => {
renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() });
expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeInTheDocument();
expect(screen.getByText('items_selected')).toBeInTheDocument();
});
it('clicking the batch item invokes onBatchForwardAsAttachment and closes the menu', () => {
const onBatchForwardAsAttachment = vi.fn();
const { onClose } = renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment });
fireEvent.click(screen.getByTestId('ctx-batch-forward-as-attachment'));
expect(onBatchForwardAsAttachment).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('hides the single-message forward-as-attachment item in batch mode', () => {
renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() });
// vitest's next-intl mock renders labels as raw keys; the singular key
// must be absent while the plural batch key is present.
expect(screen.queryByText('forward_as_attachment')).toBeNull();
expect(screen.getByText('forward_as_attachments')).toBeInTheDocument();
});
it('hides the batch item when only one email is selected', () => {
renderMenu({ isMultiSelect: true, selectedCount: 1, onBatchForwardAsAttachment: vi.fn() });
expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull();
});
it('hides the batch item when the right-clicked email is outside the selection', () => {
renderMenu({ isMultiSelect: false, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() });
expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull();
});
it('hides the batch item for scheduled emails', () => {
const scheduled = { ...email, isScheduled: true } as unknown as Email;
renderMenu({
email: scheduled,
isMultiSelect: true,
selectedCount: 3,
onBatchForwardAsAttachment: vi.fn(),
});
expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull();
});
it('disables the batch item when no handler is passed', () => {
renderMenu({ isMultiSelect: true, selectedCount: 3 });
expect((screen.getByTestId('ctx-batch-forward-as-attachment') as HTMLButtonElement).disabled).toBe(true);
});
});