Inbox.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. import {
  2. ContainerOutlined,
  3. CopyOutlined,
  4. DeleteOutlined,
  5. FileTextOutlined,
  6. FolderOutlined,
  7. InboxOutlined,
  8. MailOutlined,
  9. PlusOutlined,
  10. ReloadOutlined,
  11. SearchOutlined,
  12. SendOutlined,
  13. SettingOutlined,
  14. WarningOutlined
  15. } from '@ant-design/icons';
  16. import {
  17. Alert,
  18. App as AntApp,
  19. Badge,
  20. Button,
  21. Card,
  22. Checkbox,
  23. Descriptions,
  24. Drawer,
  25. Form,
  26. Grid,
  27. Input,
  28. InputNumber,
  29. List,
  30. Modal,
  31. Pagination,
  32. Select,
  33. Skeleton,
  34. Space,
  35. Table,
  36. Tabs,
  37. Tag,
  38. Typography
  39. } from 'antd';
  40. import type { ColumnsType } from 'antd/es/table';
  41. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  42. import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
  43. import { CodeBlock } from '../components/common/CodeBlock';
  44. import { EmptyState } from '../components/common/EmptyState';
  45. import { PageHeader } from '../components/common/PageHeader';
  46. import { SectionCard } from '../components/common/SectionCard';
  47. import { StatusPill } from '../components/common/StatusPill';
  48. import { useAppContext } from '../frontend/app-context';
  49. import { useI18n } from '../frontend/i18n/react';
  50. import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
  51. import { api } from '../frontend/services/api';
  52. import type { Domain, InboundFolder, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
  53. import { useMediaQuery } from '../frontend/use-media-query';
  54. type MailMessage = InboundMessage & { folder?: string };
  55. interface MailboxFormValues {
  56. localPart: string;
  57. domain: string;
  58. password: string;
  59. displayName?: string;
  60. quotaMb?: number | null;
  61. aliases?: string;
  62. forwardTo?: string;
  63. keepForwarded?: boolean;
  64. }
  65. const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
  66. type MessageTab = 'text' | 'html' | 'raw';
  67. export default function Inbox() {
  68. const { message } = AntApp.useApp();
  69. const screens = Grid.useBreakpoint();
  70. const isDesktop = useMediaQuery('(min-width: 1024px)');
  71. const { locale, t } = useI18n();
  72. const { config } = useAppContext();
  73. const location = useLocation();
  74. const navigate = useNavigate();
  75. const params = useParams<{ messageId?: string }>();
  76. const [searchParams, setSearchParams] = useSearchParams();
  77. const [mailboxForm] = Form.useForm<MailboxFormValues>();
  78. const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
  79. const [domains, setDomains] = useState<Domain[]>([]);
  80. const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
  81. const [folders, setFolders] = useState<InboundFolder[]>(fallbackFolders());
  82. const [messages, setMessages] = useState<MailMessage[]>([]);
  83. const [total, setTotal] = useState(0);
  84. const [selectedMessage, setSelectedMessage] = useState<MailMessage | null>(null);
  85. const [loading, setLoading] = useState(true);
  86. const [messagesLoading, setMessagesLoading] = useState(false);
  87. const [detailLoading, setDetailLoading] = useState(false);
  88. const [loadError, setLoadError] = useState('');
  89. const [messagesError, setMessagesError] = useState('');
  90. const [detailError, setDetailError] = useState('');
  91. const [actionKey, setActionKey] = useState('');
  92. const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
  93. const [mailboxOpen, setMailboxOpen] = useState(false);
  94. const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
  95. const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
  96. const pendingDirectClose = useRef<string | null>(null);
  97. const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
  98. const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
  99. const folder = searchParams.get('folder') || 'INBOX';
  100. const readFilter = searchParams.get('read') || 'all';
  101. const query = searchParams.get('q') || '';
  102. const page = Math.max(1, Number(searchParams.get('page') || 1) || 1);
  103. const pageSize = 25;
  104. const routeMessageId = Number(params.messageId || 0) || null;
  105. const messageTab = normalizeMessageTab(searchParams.get('tab'));
  106. const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) || null;
  107. const loadBase = useCallback(async () => {
  108. setLoading(true);
  109. setLoadError('');
  110. try {
  111. const [domainResult, mailboxResult] = await Promise.all([
  112. api.domains(), api.inboundMailboxes()
  113. ]);
  114. setDomains(domainResult.domains || []);
  115. setMailboxes(mailboxResult.mailboxes || []);
  116. } catch (error) {
  117. setLoadError(error instanceof Error ? error.message : t('common.error'));
  118. } finally {
  119. setLoading(false);
  120. }
  121. }, [t]);
  122. useEffect(() => {
  123. void loadBase();
  124. }, [loadBase]);
  125. useEffect(() => {
  126. if (!mailboxes.length) return;
  127. if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
  128. if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
  129. const next = new URLSearchParams(searchParams);
  130. next.set('mailboxId', String(mailboxes[0].id));
  131. next.set('folder', 'INBOX');
  132. next.set('page', '1');
  133. setSearchParams(next, { replace: true });
  134. }, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
  135. const loadFolders = useCallback(async () => {
  136. if (!selectedMailboxId) {
  137. setFolders(fallbackFolders());
  138. return;
  139. }
  140. try {
  141. const result = await api.inboundFolders(selectedMailboxId);
  142. setFolders(result.folders?.length ? result.folders : fallbackFolders(selectedMailbox || undefined));
  143. } catch {
  144. setFolders(fallbackFolders(selectedMailbox || undefined));
  145. }
  146. }, [selectedMailbox, selectedMailboxId]);
  147. useEffect(() => {
  148. void loadFolders();
  149. }, [loadFolders]);
  150. const loadMessages = useCallback(async () => {
  151. if (!selectedMailboxId || workspace !== 'messages') {
  152. setMessages([]);
  153. setTotal(0);
  154. return;
  155. }
  156. setMessagesLoading(true);
  157. setMessagesError('');
  158. try {
  159. const result = await api.inboundMessages({
  160. mailboxId: selectedMailboxId,
  161. folder,
  162. page,
  163. pageSize,
  164. q: query || undefined,
  165. read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
  166. });
  167. setMessages(result.messages || []);
  168. setTotal(result.total ?? result.messages?.length ?? 0);
  169. } catch (error) {
  170. setMessagesError(error instanceof Error ? error.message : t('common.error'));
  171. } finally {
  172. setMessagesLoading(false);
  173. }
  174. }, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
  175. useEffect(() => {
  176. void loadMessages();
  177. }, [loadMessages]);
  178. useEffect(() => {
  179. if (!routeMessageId) {
  180. setSelectedMessage(null);
  181. setDetailError('');
  182. return;
  183. }
  184. let active = true;
  185. setDetailLoading(true);
  186. setDetailError('');
  187. void api.inboundMessage(routeMessageId)
  188. .then(async (result) => {
  189. if (!active) return;
  190. const detail = result.message as MailMessage | null;
  191. if (!detail) {
  192. setDetailError(t('inbox.messageNotFound'));
  193. return;
  194. }
  195. setSelectedMessage(detail);
  196. if (!detail.read) {
  197. await api.markInboundMessageRead(detail.id, true);
  198. if (!active) return;
  199. setSelectedMessage({ ...detail, read: true });
  200. setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
  201. void loadFolders();
  202. }
  203. })
  204. .catch((error) => {
  205. if (active) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
  206. })
  207. .finally(() => {
  208. if (active) setDetailLoading(false);
  209. });
  210. return () => { active = false; };
  211. }, [loadFolders, routeMessageId, t]);
  212. useEffect(() => {
  213. if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
  214. if (searchParams.has('mailboxId') && searchParams.has('folder')) return;
  215. const next = new URLSearchParams(searchParams);
  216. next.set('mailboxId', String(selectedMessage.mailboxId));
  217. next.set('folder', selectedMessage.folder || 'INBOX');
  218. setSearchParams(next, { replace: true });
  219. }, [routeMessageId, searchParams, selectedMessage, setSearchParams]);
  220. useEffect(() => {
  221. const target = pendingDirectClose.current;
  222. if (!target || detailHistoryState(location.state)?.origin === 'direct') return;
  223. pendingDirectClose.current = null;
  224. navigate(target, { replace: true });
  225. }, [location.key, location.state, navigate]);
  226. function updateSearch(patch: Record<string, string | number | null>) {
  227. const next = new URLSearchParams(searchParams);
  228. Object.entries(patch).forEach(([key, value]) => {
  229. if (value === null || value === '') next.delete(key);
  230. else next.set(key, String(value));
  231. });
  232. setSearchParams(next);
  233. }
  234. function switchWorkspace(key: string) {
  235. const next = new URLSearchParams(searchParams);
  236. if (key === 'routing') next.set('workspace', 'routing');
  237. else next.delete('workspace');
  238. const suffix = next.toString();
  239. navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
  240. }
  241. function selectMailbox(id: number) {
  242. const next = new URLSearchParams(searchParams);
  243. next.set('mailboxId', String(id));
  244. next.set('folder', 'INBOX');
  245. next.set('page', '1');
  246. const suffix = next.toString();
  247. navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
  248. }
  249. function selectFolder(name: string) {
  250. const next = new URLSearchParams(searchParams);
  251. next.set('folder', name);
  252. next.set('page', '1');
  253. const suffix = next.toString();
  254. navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
  255. }
  256. function openMessage(item: MailMessage) {
  257. setSelectedMessage(item);
  258. const suffix = searchParams.toString();
  259. const target = `/inbox/messages/${item.id}${suffix ? `?${suffix}` : ''}`;
  260. const historyState = detailHistoryState(location.state);
  261. if (historyState) {
  262. navigate(target, {
  263. state: detailHistoryLocation(historyState.listPath, historyState.depth + 1, historyState.origin)
  264. });
  265. return;
  266. }
  267. if (routeMessageId) {
  268. navigate(target, { replace: true });
  269. return;
  270. }
  271. navigate(target, {
  272. state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
  273. });
  274. }
  275. function closeMessage() {
  276. const historyState = detailHistoryState(location.state);
  277. if (historyState?.origin === 'list') {
  278. navigate(-historyState.depth);
  279. return;
  280. }
  281. const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
  282. const listPath = inboxListPath(searchParams, detail);
  283. if (historyState?.origin === 'direct') {
  284. pendingDirectClose.current = listPath;
  285. navigate(-historyState.depth);
  286. return;
  287. }
  288. navigate(listPath, { replace: true });
  289. }
  290. function changeMessageTab(tab: MessageTab) {
  291. const next = new URLSearchParams(searchParams);
  292. if (tab === 'text') next.delete('tab');
  293. else next.set('tab', tab);
  294. const historyState = detailHistoryState(location.state);
  295. const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
  296. const search = next.toString();
  297. navigate(
  298. { pathname: location.pathname, search: search ? `?${search}` : '' },
  299. {
  300. state: detailHistoryLocation(
  301. historyState?.listPath || inboxListPath(searchParams, detail),
  302. (historyState?.depth || 0) + 1,
  303. historyState?.origin || 'direct'
  304. )
  305. }
  306. );
  307. }
  308. async function copyValue(value: string) {
  309. if (!value) return;
  310. await navigator.clipboard.writeText(value);
  311. message.success(t('common.copied'));
  312. }
  313. function openCreateMailbox() {
  314. mailboxForm.resetFields();
  315. mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true });
  316. setMailboxOpen(true);
  317. }
  318. async function createMailbox() {
  319. const values = await mailboxForm.validateFields();
  320. setActionKey('mailbox:create');
  321. try {
  322. const result = await api.createInboundMailbox({
  323. address: `${values.localPart}@${values.domain}`,
  324. displayName: values.displayName,
  325. password: values.password,
  326. aliases: values.aliases,
  327. forwardTo: values.forwardTo,
  328. keepForwarded: values.keepForwarded,
  329. quotaMb: values.quotaMb
  330. });
  331. message.success(t('actions.inboundMailboxCreated'));
  332. setMailboxOpen(false);
  333. mailboxForm.resetFields();
  334. setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
  335. await loadBase();
  336. } catch (error) {
  337. message.error(error instanceof Error ? error.message : t('common.error'));
  338. } finally {
  339. setActionKey('');
  340. }
  341. }
  342. function openCatchAll(domain: Domain) {
  343. setCatchAllDomain(domain);
  344. catchAllForm.setFieldsValue({ catchAllAddress: domain.catchAllAddress || '' });
  345. }
  346. async function saveCatchAll() {
  347. if (!catchAllDomain) return;
  348. const values = await catchAllForm.validateFields();
  349. setActionKey(`catch-all:${catchAllDomain.id}`);
  350. try {
  351. const result = await api.patchDomain(catchAllDomain.id, { catchAllAddress: String(values.catchAllAddress || '').trim() });
  352. setDomains((items) => items.map((item) => item.id === result.domain.id ? result.domain : item));
  353. setCatchAllDomain(null);
  354. message.success(t('actions.domainSaved'));
  355. } catch (error) {
  356. message.error(error instanceof Error ? error.message : t('common.error'));
  357. } finally {
  358. setActionKey('');
  359. }
  360. }
  361. const mailboxColumns: ColumnsType<InboundMailbox> = [
  362. {
  363. 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>
  364. },
  365. { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: string) => <StatusPill tone={value === 'active' ? 'success' : 'warning'}>{value}</StatusPill> },
  366. { 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> : '—' },
  367. { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
  368. { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 },
  369. {
  370. 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>
  371. }
  372. ];
  373. const routeColumns: ColumnsType<Domain> = [
  374. { title: t('domains.domain'), dataIndex: 'domain', render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
  375. { title: t('inbox.catchAllAddress'), dataIndex: 'catchAllAddress', render: (value?: string) => value ? <Tag color={value === '/dev/null' ? 'default' : 'blue'}>{value}</Tag> : <Tag>{t('inbox.catchAllDisabled')}</Tag> },
  376. { title: t('common.actions'), width: 130, render: (_, domain) => <Button icon={<SettingOutlined />} onClick={() => openCatchAll(domain)}>{t('common.edit')}</Button> }
  377. ];
  378. if (loading) return <SectionCard><Skeleton active paragraph={{ rows: 12 }} /></SectionCard>;
  379. return (
  380. <Space direction="vertical" size={20} className="full-width">
  381. <PageHeader
  382. title={t('inbox.title')}
  383. subtitle={t('inbox.subtitle')}
  384. extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
  385. />
  386. {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
  387. {config?.submission?.inboundEnabled === false ? <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} /> : null}
  388. <Tabs
  389. activeKey={workspace}
  390. onChange={switchWorkspace}
  391. items={[
  392. { key: 'messages', label: <Space><MailOutlined />{locale.startsWith('en') ? 'Mail' : '邮件'}</Space> },
  393. { key: 'routing', label: <Space><SettingOutlined />{locale.startsWith('en') ? 'Mailboxes & routing' : '邮箱与路由'}</Space> }
  394. ]}
  395. />
  396. {workspace === 'messages' ? (
  397. mailboxes.length ? (
  398. <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 }}>
  399. {screens.md ? <FolderPane mailboxes={mailboxes} selectedMailboxId={selectedMailboxId} folders={folders} activeFolder={folder} onMailbox={selectMailbox} onFolder={selectFolder} locale={locale} /> : null}
  400. <Card styles={{ body: { padding: 0, minWidth: 0 } }}>
  401. <div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
  402. {!screens.md ? (
  403. <Space direction="vertical" size={8} className="full-width" style={{ marginBottom: 8 }}>
  404. <Select value={selectedMailboxId || undefined} onChange={selectMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" aria-label={t('inbox.mailboxFilter')} />
  405. <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' : '文件夹'} />
  406. </Space>
  407. ) : null}
  408. <Space.Compact block>
  409. <Input value={searchDraft} allowClear prefix={<SearchOutlined />} placeholder={t('inbox.searchPlaceholder')} onChange={(event) => setSearchDraft(event.target.value)} onPressEnter={() => updateSearch({ q: searchDraft.trim(), page: 1 })} />
  410. <Button aria-label={t('common.refresh')} icon={<ReloadOutlined />} loading={messagesLoading} onClick={() => void loadMessages()} />
  411. </Space.Compact>
  412. <Select
  413. value={readFilter}
  414. onChange={(value) => updateSearch({ read: value === 'all' ? null : value, page: 1 })}
  415. style={{ width: '100%', marginTop: 8 }}
  416. aria-label={locale.startsWith('en') ? 'Read state' : '阅读状态'}
  417. options={[{ value: 'all', label: locale.startsWith('en') ? 'All mail' : '全部邮件' }, { value: 'unread', label: t('inbox.unread') }, { value: 'read', label: t('inbox.read') }]}
  418. />
  419. </div>
  420. {messagesError ? <Alert type="error" showIcon message={messagesError} action={<Button onClick={() => void loadMessages()}>{t('common.refresh')}</Button>} /> : null}
  421. <MessageList items={messages} loading={messagesLoading} activeId={routeMessageId} onOpen={openMessage} locale={locale} t={t} />
  422. {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}
  423. </Card>
  424. {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}
  425. </div>
  426. ) : <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>} />
  427. ) : (
  428. <Space direction="vertical" size={20} className="full-width">
  429. <SectionCard title={t('inbox.mailboxes')} extra={<StatusPill tone="neutral">{mailboxes.length}</StatusPill>}>
  430. {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>} />}
  431. </SectionCard>
  432. <SectionCard title={t('inbox.domainRoutes')} extra={<StatusPill tone="neutral">{domains.length}</StatusPill>}>
  433. {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>} />}
  434. </SectionCard>
  435. </Space>
  436. )}
  437. {!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}
  438. <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>}>
  439. {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
  440. <Form form={mailboxForm} layout="vertical">
  441. <Space.Compact block>
  442. <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>
  443. <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>
  444. </Space.Compact>
  445. <Form.Item name="displayName" label={t('inbox.displayName')}><Input /></Form.Item>
  446. <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>
  447. <Button onClick={() => mailboxForm.setFieldValue('password', generateMailboxPassword())}>{t('inbox.generatePassword')}</Button>
  448. <Form.Item name="quotaMb" label={`${t('inbox.quotaMb')} (MB)`} style={{ marginTop: 20 }}><InputNumber min={1} className="full-width" /></Form.Item>
  449. <Form.Item name="aliases" label={t('inbox.aliases')} extra={t('inbox.aliasesExtra')}><Input.TextArea rows={3} /></Form.Item>
  450. <Form.Item name="forwardTo" label={t('inbox.forwardTo')} extra={t('inbox.forwardToExtra')}><Input.TextArea rows={3} /></Form.Item>
  451. <Form.Item name="keepForwarded" valuePropName="checked"><Checkbox>{t('inbox.keepForwarded')}</Checkbox></Form.Item>
  452. </Form>
  453. )}
  454. </Drawer>
  455. <Modal title={t('inbox.catchAllTitle')} open={Boolean(catchAllDomain)} confirmLoading={actionKey.startsWith('catch-all:')} onCancel={() => setCatchAllDomain(null)} onOk={() => void saveCatchAll()}>
  456. <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>
  457. </Modal>
  458. <Modal title={t('inbox.clientConfig')} open={Boolean(clientConfig)} width={720} footer={<Button type="primary" onClick={() => setClientConfig(null)}>{t('common.confirm')}</Button>} onCancel={() => setClientConfig(null)}>
  459. {clientConfig ? <ClientConfigView config={clientConfig} onCopy={copyValue} t={t} /> : null}
  460. </Modal>
  461. </Space>
  462. );
  463. }
  464. 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 }) {
  465. return (
  466. <Card styles={{ body: { padding: 8 } }}>
  467. <Select value={selectedMailboxId || undefined} onChange={onMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" style={{ marginBottom: 12 }} />
  468. <Space direction="vertical" size={2} className="full-width">
  469. {folders.map((item) => (
  470. <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' }}>
  471. <span style={{ flex: 1, textAlign: 'left' }}>{folderLabel(item.name, locale)}</span>
  472. {item.unreadCount ? <Badge count={item.unreadCount} size="small" /> : <Typography.Text type="secondary">{item.messageCount}</Typography.Text>}
  473. </Button>
  474. ))}
  475. </Space>
  476. </Card>
  477. );
  478. }
  479. 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 }) {
  480. if (loading) return <div style={{ padding: 16 }}><Skeleton active paragraph={{ rows: 8 }} /></div>;
  481. if (!items.length) return <EmptyState description={locale.startsWith('en') ? 'No messages match this folder and filter.' : '当前文件夹和筛选条件下没有邮件。'} />;
  482. return (
  483. <List dataSource={items} split renderItem={(item) => (
  484. <List.Item style={{ padding: 0 }}>
  485. <button
  486. type="button"
  487. aria-label={`${item.subject || t('inbox.noSubject')} · ${item.sender}`}
  488. onClick={() => onOpen(item)}
  489. style={{ width: '100%', minHeight: 88, padding: '12px 16px', border: 0, textAlign: 'left', background: activeId === item.id ? '#EEF2FF' : item.read ? '#FFFFFF' : '#F8FAFF', cursor: 'pointer' }}
  490. >
  491. <Space direction="vertical" size={4} style={{ width: '100%' }}>
  492. <Space style={{ width: '100%', justifyContent: 'space-between' }}>
  493. <Typography.Text strong={!item.read} ellipsis style={{ maxWidth: '65%' }}>{item.sender || '—'}</Typography.Text>
  494. <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatCompactTime(item.receivedAt)}</Typography.Text>
  495. </Space>
  496. <Typography.Text strong={!item.read} ellipsis>{item.subject || t('inbox.noSubject')}</Typography.Text>
  497. <Typography.Text type="secondary" ellipsis>{item.preview || '—'}</Typography.Text>
  498. </Space>
  499. </button>
  500. </List.Item>
  501. )} />
  502. );
  503. }
  504. 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 }) {
  505. if (loading) return <Skeleton active paragraph={{ rows: 12 }} />;
  506. if (error) return <Alert type="error" showIcon message={error} />;
  507. if (!message) return <EmptyState description={t('inbox.messageDetail')} />;
  508. return (
  509. <Space direction="vertical" size={20} className="full-width">
  510. <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>
  511. <Descriptions column={1} size="small">
  512. <Descriptions.Item label={t('inbox.sender')}>{message.sender || '—'}</Descriptions.Item>
  513. <Descriptions.Item label={t('inbox.recipients')}>{message.recipients.join(', ') || '—'}</Descriptions.Item>
  514. <Descriptions.Item label={t('logs.messageId')}><Typography.Text code copyable={{ onCopy: () => onCopy(message.messageId) }}>{message.messageId || '—'}</Typography.Text></Descriptions.Item>
  515. </Descriptions>
  516. <Tabs activeKey={activeTab} onChange={(key) => onTabChange(normalizeMessageTab(key))} items={[
  517. { key: 'text', label: t('inbox.textBody'), children: message.textBody ? <pre className="inbox-message-body">{message.textBody}</pre> : <EmptyState description={t('inbox.noTextBody')} /> },
  518. { key: 'html', label: t('inbox.htmlBody'), children: message.htmlBody ? <CodeBlock value={message.htmlBody} onCopy={onCopy} /> : <EmptyState description={t('inbox.noHtmlBody')} /> },
  519. { key: 'raw', label: t('inbox.rawMessage'), children: message.rawMessage ? <CodeBlock value={message.rawMessage} onCopy={onCopy} /> : <EmptyState description={t('inbox.noRawMessage')} /> }
  520. ]} />
  521. </Space>
  522. );
  523. }
  524. function normalizeMessageTab(value: string | null): MessageTab {
  525. return value === 'html' || value === 'raw' ? value : 'text';
  526. }
  527. function inboxListPath(searchParams: URLSearchParams, message: MailMessage | null) {
  528. const next = new URLSearchParams(searchParams);
  529. next.delete('tab');
  530. if (message && (!next.has('mailboxId') || !next.has('folder'))) {
  531. next.set('mailboxId', String(message.mailboxId));
  532. next.set('folder', message.folder || 'INBOX');
  533. }
  534. const suffix = next.toString();
  535. return `/inbox${suffix ? `?${suffix}` : ''}`;
  536. }
  537. function ClientConfigView({ config, onCopy, t }: { config: MailboxClientConfig; onCopy: (value: string) => void; t: (key: string) => string }) {
  538. const sections = [
  539. { key: 'imap', label: t('inbox.incomingConfig'), value: config.incoming },
  540. ...(config.pop3 ? [{ key: 'pop3', label: t('inbox.pop3Config'), value: config.pop3 }] : []),
  541. { key: 'smtp', label: t('inbox.outgoingConfig'), value: config.outgoing }
  542. ];
  543. return (
  544. <Space direction="vertical" size={16} className="full-width">
  545. <Alert type="info" showIcon message={t('inbox.clientConfigHelpSummary')} />
  546. <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>
  547. <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> }))} />
  548. </Space>
  549. );
  550. }
  551. function configValue(value: string | number, onCopy: (value: string) => void) {
  552. return <Space><Typography.Text code>{value}</Typography.Text><Button aria-label="Copy" icon={<CopyOutlined />} onClick={() => void onCopy(String(value))} /></Space>;
  553. }
  554. function fallbackFolders(mailbox?: InboundMailbox): InboundFolder[] {
  555. return standardFolders.map((name) => ({ name, specialUse: folderSpecialUse(name), messageCount: name === 'INBOX' ? mailbox?.messageCount || 0 : 0, unreadCount: name === 'INBOX' ? mailbox?.unreadCount || 0 : 0 }));
  556. }
  557. function folderSpecialUse(name: string) {
  558. const values: Record<string, string | null> = {
  559. INBOX: null,
  560. Sent: '\\Sent',
  561. Drafts: '\\Drafts',
  562. Trash: '\\Trash',
  563. Junk: '\\Junk',
  564. Archive: '\\Archive'
  565. };
  566. return values[name] ?? null;
  567. }
  568. function folderLabel(name: string, locale: string) {
  569. if (locale.startsWith('en')) return name;
  570. return { INBOX: '收件箱', Sent: '已发送', Drafts: '草稿', Trash: '已删除', Junk: '垃圾邮件', Archive: '归档' }[name] || name;
  571. }
  572. function folderIcon(name: string) {
  573. if (name === 'INBOX') return <InboxOutlined />;
  574. if (name === 'Sent') return <SendOutlined />;
  575. if (name === 'Drafts') return <FileTextOutlined />;
  576. if (name === 'Trash') return <DeleteOutlined />;
  577. if (name === 'Junk') return <WarningOutlined />;
  578. if (name === 'Archive') return <ContainerOutlined />;
  579. return <FolderOutlined />;
  580. }
  581. function buildMailboxClientConfig(mailbox: InboundMailbox, config: RuntimeConfig | null, password = ''): MailboxClientConfig {
  582. const smtpPort = preferredPort(config?.submission?.ports || [], [587, 465]);
  583. const imapPort = preferredPort(config?.mailAccess?.imap.ports || [], [993, 143]);
  584. const pop3Port = preferredPort(config?.mailAccess?.pop3.ports || [], [995, 110]);
  585. const accessHost = config?.mailAccess?.host || config?.submission?.host || config?.mailHostname || mailbox.domain;
  586. return {
  587. username: mailbox.address,
  588. password,
  589. incoming: { protocol: 'IMAP', host: accessHost, port: imapPort?.port || 143, security: imapPort?.protocol || 'IMAP + STARTTLS', authMethod: 'Normal password', username: mailbox.address, password },
  590. pop3: { protocol: 'POP3', host: accessHost, port: pop3Port?.port || 110, security: pop3Port?.protocol || 'POP3 + STLS', authMethod: 'Normal password', username: mailbox.address, password },
  591. 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 }
  592. };
  593. }
  594. function preferredPort(ports: Array<{ port: number; protocol: string }>, preferred: number[]) {
  595. for (const port of preferred) {
  596. const match = ports.find((item) => item.port === port);
  597. if (match) return match;
  598. }
  599. return ports[0] || null;
  600. }
  601. function generateMailboxPassword() {
  602. const bytes = new Uint8Array(14);
  603. globalThis.crypto.getRandomValues(bytes);
  604. return Array.from(bytes, (value) => (value % 36).toString(36)).join('');
  605. }
  606. function formatCompactTime(value: string) {
  607. const date = new Date(value);
  608. const now = new Date();
  609. return date.toDateString() === now.toDateString() ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : date.toLocaleDateString();
  610. }
  611. function formatOptionalTime(value?: string | null) {
  612. return value ? new Date(value).toLocaleString() : '—';
  613. }