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