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(); const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>(); const [domains, setDomains] = useState([]); const [mailboxes, setMailboxes] = useState([]); const [folders, setFolders] = useState(fallbackFolders()); const [messages, setMessages] = useState([]); const [total, setTotal] = useState(0); const [selectedMessage, setSelectedMessage] = useState(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(null); const [catchAllDomain, setCatchAllDomain] = useState(null); const pendingDirectClose = useRef(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) { 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 = [ { title: t('inbox.mailboxAddress'), dataIndex: 'address', render: (value: string, item) => {value}{item.displayName ? {item.displayName} : null} }, { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: string) => {value} }, { title: t('inbox.forwardTo'), dataIndex: 'forwardTo', render: (value: string[], item) => value?.length ? {value.join(', ')}{item.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')} : '—' }, { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 }, { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 }, { title: t('common.actions'), width: 230, render: (_, item) => } ]; const routeColumns: ColumnsType = [ { title: t('domains.domain'), dataIndex: 'domain', render: (value: string) => {value} }, { title: t('inbox.catchAllAddress'), dataIndex: 'catchAllAddress', render: (value?: string) => value ? {value} : {t('inbox.catchAllDisabled')} }, { title: t('common.actions'), width: 130, render: (_, domain) => } ]; if (loading) return ; return ( } disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')} : null} /> {loadError ? } onClick={() => void loadBase()}>{t('common.refresh')}} /> : null} {config?.submission?.inboundEnabled === false ? : null} {locale.startsWith('en') ? 'Mail' : '邮件'} }, { key: 'routing', label: {locale.startsWith('en') ? 'Mailboxes & routing' : '邮箱与路由'} } ]} /> {workspace === 'messages' ? ( mailboxes.length ? (
{screens.md ? : null}
{!screens.md ? ( ({ value: item.name, label: `${folderLabel(item.name, locale)} (${item.unreadCount})` }))} className="full-width" aria-label={locale.startsWith('en') ? 'Folder' : '文件夹'} /> ) : null} } placeholder={t('inbox.searchPlaceholder')} onChange={(event) => setSearchDraft(event.target.value)} onPressEnter={() => updateSearch({ q: searchDraft.trim(), page: 1 })} /> {t('inbox.keepForwarded')} )} setCatchAllDomain(null)} onOk={() => void saveCatchAll()}>
setClientConfig(null)}>{t('common.confirm')}} onCancel={() => setClientConfig(null)}> {clientConfig ? : null} ); } 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 (