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: '

Rendered invoice

' }; 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('

Rendered invoice

'); 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('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: }, { path: '/inbox/messages/:messageId', element: }, { path: '*', element:
other
} ], { initialEntries, initialIndex }); } function renderRouter(router: ReturnType) { return render( ); } 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 { 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: ``, 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: `

${subject}

`, 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) };