| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666 |
- import {
- ContainerOutlined,
- CopyOutlined,
- DeleteOutlined,
- FileTextOutlined,
- FolderOutlined,
- InboxOutlined,
- MailOutlined,
- PlusOutlined,
- ReloadOutlined,
- SearchOutlined,
- SendOutlined,
- SettingOutlined,
- WarningOutlined
- } from '@ant-design/icons';
- import {
- Alert,
- App as AntApp,
- Badge,
- Button,
- Card,
- Checkbox,
- Descriptions,
- Drawer,
- Form,
- Grid,
- Input,
- InputNumber,
- List,
- Modal,
- Pagination,
- Select,
- Skeleton,
- Space,
- Table,
- Tabs,
- Tag,
- Typography
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
- import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
- import { CodeBlock } from '../components/common/CodeBlock';
- import { EmptyState } from '../components/common/EmptyState';
- import { PageHeader } from '../components/common/PageHeader';
- import { SectionCard } from '../components/common/SectionCard';
- import { StatusPill } from '../components/common/StatusPill';
- import { useAppContext } from '../frontend/app-context';
- import { useI18n } from '../frontend/i18n/react';
- import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
- import { api } from '../frontend/services/api';
- import type { Domain, InboundFolder, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
- import { useMediaQuery } from '../frontend/use-media-query';
- type MailMessage = InboundMessage & { folder?: string };
- interface MailboxFormValues {
- localPart: string;
- domain: string;
- password: string;
- displayName?: string;
- quotaMb?: number | null;
- aliases?: string;
- forwardTo?: string;
- keepForwarded?: boolean;
- }
- const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
- type MessageTab = 'text' | 'html' | 'raw';
- export default function Inbox() {
- const { message } = AntApp.useApp();
- const screens = Grid.useBreakpoint();
- const isDesktop = useMediaQuery('(min-width: 1024px)');
- const { locale, t } = useI18n();
- const { config } = useAppContext();
- const location = useLocation();
- const navigate = useNavigate();
- const params = useParams<{ messageId?: string }>();
- const [searchParams, setSearchParams] = useSearchParams();
- const [mailboxForm] = Form.useForm<MailboxFormValues>();
- const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
- const [domains, setDomains] = useState<Domain[]>([]);
- const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
- const [folders, setFolders] = useState<InboundFolder[]>(fallbackFolders());
- const [messages, setMessages] = useState<MailMessage[]>([]);
- const [total, setTotal] = useState(0);
- const [selectedMessage, setSelectedMessage] = useState<MailMessage | null>(null);
- const [loading, setLoading] = useState(true);
- const [messagesLoading, setMessagesLoading] = useState(false);
- const [detailLoading, setDetailLoading] = useState(false);
- const [loadError, setLoadError] = useState('');
- const [messagesError, setMessagesError] = useState('');
- const [detailError, setDetailError] = useState('');
- const [actionKey, setActionKey] = useState('');
- const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
- const [mailboxOpen, setMailboxOpen] = useState(false);
- const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
- const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
- const pendingDirectClose = useRef<string | null>(null);
- const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
- const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
- const folder = searchParams.get('folder') || 'INBOX';
- const readFilter = searchParams.get('read') || 'all';
- const query = searchParams.get('q') || '';
- const page = Math.max(1, Number(searchParams.get('page') || 1) || 1);
- const pageSize = 25;
- const routeMessageId = Number(params.messageId || 0) || null;
- const messageTab = normalizeMessageTab(searchParams.get('tab'));
- const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) || null;
- const loadBase = useCallback(async () => {
- setLoading(true);
- setLoadError('');
- try {
- const [domainResult, mailboxResult] = await Promise.all([
- api.domains(), api.inboundMailboxes()
- ]);
- setDomains(domainResult.domains || []);
- setMailboxes(mailboxResult.mailboxes || []);
- } catch (error) {
- setLoadError(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setLoading(false);
- }
- }, [t]);
- useEffect(() => {
- void loadBase();
- }, [loadBase]);
- useEffect(() => {
- if (!mailboxes.length) return;
- if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
- if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
- const next = new URLSearchParams(searchParams);
- next.set('mailboxId', String(mailboxes[0].id));
- next.set('folder', 'INBOX');
- next.set('page', '1');
- setSearchParams(next, { replace: true });
- }, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
- const loadFolders = useCallback(async () => {
- if (!selectedMailboxId) {
- setFolders(fallbackFolders());
- return;
- }
- try {
- const result = await api.inboundFolders(selectedMailboxId);
- setFolders(result.folders?.length ? result.folders : fallbackFolders(selectedMailbox || undefined));
- } catch {
- setFolders(fallbackFolders(selectedMailbox || undefined));
- }
- }, [selectedMailbox, selectedMailboxId]);
- useEffect(() => {
- void loadFolders();
- }, [loadFolders]);
- const loadMessages = useCallback(async () => {
- if (!selectedMailboxId || workspace !== 'messages') {
- setMessages([]);
- setTotal(0);
- return;
- }
- setMessagesLoading(true);
- setMessagesError('');
- try {
- const result = await api.inboundMessages({
- mailboxId: selectedMailboxId,
- folder,
- page,
- pageSize,
- q: query || undefined,
- read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
- });
- setMessages(result.messages || []);
- setTotal(result.total ?? result.messages?.length ?? 0);
- } catch (error) {
- setMessagesError(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setMessagesLoading(false);
- }
- }, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
- useEffect(() => {
- void loadMessages();
- }, [loadMessages]);
- useEffect(() => {
- if (!routeMessageId) {
- setSelectedMessage(null);
- setDetailError('');
- return;
- }
- let active = true;
- setDetailLoading(true);
- setDetailError('');
- void api.inboundMessage(routeMessageId)
- .then(async (result) => {
- if (!active) return;
- const detail = result.message as MailMessage | null;
- if (!detail) {
- setDetailError(t('inbox.messageNotFound'));
- return;
- }
- setSelectedMessage(detail);
- if (!detail.read) {
- await api.markInboundMessageRead(detail.id, true);
- if (!active) return;
- setSelectedMessage({ ...detail, read: true });
- setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
- void loadFolders();
- }
- })
- .catch((error) => {
- if (active) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
- })
- .finally(() => {
- if (active) setDetailLoading(false);
- });
- return () => { active = false; };
- }, [loadFolders, routeMessageId, t]);
- useEffect(() => {
- if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
- if (searchParams.has('mailboxId') && searchParams.has('folder')) return;
- const next = new URLSearchParams(searchParams);
- next.set('mailboxId', String(selectedMessage.mailboxId));
- next.set('folder', selectedMessage.folder || 'INBOX');
- setSearchParams(next, { replace: true });
- }, [routeMessageId, searchParams, selectedMessage, setSearchParams]);
- useEffect(() => {
- const target = pendingDirectClose.current;
- if (!target || detailHistoryState(location.state)?.origin === 'direct') return;
- pendingDirectClose.current = null;
- navigate(target, { replace: true });
- }, [location.key, location.state, navigate]);
- function updateSearch(patch: Record<string, string | number | null>) {
- const next = new URLSearchParams(searchParams);
- Object.entries(patch).forEach(([key, value]) => {
- if (value === null || value === '') next.delete(key);
- else next.set(key, String(value));
- });
- setSearchParams(next);
- }
- function switchWorkspace(key: string) {
- const next = new URLSearchParams(searchParams);
- if (key === 'routing') next.set('workspace', 'routing');
- else next.delete('workspace');
- const suffix = next.toString();
- navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
- }
- function selectMailbox(id: number) {
- const next = new URLSearchParams(searchParams);
- next.set('mailboxId', String(id));
- next.set('folder', 'INBOX');
- next.set('page', '1');
- const suffix = next.toString();
- navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
- }
- function selectFolder(name: string) {
- const next = new URLSearchParams(searchParams);
- next.set('folder', name);
- next.set('page', '1');
- const suffix = next.toString();
- navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
- }
- function openMessage(item: MailMessage) {
- setSelectedMessage(item);
- const suffix = searchParams.toString();
- const target = `/inbox/messages/${item.id}${suffix ? `?${suffix}` : ''}`;
- const historyState = detailHistoryState(location.state);
- if (historyState) {
- navigate(target, {
- state: detailHistoryLocation(historyState.listPath, historyState.depth + 1, historyState.origin)
- });
- return;
- }
- if (routeMessageId) {
- navigate(target, { replace: true });
- return;
- }
- navigate(target, {
- state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
- });
- }
- function closeMessage() {
- const historyState = detailHistoryState(location.state);
- if (historyState?.origin === 'list') {
- navigate(-historyState.depth);
- return;
- }
- const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
- const listPath = inboxListPath(searchParams, detail);
- if (historyState?.origin === 'direct') {
- pendingDirectClose.current = listPath;
- navigate(-historyState.depth);
- return;
- }
- navigate(listPath, { replace: true });
- }
- function changeMessageTab(tab: MessageTab) {
- const next = new URLSearchParams(searchParams);
- if (tab === 'text') next.delete('tab');
- else next.set('tab', tab);
- const historyState = detailHistoryState(location.state);
- const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
- const search = next.toString();
- navigate(
- { pathname: location.pathname, search: search ? `?${search}` : '' },
- {
- state: detailHistoryLocation(
- historyState?.listPath || inboxListPath(searchParams, detail),
- (historyState?.depth || 0) + 1,
- historyState?.origin || 'direct'
- )
- }
- );
- }
- async function copyValue(value: string) {
- if (!value) return;
- await navigator.clipboard.writeText(value);
- message.success(t('common.copied'));
- }
- function openCreateMailbox() {
- mailboxForm.resetFields();
- mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true });
- setMailboxOpen(true);
- }
- async function createMailbox() {
- const values = await mailboxForm.validateFields();
- setActionKey('mailbox:create');
- try {
- const result = await api.createInboundMailbox({
- address: `${values.localPart}@${values.domain}`,
- displayName: values.displayName,
- password: values.password,
- aliases: values.aliases,
- forwardTo: values.forwardTo,
- keepForwarded: values.keepForwarded,
- quotaMb: values.quotaMb
- });
- message.success(t('actions.inboundMailboxCreated'));
- setMailboxOpen(false);
- mailboxForm.resetFields();
- setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
- await loadBase();
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setActionKey('');
- }
- }
- function openCatchAll(domain: Domain) {
- setCatchAllDomain(domain);
- catchAllForm.setFieldsValue({ catchAllAddress: domain.catchAllAddress || '' });
- }
- async function saveCatchAll() {
- if (!catchAllDomain) return;
- const values = await catchAllForm.validateFields();
- setActionKey(`catch-all:${catchAllDomain.id}`);
- try {
- const result = await api.patchDomain(catchAllDomain.id, { catchAllAddress: String(values.catchAllAddress || '').trim() });
- setDomains((items) => items.map((item) => item.id === result.domain.id ? result.domain : item));
- setCatchAllDomain(null);
- message.success(t('actions.domainSaved'));
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setActionKey('');
- }
- }
- const mailboxColumns: ColumnsType<InboundMailbox> = [
- {
- title: t('inbox.mailboxAddress'), dataIndex: 'address', render: (value: string, item) => <Space direction="vertical" size={0}><Typography.Text strong>{value}</Typography.Text>{item.displayName ? <Typography.Text type="secondary">{item.displayName}</Typography.Text> : null}</Space>
- },
- { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: string) => <StatusPill tone={value === 'active' ? 'success' : 'warning'}>{value}</StatusPill> },
- { title: t('inbox.forwardTo'), dataIndex: 'forwardTo', render: (value: string[], item) => value?.length ? <Space direction="vertical" size={2}><Typography.Text>{value.join(', ')}</Typography.Text><Tag>{item.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')}</Tag></Space> : '—' },
- { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
- { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 },
- {
- title: t('common.actions'), width: 230, render: (_, item) => <Space wrap><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space>
- }
- ];
- const routeColumns: ColumnsType<Domain> = [
- { title: t('domains.domain'), dataIndex: 'domain', render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
- { title: t('inbox.catchAllAddress'), dataIndex: 'catchAllAddress', render: (value?: string) => value ? <Tag color={value === '/dev/null' ? 'default' : 'blue'}>{value}</Tag> : <Tag>{t('inbox.catchAllDisabled')}</Tag> },
- { title: t('common.actions'), width: 130, render: (_, domain) => <Button icon={<SettingOutlined />} onClick={() => openCatchAll(domain)}>{t('common.edit')}</Button> }
- ];
- if (loading) return <SectionCard><Skeleton active paragraph={{ rows: 12 }} /></SectionCard>;
- return (
- <Space direction="vertical" size={20} className="full-width">
- <PageHeader
- title={t('inbox.title')}
- subtitle={t('inbox.subtitle')}
- extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
- />
- {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
- {config?.submission?.inboundEnabled === false ? <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} /> : null}
- <Tabs
- activeKey={workspace}
- onChange={switchWorkspace}
- items={[
- { key: 'messages', label: <Space><MailOutlined />{locale.startsWith('en') ? 'Mail' : '邮件'}</Space> },
- { key: 'routing', label: <Space><SettingOutlined />{locale.startsWith('en') ? 'Mailboxes & routing' : '邮箱与路由'}</Space> }
- ]}
- />
- {workspace === 'messages' ? (
- mailboxes.length ? (
- <div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
- {screens.md ? <FolderPane mailboxes={mailboxes} selectedMailboxId={selectedMailboxId} folders={folders} activeFolder={folder} onMailbox={selectMailbox} onFolder={selectFolder} locale={locale} /> : null}
- <Card styles={{ body: { padding: 0, minWidth: 0 } }}>
- <div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
- {!screens.md ? (
- <Space direction="vertical" size={8} className="full-width" style={{ marginBottom: 8 }}>
- <Select value={selectedMailboxId || undefined} onChange={selectMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" aria-label={t('inbox.mailboxFilter')} />
- <Select value={folder} onChange={selectFolder} options={folders.map((item) => ({ value: item.name, label: `${folderLabel(item.name, locale)} (${item.unreadCount})` }))} className="full-width" aria-label={locale.startsWith('en') ? 'Folder' : '文件夹'} />
- </Space>
- ) : null}
- <Space.Compact block>
- <Input value={searchDraft} allowClear prefix={<SearchOutlined />} placeholder={t('inbox.searchPlaceholder')} onChange={(event) => setSearchDraft(event.target.value)} onPressEnter={() => updateSearch({ q: searchDraft.trim(), page: 1 })} />
- <Button aria-label={t('common.refresh')} icon={<ReloadOutlined />} loading={messagesLoading} onClick={() => void loadMessages()} />
- </Space.Compact>
- <Select
- value={readFilter}
- onChange={(value) => updateSearch({ read: value === 'all' ? null : value, page: 1 })}
- style={{ width: '100%', marginTop: 8 }}
- aria-label={locale.startsWith('en') ? 'Read state' : '阅读状态'}
- options={[{ value: 'all', label: locale.startsWith('en') ? 'All mail' : '全部邮件' }, { value: 'unread', label: t('inbox.unread') }, { value: 'read', label: t('inbox.read') }]}
- />
- </div>
- {messagesError ? <Alert type="error" showIcon message={messagesError} action={<Button onClick={() => void loadMessages()}>{t('common.refresh')}</Button>} /> : null}
- <MessageList items={messages} loading={messagesLoading} activeId={routeMessageId} onOpen={openMessage} locale={locale} t={t} />
- {total > pageSize ? <div style={{ padding: 12, display: 'flex', justifyContent: 'center' }}><Pagination size="small" current={page} pageSize={pageSize} total={total} showSizeChanger={false} onChange={(value) => updateSearch({ page: value })} /></div> : null}
- </Card>
- {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
- </div>
- ) : <EmptyState description={locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。'} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={() => { switchWorkspace('routing'); openCreateMailbox(); }}>{t('inbox.createMailbox')}</Button>} />
- ) : (
- <Space direction="vertical" size={20} className="full-width">
- <SectionCard title={t('inbox.mailboxes')} extra={<StatusPill tone="neutral">{mailboxes.length}</StatusPill>}>
- {mailboxes.length ? (screens.md ? <Table rowKey="id" columns={mailboxColumns} dataSource={mailboxes} scroll={{ x: 940 }} /> : <List dataSource={mailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><Typography.Text type="secondary">{item.messageCount} {t('inbox.messageCount')} · {item.unreadCount} {t('inbox.unread')}</Typography.Text><Space wrap><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space></Space></Card></List.Item>} />) : <EmptyState description={t('inbox.noDomain')} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
- </SectionCard>
- <SectionCard title={t('inbox.domainRoutes')} extra={<StatusPill tone="neutral">{domains.length}</StatusPill>}>
- {domains.length ? (screens.md ? <Table rowKey="id" columns={routeColumns} dataSource={domains} /> : <List dataSource={domains} renderItem={(domain) => <List.Item actions={[<Button key="edit" onClick={() => openCatchAll(domain)}>{t('common.edit')}</Button>]}><List.Item.Meta title={domain.domain} description={domain.catchAllAddress || t('inbox.catchAllDisabled')} /></List.Item>} />) : <EmptyState description={t('inbox.noDomain')} action={<Button onClick={() => navigate('/domains?create=1')}>{t('common.addDomain')}</Button>} />}
- </SectionCard>
- </Space>
- )}
- {!isDesktop ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Drawer> : null}
- <Drawer title={t('inbox.createMailbox')} width={560} open={mailboxOpen} onClose={() => setMailboxOpen(false)} destroyOnHidden footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setMailboxOpen(false)}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create'} onClick={() => void createMailbox()}>{t('inbox.createMailbox')}</Button></Space>}>
- {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
- <Form form={mailboxForm} layout="vertical">
- <Space.Compact block>
- <Form.Item name="localPart" label={t('inbox.localPart')} rules={[{ required: true, message: t('inbox.localPartRequired') }, { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }]} style={{ flex: 1 }}><Input autoComplete="off" /></Form.Item>
- <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]} style={{ minWidth: 220 }}><Select options={domains.map((domain) => ({ value: domain.domain, label: `@${domain.domain}` }))} /></Form.Item>
- </Space.Compact>
- <Form.Item name="displayName" label={t('inbox.displayName')}><Input /></Form.Item>
- <Form.Item name="password" label={t('inbox.password')} rules={[{ required: true, message: t('inbox.passwordRequired') }, { min: 8, message: t('inbox.passwordMin') }]}><Input.Password autoComplete="new-password" /></Form.Item>
- <Button onClick={() => mailboxForm.setFieldValue('password', generateMailboxPassword())}>{t('inbox.generatePassword')}</Button>
- <Form.Item name="quotaMb" label={`${t('inbox.quotaMb')} (MB)`} style={{ marginTop: 20 }}><InputNumber min={1} className="full-width" /></Form.Item>
- <Form.Item name="aliases" label={t('inbox.aliases')} extra={t('inbox.aliasesExtra')}><Input.TextArea rows={3} /></Form.Item>
- <Form.Item name="forwardTo" label={t('inbox.forwardTo')} extra={t('inbox.forwardToExtra')}><Input.TextArea rows={3} /></Form.Item>
- <Form.Item name="keepForwarded" valuePropName="checked"><Checkbox>{t('inbox.keepForwarded')}</Checkbox></Form.Item>
- </Form>
- )}
- </Drawer>
- <Modal title={t('inbox.catchAllTitle')} open={Boolean(catchAllDomain)} confirmLoading={actionKey.startsWith('catch-all:')} onCancel={() => setCatchAllDomain(null)} onOk={() => void saveCatchAll()}>
- <Form form={catchAllForm} layout="vertical"><Form.Item name="catchAllAddress" label={t('inbox.catchAllAddress')} extra={t('inbox.catchAllExtra')}><Input placeholder="catchall@example.com / /dev/null" /></Form.Item></Form>
- </Modal>
- <Modal title={t('inbox.clientConfig')} open={Boolean(clientConfig)} width={720} footer={<Button type="primary" onClick={() => setClientConfig(null)}>{t('common.confirm')}</Button>} onCancel={() => setClientConfig(null)}>
- {clientConfig ? <ClientConfigView config={clientConfig} onCopy={copyValue} t={t} /> : null}
- </Modal>
- </Space>
- );
- }
- function FolderPane({ mailboxes, selectedMailboxId, folders, activeFolder, onMailbox, onFolder, locale }: { mailboxes: InboundMailbox[]; selectedMailboxId: number | null; folders: InboundFolder[]; activeFolder: string; onMailbox: (id: number) => void; onFolder: (name: string) => void; locale: string }) {
- return (
- <Card styles={{ body: { padding: 8 } }}>
- <Select value={selectedMailboxId || undefined} onChange={onMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" style={{ marginBottom: 12 }} />
- <Space direction="vertical" size={2} className="full-width">
- {folders.map((item) => (
- <Button key={item.name} type={activeFolder === item.name ? 'primary' : 'text'} icon={folderIcon(item.name)} onClick={() => onFolder(item.name)} style={{ width: '100%', minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }}>
- <span style={{ flex: 1, textAlign: 'left' }}>{folderLabel(item.name, locale)}</span>
- {item.unreadCount ? <Badge count={item.unreadCount} size="small" /> : <Typography.Text type="secondary">{item.messageCount}</Typography.Text>}
- </Button>
- ))}
- </Space>
- </Card>
- );
- }
- function MessageList({ items, loading, activeId, onOpen, locale, t }: { items: MailMessage[]; loading: boolean; activeId: number | null; onOpen: (item: MailMessage) => void; locale: string; t: (key: string) => string }) {
- if (loading) return <div style={{ padding: 16 }}><Skeleton active paragraph={{ rows: 8 }} /></div>;
- if (!items.length) return <EmptyState description={locale.startsWith('en') ? 'No messages match this folder and filter.' : '当前文件夹和筛选条件下没有邮件。'} />;
- return (
- <List dataSource={items} split renderItem={(item) => (
- <List.Item style={{ padding: 0 }}>
- <button
- type="button"
- aria-label={`${item.subject || t('inbox.noSubject')} · ${item.sender}`}
- onClick={() => onOpen(item)}
- style={{ width: '100%', minHeight: 88, padding: '12px 16px', border: 0, textAlign: 'left', background: activeId === item.id ? '#EEF2FF' : item.read ? '#FFFFFF' : '#F8FAFF', cursor: 'pointer' }}
- >
- <Space direction="vertical" size={4} style={{ width: '100%' }}>
- <Space style={{ width: '100%', justifyContent: 'space-between' }}>
- <Typography.Text strong={!item.read} ellipsis style={{ maxWidth: '65%' }}>{item.sender || '—'}</Typography.Text>
- <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatCompactTime(item.receivedAt)}</Typography.Text>
- </Space>
- <Typography.Text strong={!item.read} ellipsis>{item.subject || t('inbox.noSubject')}</Typography.Text>
- <Typography.Text type="secondary" ellipsis>{item.preview || '—'}</Typography.Text>
- </Space>
- </button>
- </List.Item>
- )} />
- );
- }
- function MessageDetail({ message, loading, error, activeTab, onTabChange, onCopy, t }: { message: MailMessage | null; loading: boolean; error: string; activeTab: MessageTab; onTabChange: (tab: MessageTab) => void; onCopy: (value: string) => void; t: (key: string) => string }) {
- if (loading) return <Skeleton active paragraph={{ rows: 12 }} />;
- if (error) return <Alert type="error" showIcon message={error} />;
- if (!message) return <EmptyState description={t('inbox.messageDetail')} />;
- return (
- <Space direction="vertical" size={20} className="full-width">
- <div><Typography.Title level={4} style={{ marginBottom: 4 }}>{message.subject || t('inbox.noSubject')}</Typography.Title><Typography.Text type="secondary">{formatOptionalTime(message.receivedAt)}</Typography.Text></div>
- <Descriptions column={1} size="small">
- <Descriptions.Item label={t('inbox.sender')}>{message.sender || '—'}</Descriptions.Item>
- <Descriptions.Item label={t('inbox.recipients')}>{message.recipients.join(', ') || '—'}</Descriptions.Item>
- <Descriptions.Item label={t('logs.messageId')}><Typography.Text code copyable={{ onCopy: () => onCopy(message.messageId) }}>{message.messageId || '—'}</Typography.Text></Descriptions.Item>
- </Descriptions>
- <Tabs activeKey={activeTab} onChange={(key) => onTabChange(normalizeMessageTab(key))} items={[
- { key: 'text', label: t('inbox.textBody'), children: message.textBody ? <pre className="inbox-message-body">{message.textBody}</pre> : <EmptyState description={t('inbox.noTextBody')} /> },
- { key: 'html', label: t('inbox.htmlBody'), children: message.htmlBody ? <CodeBlock value={message.htmlBody} onCopy={onCopy} /> : <EmptyState description={t('inbox.noHtmlBody')} /> },
- { key: 'raw', label: t('inbox.rawMessage'), children: message.rawMessage ? <CodeBlock value={message.rawMessage} onCopy={onCopy} /> : <EmptyState description={t('inbox.noRawMessage')} /> }
- ]} />
- </Space>
- );
- }
- function normalizeMessageTab(value: string | null): MessageTab {
- return value === 'html' || value === 'raw' ? value : 'text';
- }
- function inboxListPath(searchParams: URLSearchParams, message: MailMessage | null) {
- const next = new URLSearchParams(searchParams);
- next.delete('tab');
- if (message && (!next.has('mailboxId') || !next.has('folder'))) {
- next.set('mailboxId', String(message.mailboxId));
- next.set('folder', message.folder || 'INBOX');
- }
- const suffix = next.toString();
- return `/inbox${suffix ? `?${suffix}` : ''}`;
- }
- function ClientConfigView({ config, onCopy, t }: { config: MailboxClientConfig; onCopy: (value: string) => void; t: (key: string) => string }) {
- const sections = [
- { key: 'imap', label: t('inbox.incomingConfig'), value: config.incoming },
- ...(config.pop3 ? [{ key: 'pop3', label: t('inbox.pop3Config'), value: config.pop3 }] : []),
- { key: 'smtp', label: t('inbox.outgoingConfig'), value: config.outgoing }
- ];
- return (
- <Space direction="vertical" size={16} className="full-width">
- <Alert type="info" showIcon message={t('inbox.clientConfigHelpSummary')} />
- <Descriptions bordered column={1} size="small"><Descriptions.Item label={t('inbox.configUsername')}>{configValue(config.username, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configPassword')}>{config.password ? configValue(config.password, onCopy) : <Typography.Text type="secondary">{t('inbox.passwordNotShown')}</Typography.Text>}</Descriptions.Item></Descriptions>
- <Tabs items={sections.map((section) => ({ key: section.key, label: section.label, children: <Descriptions bordered column={1} size="small"><Descriptions.Item label={t('inbox.configHost')}>{configValue(section.value.host, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configPort')}>{configValue(section.value.port, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configSecurity')}>{section.value.security}</Descriptions.Item><Descriptions.Item label={t('inbox.configAuthMethod')}>{section.value.authMethod}</Descriptions.Item></Descriptions> }))} />
- </Space>
- );
- }
- function configValue(value: string | number, onCopy: (value: string) => void) {
- return <Space><Typography.Text code>{value}</Typography.Text><Button aria-label="Copy" icon={<CopyOutlined />} onClick={() => void onCopy(String(value))} /></Space>;
- }
- function fallbackFolders(mailbox?: InboundMailbox): InboundFolder[] {
- return standardFolders.map((name) => ({ name, specialUse: folderSpecialUse(name), messageCount: name === 'INBOX' ? mailbox?.messageCount || 0 : 0, unreadCount: name === 'INBOX' ? mailbox?.unreadCount || 0 : 0 }));
- }
- function folderSpecialUse(name: string) {
- const values: Record<string, string | null> = {
- INBOX: null,
- Sent: '\\Sent',
- Drafts: '\\Drafts',
- Trash: '\\Trash',
- Junk: '\\Junk',
- Archive: '\\Archive'
- };
- return values[name] ?? null;
- }
- function folderLabel(name: string, locale: string) {
- if (locale.startsWith('en')) return name;
- return { INBOX: '收件箱', Sent: '已发送', Drafts: '草稿', Trash: '已删除', Junk: '垃圾邮件', Archive: '归档' }[name] || name;
- }
- function folderIcon(name: string) {
- if (name === 'INBOX') return <InboxOutlined />;
- if (name === 'Sent') return <SendOutlined />;
- if (name === 'Drafts') return <FileTextOutlined />;
- if (name === 'Trash') return <DeleteOutlined />;
- if (name === 'Junk') return <WarningOutlined />;
- if (name === 'Archive') return <ContainerOutlined />;
- return <FolderOutlined />;
- }
- function buildMailboxClientConfig(mailbox: InboundMailbox, config: RuntimeConfig | null, password = ''): MailboxClientConfig {
- const smtpPort = preferredPort(config?.submission?.ports || [], [587, 465]);
- const imapPort = preferredPort(config?.mailAccess?.imap.ports || [], [993, 143]);
- const pop3Port = preferredPort(config?.mailAccess?.pop3.ports || [], [995, 110]);
- const accessHost = config?.mailAccess?.host || config?.submission?.host || config?.mailHostname || mailbox.domain;
- return {
- username: mailbox.address,
- password,
- incoming: { protocol: 'IMAP', host: accessHost, port: imapPort?.port || 143, security: imapPort?.protocol || 'IMAP + STARTTLS', authMethod: 'Normal password', username: mailbox.address, password },
- pop3: { protocol: 'POP3', host: accessHost, port: pop3Port?.port || 110, security: pop3Port?.protocol || 'POP3 + STLS', authMethod: 'Normal password', username: mailbox.address, password },
- outgoing: { protocol: 'SMTP', host: config?.submission?.host || config?.mailHostname || mailbox.domain, port: smtpPort?.port || 587, security: smtpPort?.protocol || 'SMTP + STARTTLS', authMethod: 'Normal password', username: mailbox.address, password }
- };
- }
- function preferredPort(ports: Array<{ port: number; protocol: string }>, preferred: number[]) {
- for (const port of preferred) {
- const match = ports.find((item) => item.port === port);
- if (match) return match;
- }
- return ports[0] || null;
- }
- function generateMailboxPassword() {
- const bytes = new Uint8Array(14);
- globalThis.crypto.getRandomValues(bytes);
- return Array.from(bytes, (value) => (value % 36).toString(36)).join('');
- }
- function formatCompactTime(value: string) {
- const date = new Date(value);
- const now = new Date();
- return date.toDateString() === now.toDateString() ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : date.toLocaleDateString();
- }
- function formatOptionalTime(value?: string | null) {
- return value ? new Date(value).toLocaleString() : '—';
- }
|