| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666 |
- import { App as AntApp, ConfigProvider } from 'antd';
- import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { createMemoryRouter, RouterProvider } from 'react-router-dom';
- import { afterEach, describe, expect, it, vi } from 'vitest';
- import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
- import { I18nProvider } from '../../src/frontend/i18n/react';
- import { detailHistoryState } from '../../src/frontend/navigation-state';
- import { api } from '../../src/frontend/services/api';
- import { mailhubTheme } from '../../src/frontend/theme';
- import type { Domain, InboundMailbox, InboundMessage, RuntimeConfig, WebmailLogin } from '../../src/frontend/types';
- import Inbox from '../../src/pages/Inbox';
- describe('Inbox detail return path', () => {
- afterEach(() => vi.restoreAllMocks());
- it('keeps the original list history when another message is selected from an open detail', async () => {
- const user = userEvent.setup();
- const first = messageFixture(9, 1, 'Sent', 'First message');
- const second = messageFixture(10, 1, 'Sent', 'Second message');
- mockInboxApis([mailboxFixture(1)], [first, second]);
- vi.spyOn(api, 'inboundMessage').mockImplementation(async (id) => ({ message: id === second.id ? second : first }));
- const listPath = '/inbox?mailboxId=1&folder=Sent&page=2';
- const router = createInboxRouter(['/overview', listPath], 1);
- renderRouter(router);
- await user.click(await screen.findByRole('button', { name: 'First message · sender@example.test' }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
- expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 1, origin: 'list' });
- fireEvent.click(screen.getByRole('button', { name: 'Second message · sender@example.test' }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/10'));
- expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 2, origin: 'list' });
- fireEvent.click(screen.getByRole('button', { name: 'Close' }));
- await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
- await act(async () => {
- await router.navigate(-1);
- });
- expect(router.state.location.pathname).toBe('/overview');
- });
- it('keeps direct detail tabs navigable and closes them without reopening on Back', async () => {
- const user = userEvent.setup();
- const deepLinked = messageFixture(20, 2, 'Archive', 'Archived message');
- const messages = vi.fn(async () => ({ messages: [deepLinked], total: 1, page: 1, pageSize: 25 }));
- mockInboxApis([mailboxFixture(1), mailboxFixture(2)], [deepLinked]);
- vi.spyOn(api, 'inboundMessages').mockImplementation(messages);
- vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
- const router = createInboxRouter(['/overview', '/inbox/messages/20'], 1);
- renderRouter(router);
- expect(await screen.findByText('Archived message')).toBeTruthy();
- await waitFor(() => {
- const params = new URLSearchParams(router.state.location.search);
- expect(params.get('mailboxId')).toBe('2');
- expect(params.get('folder')).toBe('Archive');
- });
- await waitFor(() => expect(messages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2, folder: 'Archive' })));
- await user.click(screen.getByRole('tab', { name: 'HTML 预览' }));
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
- expect(detailHistoryState(router.state.location.state)).toEqual({
- listPath: '/inbox?mailboxId=2&folder=Archive',
- depth: 1,
- origin: 'direct'
- });
- await act(async () => {
- await router.navigate(-1);
- });
- expect(router.state.location.pathname).toBe('/inbox/messages/20');
- expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
- expect(detailHistoryState(router.state.location.state)).toBeNull();
- await act(async () => {
- await router.navigate(1);
- });
- expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
- expect(detailHistoryState(router.state.location.state)?.origin).toBe('direct');
- fireEvent.click(screen.getByRole('button', { name: 'Close' }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
- const params = new URLSearchParams(router.state.location.search);
- expect(params.get('mailboxId')).toBe('2');
- expect(params.get('folder')).toBe('Archive');
- await act(async () => {
- await router.navigate(-1);
- });
- expect(router.state.location.pathname).toBe('/overview');
- expect(router.state.location.pathname).not.toContain('/messages/');
- });
- it('uses a detail drawer below the 1280px product breakpoint', async () => {
- vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)', query));
- const deepLinked = messageFixture(30, 1, 'INBOX', 'Compact detail');
- mockInboxApis([mailboxFixture(1)], [deepLinked]);
- vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
- const router = createInboxRouter(['/inbox/messages/30'], 0);
- renderRouter(router);
- expect(await screen.findByRole('button', { name: 'Close' })).toBeTruthy();
- });
- it('renders HTML-only messages in a sandboxed preview while keeping the source tab available', async () => {
- const user = userEvent.setup();
- const htmlOnly = {
- ...messageFixture(31, 1, 'INBOX', 'HTML only'),
- textBody: '',
- htmlBody: '<html><body><h1>Rendered invoice</h1><script>window.parent.pwned = true</script></body></html>'
- };
- mockInboxApis([mailboxFixture(1)], [htmlOnly]);
- vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: htmlOnly });
- const router = createInboxRouter(['/inbox/messages/31?mailboxId=1&folder=INBOX'], 0);
- renderRouter(router);
- const preview = await screen.findByTitle('HTML 预览') as HTMLIFrameElement;
- expect(screen.getByRole('tab', { name: 'HTML 预览' }).getAttribute('aria-selected')).toBe('true');
- expect(preview.getAttribute('sandbox')).toBe('');
- expect(preview.getAttribute('referrerpolicy')).toBe('no-referrer');
- expect(preview.getAttribute('srcdoc')).toContain('<h1>Rendered invoice</h1>');
- expect(preview.getAttribute('srcdoc')).toContain("script-src 'none'");
- await user.click(screen.getByRole('tab', { name: 'HTML 源码' }));
- expect(screen.getByText(/Rendered invoice/)).toBeTruthy();
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('source'));
- });
- });
- describe('Inbox request and mailbox behavior', () => {
- afterEach(() => vi.restoreAllMocks());
- it('keeps the search draft synchronized with browser history', async () => {
- mockInboxApis([mailboxFixture(1)], []);
- const router = createInboxRouter([
- '/inbox?mailboxId=1&folder=INBOX&q=first%20query',
- '/inbox?mailboxId=1&folder=INBOX&q=second%20query'
- ], 1);
- renderRouter(router);
- const searchInput = await screen.findByPlaceholderText('搜索发件人、主题或正文预览') as HTMLInputElement;
- expect(searchInput.value).toBe('second query');
- await act(async () => {
- await router.navigate(-1);
- });
- await waitFor(() => expect(searchInput.value).toBe('first query'));
- await act(async () => {
- await router.navigate(1);
- });
- await waitFor(() => expect(searchInput.value).toBe('second query'));
- });
- it('does not let an older message-list response replace the current mailbox', async () => {
- const first = messageFixture(41, 1, 'INBOX', 'Stale mailbox message');
- const second = messageFixture(42, 2, 'INBOX', 'Current mailbox message');
- let resolveFirst: ((value: { messages: InboundMessage[]; total: number; page: number; pageSize: number }) => void) | undefined;
- const firstResponse = new Promise<{ messages: InboundMessage[]; total: number; page: number; pageSize: number }>((resolve) => {
- resolveFirst = resolve;
- });
- mockInboxApis([mailboxFixture(1), mailboxFixture(2)], []);
- const list = vi.spyOn(api, 'inboundMessages').mockImplementation(async (filters) => {
- const mailboxId = typeof filters === 'number' ? filters : filters?.mailboxId;
- if (mailboxId === 1) return firstResponse;
- return { messages: [second], total: 1, page: 1, pageSize: 25 };
- });
- const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
- renderRouter(router);
- await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 })));
- await act(async () => {
- await router.navigate('/inbox?mailboxId=2&folder=INBOX');
- });
- expect(await screen.findByText('Current mailbox message')).toBeTruthy();
- await act(async () => {
- resolveFirst?.({ messages: [first], total: 1, page: 1, pageSize: 25 });
- await Promise.resolve();
- });
- expect(screen.queryByText('Stale mailbox message')).toBeNull();
- expect(screen.getByText('Current mailbox message')).toBeTruthy();
- });
- it('keeps message content visible when marking it as read fails', async () => {
- const unread = { ...messageFixture(50, 1, 'INBOX', 'Unread detail'), read: false };
- mockInboxApis([mailboxFixture(1, { unreadCount: 1 })], [unread]);
- vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: unread });
- vi.spyOn(api, 'markInboundMessageRead').mockRejectedValue(new Error('mark read failed'));
- const router = createInboxRouter(['/inbox/messages/50?mailboxId=1&folder=INBOX'], 0);
- renderRouter(router);
- expect(await screen.findByText('Unread detail body')).toBeTruthy();
- expect(await screen.findByText('mark read failed')).toBeTruthy();
- });
- it('shows unavailable folder counts and retries instead of displaying fake zeroes', async () => {
- vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)' || query === '(min-width: 768px)', query));
- mockInboxApis([mailboxFixture(1)], []);
- const folders = vi.spyOn(api, 'inboundFolders')
- .mockRejectedValueOnce(new Error('folder request failed'))
- .mockResolvedValue({ folders: [{ name: 'INBOX', specialUse: null, messageCount: 3, unreadCount: 2 }] });
- const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
- renderRouter(router);
- const warning = await screen.findByText('文件夹计数暂不可用。');
- expect(warning).toBeTruthy();
- expect(screen.getAllByLabelText('计数不可用').length).toBeGreaterThan(0);
- const alert = warning.closest('.ant-alert');
- expect(alert).toBeTruthy();
- await userEvent.click(within(alert as HTMLElement).getByRole('button', { name: /刷\s*新/ }));
- await waitFor(() => expect(folders).toHaveBeenCalledTimes(2));
- await waitFor(() => expect(screen.queryByText('文件夹计数暂不可用。')).toBeNull());
- });
- it('selects the mailbox with the most recent activity when the URL has no mailbox', async () => {
- const older = mailboxFixture(1, { lastMessageAt: '2026-07-14T00:00:00.000Z' });
- const recent = mailboxFixture(2, { lastMessageAt: '2026-07-15T00:00:00.000Z' });
- mockInboxApis([older, recent], []);
- const list = vi.spyOn(api, 'inboundMessages');
- const router = createInboxRouter(['/inbox'], 0);
- renderRouter(router);
- await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
- expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2');
- });
- it('uses effective mailbox access and never loads messages for view-only assignments', async () => {
- const viewOnly = mailboxFixture(1, {
- userId: 2,
- ownerUserId: 2,
- access: { type: 'assigned', permissions: { view: true, receive: false, send: false } },
- messageCount: null,
- unreadCount: null,
- lastMessageAt: null
- });
- const receiving = mailboxFixture(2, {
- userId: 2,
- ownerUserId: 2,
- access: { type: 'assigned', permissions: { view: true, receive: true, send: false } }
- });
- mockInboxApis([viewOnly, receiving], []);
- const loadMailboxes = vi.spyOn(api, 'inboundMailboxes');
- const listMessages = vi.spyOn(api, 'inboundMessages');
- const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
- renderRouter(router);
- await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2'));
- await waitFor(() => expect(listMessages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
- expect(listMessages).not.toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 }));
- });
- it('offers the Webmail shortcut in mailbox routing only to owners and assigned receivers', async () => {
- const user = userEvent.setup();
- const owner = mailboxFixture(1);
- const assigned = mailboxFixture(2, {
- userId: 2,
- ownerUserId: 2,
- access: { type: 'assigned', permissions: { view: true, receive: true, send: false } }
- });
- const viewOnly = mailboxFixture(3, {
- userId: 2,
- ownerUserId: 2,
- access: { type: 'assigned', permissions: { view: true, receive: false, send: false } },
- messageCount: null,
- unreadCount: null
- });
- mockInboxApis([owner, assigned, viewOnly], []);
- const webmailLogin: WebmailLogin = {
- action: 'https://mail.us.ss5.xyz/',
- ticket: 'mht_routing-ticket',
- expiresAt: '2026-07-18T01:00:00.000Z'
- };
- const login = vi.spyOn(api, 'createWebmailLogin').mockResolvedValue({ webmailLogin });
- const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => undefined);
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const ownerCard = (await screen.findByText(owner.address)).closest('.ant-card') as HTMLElement;
- const assignedCard = screen.getByText(assigned.address).closest('.ant-card') as HTMLElement;
- const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
- expect(within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ })).toBeTruthy();
- expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
- await user.click(within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ }));
- await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
- expect(requestSubmit).toHaveBeenCalledTimes(1);
- });
- it('updates mailbox settings from the routing workspace edit drawer', async () => {
- const user = userEvent.setup();
- const mailbox = mailboxFixture(1);
- mockInboxApis([mailbox], [], [domainFixture(1)]);
- const update = vi.spyOn(api, 'updateInboundMailbox').mockResolvedValue({
- mailbox: { ...mailbox, displayName: 'Updated inbox', aliases: ['sales'] }
- });
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const mailboxTitle = await screen.findByText(mailbox.address);
- const card = mailboxTitle.closest('.ant-card');
- expect(card).toBeTruthy();
- await user.click(within(card as HTMLElement).getByRole('button', { name: /修改/ }));
- const displayName = await screen.findByLabelText('显示名称');
- await user.clear(displayName);
- await user.type(displayName, 'Updated inbox');
- await user.type(screen.getByLabelText('新密码'), 'new-password-123');
- await user.clear(screen.getByLabelText('别名'));
- await user.type(screen.getByLabelText('别名'), 'sales');
- await user.click(screen.getByRole('button', { name: /保\s*存/ }));
- await waitFor(() => expect(update).toHaveBeenCalledWith(1, expect.objectContaining({
- displayName: 'Updated inbox',
- password: 'new-password-123',
- aliases: 'sales',
- status: 'active'
- })));
- });
- it('migrates mail before deleting an owned mailbox and excludes shared mailboxes as targets', async () => {
- const user = userEvent.setup();
- const source = mailboxFixture(1, { messageCount: 5 });
- const target = mailboxFixture(2, { messageCount: 1 });
- const assigned = mailboxFixture(3, {
- ownerUserId: 1,
- access: { type: 'assigned', permissions: { view: true, receive: true, send: true } }
- });
- const disabled = mailboxFixture(4, { status: 'disabled' });
- const expired = mailboxFixture(5, {
- temporary: true,
- expiresAt: '2000-01-01T00:00:00.000Z'
- });
- mockInboxApis([source, target, assigned, disabled, expired], []);
- const loadMailboxes = vi.mocked(api.inboundMailboxes);
- let resolveDelete!: (value: {
- deletedMailbox: InboundMailbox;
- targetMailbox: InboundMailbox;
- migratedMessageCount: number;
- }) => void;
- const remove = vi.spyOn(api, 'deleteInboundMailbox').mockReturnValue(new Promise((resolve) => {
- resolveDelete = resolve;
- }));
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
- const assignedCard = screen.getByText(assigned.address).closest('.ant-card') as HTMLElement;
- expect(within(assignedCard).queryByRole('button', { name: `删除邮箱 ${assigned.address}` })).toBeNull();
- await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
- const dialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
- expect(within(dialog).getByText(source.address)).toBeTruthy();
- expect(within(dialog).getByText('5')).toBeTruthy();
- const destructiveButton = within(dialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement;
- expect(destructiveButton.disabled).toBe(true);
- await user.click(within(dialog).getByRole('combobox', { name: '目标邮箱' }));
- const listbox = await screen.findByRole('listbox');
- const targetLabel = `${target.address} · 1 封邮件`;
- expect(within(listbox).getByRole('option', { name: targetLabel })).toBeTruthy();
- expect(within(listbox).queryByRole('option', { name: new RegExp(source.address) })).toBeNull();
- expect(within(listbox).queryByRole('option', { name: new RegExp(assigned.address) })).toBeNull();
- expect(within(listbox).queryByRole('option', { name: new RegExp(disabled.address) })).toBeNull();
- expect(within(listbox).queryByRole('option', { name: new RegExp(expired.address) })).toBeNull();
- await user.click(screen.getByText(targetLabel));
- expect(destructiveButton.disabled).toBe(true);
- const confirmation = within(dialog).getByRole('textbox', { name: '输入完整邮箱地址确认' });
- await user.type(confirmation, 'wrong@example.test');
- expect(within(dialog).getByText('输入的邮箱地址与待删除邮箱不一致。')).toBeTruthy();
- expect(destructiveButton.disabled).toBe(true);
- await user.clear(confirmation);
- await user.type(confirmation, source.address);
- expect(destructiveButton.disabled).toBe(false);
- await user.click(destructiveButton);
- await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
- targetMailboxId: target.id,
- confirmAddress: source.address
- }));
- await waitFor(() => expect(destructiveButton.classList.contains('ant-btn-loading')).toBe(true));
- fireEvent.click(destructiveButton);
- expect(remove).toHaveBeenCalledTimes(1);
- await act(async () => {
- resolveDelete({ deletedMailbox: source, targetMailbox: target, migratedMessageCount: 5 });
- await Promise.resolve();
- });
- expect(await screen.findByText(`邮箱已删除,已将 5 封邮件迁移到 ${target.address}。`)).toBeTruthy();
- await waitFor(() => expect(loadMailboxes).toHaveBeenCalledTimes(2));
- expect(screen.queryByRole('dialog', { name: '迁移邮件并删除邮箱' })).toBeNull();
- });
- it('blocks deletion when no other owned mailbox can receive the messages', async () => {
- const user = userEvent.setup();
- const source = mailboxFixture(1);
- const assigned = mailboxFixture(2, {
- access: { type: 'assigned', permissions: { view: true, receive: true, send: true } }
- });
- mockInboxApis([source, assigned], []);
- const remove = vi.spyOn(api, 'deleteInboundMailbox');
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
- await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
- const dialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
- expect(within(dialog).getByText('没有其他可管理的邮箱。请先创建一个目标邮箱,再删除当前邮箱。')).toBeTruthy();
- expect((within(dialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement).disabled).toBe(true);
- expect(remove).not.toHaveBeenCalled();
- });
- it('deletes an empty owned mailbox without requiring a migration target', async () => {
- const user = userEvent.setup();
- const source = mailboxFixture(1, { messageCount: 0 });
- mockInboxApis([source], []);
- const remove = vi.spyOn(api, 'deleteInboundMailbox').mockResolvedValue({
- deletedMailbox: source,
- targetMailbox: null,
- migratedMessageCount: 0
- });
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
- await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
- const dialog = await screen.findByRole('dialog', { name: '删除邮箱' });
- expect(within(dialog).getByText('此邮箱中没有邮件,可以直接停用并移除邮箱账号。')).toBeTruthy();
- expect(within(dialog).queryByRole('combobox', { name: '目标邮箱' })).toBeNull();
- const destructiveButton = within(dialog).getByRole('button', { name: '删除邮箱' }) as HTMLButtonElement;
- expect(destructiveButton.disabled).toBe(true);
- await user.type(
- within(dialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }),
- source.address
- );
- expect(destructiveButton.disabled).toBe(false);
- await user.click(destructiveButton);
- await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
- targetMailboxId: null,
- confirmAddress: source.address
- }));
- expect(await screen.findByText('邮箱已删除')).toBeTruthy();
- });
- it('refreshes an empty mailbox deletion dialog when mail arrives before deletion', async () => {
- const user = userEvent.setup();
- const source = mailboxFixture(1, { messageCount: 0 });
- const refreshedSource = mailboxFixture(1, { messageCount: 1 });
- const target = mailboxFixture(2, { messageCount: 0 });
- mockInboxApis([source, target], []);
- const loadMailboxes = vi.mocked(api.inboundMailboxes)
- .mockResolvedValueOnce({ mailboxes: [source, target] })
- .mockResolvedValue({ mailboxes: [refreshedSource, target] });
- const remove = vi.spyOn(api, 'deleteInboundMailbox').mockRejectedValue(
- new Error('Mailbox now contains messages and requires a migration target.')
- );
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
- await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
- const emptyDialog = await screen.findByRole('dialog', { name: '删除邮箱' });
- await user.type(
- within(emptyDialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }),
- source.address
- );
- await user.click(within(emptyDialog).getByRole('button', { name: '删除邮箱' }));
- await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
- targetMailboxId: null,
- confirmAddress: source.address
- }));
- await waitFor(() => expect(loadMailboxes).toHaveBeenCalledTimes(2));
- const refreshedDialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
- expect(within(refreshedDialog).getByRole('combobox', { name: '目标邮箱' })).toBeTruthy();
- expect((within(refreshedDialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }) as HTMLInputElement).value)
- .toBe(source.address);
- expect((within(refreshedDialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement).disabled)
- .toBe(true);
- });
- it('groups owned and shared domains when creating a mailbox', async () => {
- const user = userEvent.setup();
- const ownedDomain = domainFixture(1);
- const sharedDomain = { ...domainFixture(2), userId: 2, mailboxSignupEnabled: true };
- mockInboxApis([], [], [ownedDomain], [ownedDomain, sharedDomain]);
- const router = createInboxRouter(['/inbox?workspace=routing'], 0);
- renderRouter(router);
- const createButtons = await screen.findAllByRole('button', { name: /新增收信邮箱/ });
- await user.click(createButtons[0]);
- await user.click(screen.getByRole('combobox', { name: '域名' }));
- expect((await screen.findAllByText('我的域名')).length).toBeGreaterThan(0);
- expect(screen.getAllByText('共享域名').length).toBeGreaterThan(0);
- expect(screen.getAllByText(`@${ownedDomain.domain}`).length).toBeGreaterThan(0);
- expect(screen.getAllByText(`@${sharedDomain.domain}`).length).toBeGreaterThan(0);
- });
- });
- function mediaQueryList(matches: boolean, media: string): MediaQueryList {
- return {
- matches,
- media,
- onchange: null,
- addListener: () => undefined,
- removeListener: () => undefined,
- addEventListener: () => undefined,
- removeEventListener: () => undefined,
- dispatchEvent: () => false
- };
- }
- function createInboxRouter(initialEntries: string[], initialIndex: number) {
- return createMemoryRouter([
- { path: '/inbox', element: <Inbox /> },
- { path: '/inbox/messages/:messageId', element: <Inbox /> },
- { path: '*', element: <div>other</div> }
- ], { initialEntries, initialIndex });
- }
- function renderRouter(router: ReturnType<typeof createInboxRouter>) {
- return render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={appContext}>
- <RouterProvider router={router} />
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- }
- function mockInboxApis(
- mailboxes: InboundMailbox[],
- messages: InboundMessage[],
- domains: Domain[] = [],
- mailboxDomains: Domain[] = domains
- ) {
- vi.spyOn(api, 'domains').mockResolvedValue({ domains });
- vi.spyOn(api, 'inboundMailboxDomains').mockResolvedValue({
- domains: mailboxDomains.map(({ id, userId, domain, mailboxSignupEnabled }) => ({
- id,
- userId,
- domain,
- mailboxSignupEnabled
- }))
- });
- vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
- vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
- folders: [
- { name: 'INBOX', specialUse: null, messageCount: 0, unreadCount: 0 },
- { name: 'Sent', specialUse: '\\Sent', messageCount: mailboxId === 1 ? messages.length : 0, unreadCount: 0 },
- { name: 'Archive', specialUse: '\\Archive', messageCount: mailboxId === 2 ? messages.length : 0, unreadCount: 0 }
- ]
- }));
- vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages, total: messages.length, page: 1, pageSize: 25 });
- }
- function domainFixture(id: number): Domain {
- return {
- id,
- userId: 1,
- ownerUserId: 1,
- dnsCredentialId: null,
- smtpRelayId: null,
- domain: `example-${id}.test`,
- selector: 'mail',
- verificationToken: 'verification',
- dkimPublic: 'public-key',
- senderHost: `mail.example-${id}.test`,
- sendingIp: '192.0.2.10',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: '',
- catchAllAddress: '',
- mailboxSignupEnabled: false,
- status: {},
- createdAt: '2026-07-14T00:00:00.000Z',
- updatedAt: '2026-07-14T00:00:00.000Z'
- };
- }
- function mailboxFixture(id: number, overrides: Partial<InboundMailbox> = {}): InboundMailbox {
- return {
- id,
- userId: 1,
- domainId: id,
- domain: `example-${id}.test`,
- address: `inbox-${id}@example-${id}.test`,
- localPart: `inbox-${id}`,
- displayName: `Inbox ${id}`,
- aliases: [],
- forwardTo: [],
- keepForwarded: true,
- quotaMb: 1024,
- passwordSet: true,
- passwordRecoverable: false,
- status: 'active',
- messageCount: 2,
- unreadCount: 0,
- access: { type: 'owner', permissions: { view: true, receive: true, send: true } },
- createdAt: '2026-07-14T00:00:00.000Z',
- updatedAt: '2026-07-14T00:00:00.000Z',
- ...overrides
- };
- }
- function messageFixture(id: number, mailboxId: number, folder: string, subject: string): InboundMessage {
- return {
- id,
- mailboxId,
- userId: 1,
- domainId: mailboxId,
- domain: `example-${mailboxId}.test`,
- mailboxAddress: `inbox-${mailboxId}@example-${mailboxId}.test`,
- folder,
- sender: 'sender@example.test',
- recipients: [`inbox-${mailboxId}@example-${mailboxId}.test`],
- subject,
- messageId: `<message-${id}@example.test>`,
- preview: `${subject} preview`,
- read: true,
- receivedAt: '2026-07-14T00:00:00.000Z',
- createdAt: '2026-07-14T00:00:00.000Z',
- updatedAt: '2026-07-14T00:00:00.000Z',
- textBody: `${subject} body`,
- htmlBody: `<p>${subject}</p>`,
- rawMessage: `Subject: ${subject}`
- };
- }
- const runtimeConfig: RuntimeConfig = {
- appBaseUrl: 'https://mail.example.test',
- webmailSsoEnabled: true,
- mailHostname: 'mail.example.test',
- sendingIp: '192.0.2.10',
- defaultSpfMechanisms: '',
- dmarcPolicy: 'none',
- dmarcRua: '',
- registrationRequiresApproval: false,
- sendRequiresVerified: true,
- engagementTrackingEnabled: true,
- listUnsubscribeMailto: '',
- listUnsubscribeUrl: '',
- listUnsubscribePostEnabled: false,
- feedbackIdEnabled: false,
- reportAbuseTo: '',
- csaComplaintsTo: '',
- bounceAddress: '',
- bounceEnvelopeEnabled: false
- };
- const appContext: AppContextValue = {
- user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
- config: runtimeConfig,
- refreshBootstrap: vi.fn(async () => undefined),
- logout: vi.fn(async () => undefined)
- };
|