App.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. import { App as AntApp, ConfigProvider, Form, Input, Modal, Select } from 'antd';
  2. import { useEffect, useMemo, useState } from 'react';
  3. import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
  4. import { AdminLayout } from '../layouts/AdminLayout';
  5. import AdminPage from '../pages/Admin';
  6. import ApiTokens from '../pages/ApiTokens';
  7. import Dashboard from '../pages/Dashboard';
  8. import DnsApi from '../pages/DnsApi';
  9. import DomainDetail from '../pages/Domains/DomainDetail';
  10. import DomainsPage from '../pages/Domains';
  11. import PlaceholderPage from '../pages/PlaceholderPage';
  12. import SendingLogs from '../pages/SendingLogs';
  13. import Settings from '../pages/Settings';
  14. import SmtpCredentials from '../pages/SmtpCredentials';
  15. import { I18nProvider, useI18n } from './i18n/react';
  16. import { buildDnsApplyFeedback } from './domain-model.js';
  17. import { api } from './services/api';
  18. import './styles.css';
  19. import type {
  20. AddDomainPayload,
  21. ApiToken,
  22. AppData,
  23. DnsCredential,
  24. Domain,
  25. DomainMode,
  26. DomainPatchPayload,
  27. RuntimeConfig,
  28. SmtpCredential,
  29. SmtpRelay,
  30. SmtpRelayPayload,
  31. User,
  32. ViewKey
  33. } from './types';
  34. const emptyData: AppData = {
  35. me: null,
  36. config: null,
  37. domains: [],
  38. events: [],
  39. analytics: null,
  40. smtpCredential: null,
  41. smtpCredentials: [],
  42. smtpRelays: [],
  43. dnsCredentials: [],
  44. apiTokens: [],
  45. settings: null,
  46. users: []
  47. };
  48. const viewTitleKeys: Record<ViewKey, string> = {
  49. dashboard: 'nav.dashboard',
  50. domains: 'nav.domains',
  51. 'dns-api': 'nav.dnsApi',
  52. smtp: 'nav.smtp',
  53. tokens: 'nav.tokens',
  54. logs: 'nav.logs',
  55. webhooks: 'nav.webhooks',
  56. admin: 'nav.admin',
  57. settings: 'nav.settings'
  58. };
  59. export default function App() {
  60. return (
  61. <ConfigProvider
  62. theme={{
  63. token: {
  64. colorPrimary: '#1677ff',
  65. borderRadius: 10,
  66. colorBgLayout: '#f5f7fb',
  67. colorBorderSecondary: '#e5eaf2'
  68. },
  69. components: {
  70. Card: {
  71. borderRadiusLG: 12
  72. },
  73. Table: {
  74. cellPaddingBlock: 14,
  75. cellPaddingInline: 14
  76. }
  77. }
  78. }}
  79. >
  80. <AntApp>
  81. <I18nProvider>
  82. <MailHubConsole />
  83. </I18nProvider>
  84. </AntApp>
  85. </ConfigProvider>
  86. );
  87. }
  88. function MailHubConsole() {
  89. const { message } = AntApp.useApp();
  90. const { t } = useI18n();
  91. const [data, setData] = useState<AppData>(emptyData);
  92. const [activeView, setActiveView] = useState<ViewKey>('dashboard');
  93. const [domainMode, setDomainMode] = useState<DomainMode>('list');
  94. const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null);
  95. const [initialDomainTab, setInitialDomainTab] = useState('overview');
  96. const [addOpen, setAddOpen] = useState(false);
  97. const [loading, setLoading] = useState(true);
  98. const [actionLoading, setActionLoading] = useState(false);
  99. const [testDomain, setTestDomain] = useState<Domain | null>(null);
  100. const [testForm] = Form.useForm();
  101. const selectedDomain = data.domains.find((domain) => domain.id === selectedDomainId) || data.domains[0] || null;
  102. useEffect(() => {
  103. void loadAll();
  104. }, []);
  105. async function loadAll() {
  106. setLoading(true);
  107. try {
  108. const me = await api.me();
  109. const [config, domains, events, analytics, smtpCredential, smtpCredentials, smtpRelays, dnsCredentials, apiTokens] = await Promise.all([
  110. api.config(),
  111. api.domains(),
  112. api.events(),
  113. api.analytics(30),
  114. api.smtpCredential(),
  115. api.smtpCredentials(),
  116. api.smtpRelays(),
  117. api.dnsCredentials(),
  118. api.apiTokens()
  119. ]);
  120. let settings: RuntimeConfig | null = null;
  121. let users: User[] = [];
  122. if (me.user.role === 'admin') {
  123. const [settingsResult, usersResult] = await Promise.all([api.adminSettings(), api.adminUsers()]);
  124. settings = settingsResult.settings;
  125. users = usersResult.users;
  126. }
  127. setData({
  128. me: me.user,
  129. config,
  130. domains: domains.domains || [],
  131. events: events.events || [],
  132. analytics: analytics.analytics || null,
  133. smtpCredential: smtpCredential.credential || null,
  134. smtpCredentials: smtpCredentials.credentials || [],
  135. smtpRelays: smtpRelays.relays || [],
  136. dnsCredentials: dnsCredentials.credentials || [],
  137. apiTokens: apiTokens.tokens || [],
  138. settings,
  139. users
  140. });
  141. setSelectedDomainId((current) => {
  142. if (current && domains.domains.some((domain) => domain.id === current)) return current;
  143. return domains.domains[0]?.id || null;
  144. });
  145. } catch (error) {
  146. const text = error instanceof Error ? error.message : t('common.error');
  147. message.error(text);
  148. if (/Authentication required/i.test(text)) window.location.href = '/login';
  149. } finally {
  150. setLoading(false);
  151. }
  152. }
  153. function replaceDomain(domain: Domain) {
  154. setData((current) => ({
  155. ...current,
  156. domains: current.domains.map((item) => item.id === domain.id ? domain : item)
  157. }));
  158. }
  159. async function runAction<T>(fn: () => Promise<T>, success?: string) {
  160. setActionLoading(true);
  161. try {
  162. const result = await fn();
  163. if (success) message.success(success);
  164. return result;
  165. } catch (error) {
  166. message.error(error instanceof Error ? error.message : t('common.error'));
  167. return null;
  168. } finally {
  169. setActionLoading(false);
  170. }
  171. }
  172. async function createDomain(values: AddDomainPayload) {
  173. const immediateCheck = Boolean(values.immediateCheck);
  174. const result = await runAction(async () => api.createDomain(values), t('actions.domainCreated'));
  175. if (!result?.domain) return;
  176. let nextDomain = result.domain;
  177. setData((current) => ({ ...current, domains: [nextDomain, ...current.domains] }));
  178. setSelectedDomainId(nextDomain.id);
  179. setActiveView('domains');
  180. setDomainMode('detail');
  181. setInitialDomainTab('dns');
  182. setAddOpen(false);
  183. if (immediateCheck) {
  184. const checked = await runAction(async () => api.checkDomain(nextDomain.id), t('actions.dnsCheckCompleted'));
  185. if (checked?.domain) {
  186. nextDomain = checked.domain;
  187. replaceDomain(nextDomain);
  188. }
  189. }
  190. }
  191. function viewDetail(domain: Domain, tab = 'overview') {
  192. setSelectedDomainId(domain.id);
  193. setInitialDomainTab(tab);
  194. setDomainMode('detail');
  195. setActiveView('domains');
  196. }
  197. async function checkDomain(domain: Domain) {
  198. const result = await runAction(async () => api.checkDomain(domain.id), t('actions.dnsCheckRefreshed'));
  199. if (result?.domain) replaceDomain(result.domain);
  200. }
  201. async function applyDns(domain: Domain) {
  202. const result = await runAction(async () => api.applyDns(domain.id));
  203. if (result) {
  204. const feedback = buildDnsApplyFeedback(result.apply, {
  205. completed: t('actions.dnsApplyCompleted'),
  206. partial: t('actions.dnsApplyPartial')
  207. });
  208. if (feedback.type === 'warning') {
  209. message.warning(feedback.message);
  210. } else {
  211. message.success(feedback.message);
  212. }
  213. }
  214. if (result?.domain) {
  215. replaceDomain(result.domain);
  216. setInitialDomainTab('dns');
  217. viewDetail(result.domain, 'dns');
  218. }
  219. }
  220. async function patchDomain(domain: Domain, values: DomainPatchPayload) {
  221. const result = await runAction(async () => api.patchDomain(domain.id, values), t('actions.domainSaved'));
  222. if (result?.domain) replaceDomain(result.domain);
  223. }
  224. async function deleteDomain(domain: Domain) {
  225. const result = await runAction(async () => api.deleteDomain(domain.id), t('actions.domainDeleted'));
  226. if (!result?.deleted) return;
  227. setData((current) => ({ ...current, domains: current.domains.filter((item) => item.id !== domain.id) }));
  228. if (selectedDomainId === domain.id) {
  229. setSelectedDomainId(null);
  230. setDomainMode('list');
  231. }
  232. }
  233. function openTestModal(domain: Domain) {
  234. setTestDomain(domain);
  235. testForm.setFieldsValue({
  236. from: `noreply@${domain.domain}`,
  237. subject: `MailHub test for ${domain.domain}`,
  238. text: `This is a MailHub test message from ${domain.domain}.`,
  239. smtpRelayId: domain.smtpRelayId || undefined
  240. });
  241. }
  242. async function submitTestMail() {
  243. if (!testDomain) return;
  244. const values = await testForm.validateFields();
  245. await runAction(async () => api.sendTest(testDomain.id, values), t('actions.testMailQueued'));
  246. setTestDomain(null);
  247. const [events, analytics] = await Promise.all([api.events(), api.analytics(30)]);
  248. setData((current) => ({ ...current, events: events.events || [], analytics: analytics.analytics || current.analytics }));
  249. }
  250. async function copy(value: string) {
  251. if (!value || value === '-') return;
  252. await navigator.clipboard.writeText(value);
  253. message.success(t('common.copied'));
  254. }
  255. async function saveDnsCredential(values: Record<string, unknown>, id?: number) {
  256. const result = await runAction(async () => api.saveDnsCredential(values, id), id ? t('actions.dnsApiUpdated') : t('actions.dnsApiCreated'));
  257. if (!result?.credential) return;
  258. setData((current) => ({
  259. ...current,
  260. dnsCredentials: id
  261. ? current.dnsCredentials.map((item) => item.id === id ? result.credential : item)
  262. : [result.credential, ...current.dnsCredentials]
  263. }));
  264. }
  265. async function testDnsCredential(credential: DnsCredential) {
  266. await runAction(async () => api.testDnsCredential(credential.id), `${credential.name} ${t('actions.dnsApiTestCompleted')}`);
  267. }
  268. async function deleteDnsCredential(credential: DnsCredential) {
  269. const result = await runAction(async () => api.deleteDnsCredential(credential.id), t('actions.dnsApiDeleted'));
  270. if (!result?.deleted) return;
  271. setData((current) => ({
  272. ...current,
  273. dnsCredentials: current.dnsCredentials.filter((item) => item.id !== credential.id),
  274. domains: current.domains.map((domain) => domain.dnsCredentialId === credential.id ? { ...domain, dnsCredentialId: null } : domain)
  275. }));
  276. }
  277. async function loadSmtpLoginCredential(id: number) {
  278. const result = await runAction(async () => api.smtpCredentialDetail(id));
  279. return result?.credential || null;
  280. }
  281. async function saveSmtpLoginCredential(values: { username: string; password?: string }, id?: number) {
  282. const result = await runAction(
  283. async () => api.saveSmtpLoginCredential(values, id),
  284. id ? t('actions.smtpUpdated') : t('actions.smtpCreated')
  285. );
  286. if (!result?.credential) return null;
  287. setData((current) => {
  288. const credentials = id
  289. ? current.smtpCredentials.map((item) => item.id === id ? result.credential : item)
  290. : [result.credential, ...current.smtpCredentials];
  291. return {
  292. ...current,
  293. smtpCredential: credentials[0] || null,
  294. smtpCredentials: credentials,
  295. config: current.config?.submission
  296. ? {
  297. ...current.config,
  298. submission: {
  299. ...current.config.submission,
  300. username: credentials[0]?.username || '',
  301. passwordSet: Boolean(credentials[0]?.passwordSet)
  302. }
  303. }
  304. : current.config
  305. };
  306. });
  307. return result.credential;
  308. }
  309. async function deleteSmtpLoginCredential(credential: SmtpCredential) {
  310. const credentialId = credential.id;
  311. if (!credentialId) return;
  312. const result = await runAction(async () => api.deleteSmtpCredential(credentialId), t('actions.smtpDeleted'));
  313. if (!result?.deleted) return;
  314. setData((current) => {
  315. const credentials = current.smtpCredentials.filter((item) => item.id !== credentialId);
  316. return {
  317. ...current,
  318. smtpCredential: credentials[0] || null,
  319. smtpCredentials: credentials,
  320. config: current.config?.submission
  321. ? {
  322. ...current.config,
  323. submission: {
  324. ...current.config.submission,
  325. username: credentials[0]?.username || '',
  326. passwordSet: Boolean(credentials[0]?.passwordSet)
  327. }
  328. }
  329. : current.config
  330. };
  331. });
  332. }
  333. async function loadSmtpRelay(id: number) {
  334. const result = await runAction(async () => api.smtpRelay(id));
  335. return result?.relay || null;
  336. }
  337. async function saveSmtpRelay(values: SmtpRelayPayload, id?: number) {
  338. const result = await runAction(
  339. async () => api.saveSmtpRelay(values, id),
  340. id ? t('actions.smtpRelayUpdated') : t('actions.smtpRelayCreated')
  341. );
  342. if (!result?.relay) return null;
  343. setData((current) => ({
  344. ...current,
  345. smtpRelays: id
  346. ? current.smtpRelays.map((item) => item.id === id ? result.relay : item)
  347. : [result.relay, ...current.smtpRelays]
  348. }));
  349. return result.relay;
  350. }
  351. async function deleteSmtpRelay(relay: SmtpRelay) {
  352. const result = await runAction(async () => api.deleteSmtpRelay(relay.id), t('actions.smtpRelayDeleted'));
  353. if (!result?.deleted) return;
  354. setData((current) => ({
  355. ...current,
  356. smtpRelays: current.smtpRelays.filter((item) => item.id !== relay.id),
  357. domains: current.domains.map((domain) => domain.smtpRelayId === relay.id ? { ...domain, smtpRelayId: null } : domain)
  358. }));
  359. }
  360. async function createApiToken(name: string) {
  361. const result = await runAction(async () => api.createApiToken(name), t('tokens.createdSuccess'));
  362. if (!result?.token) return null;
  363. setData((current) => ({ ...current, apiTokens: [result.token, ...current.apiTokens] }));
  364. return result.token;
  365. }
  366. async function deleteApiToken(token: ApiToken) {
  367. const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.deletedSuccess'));
  368. if (!result?.deleted) return;
  369. setData((current) => ({ ...current, apiTokens: current.apiTokens.filter((item) => item.id !== token.id) }));
  370. }
  371. async function saveSettings(values: Partial<RuntimeConfig>) {
  372. const result = await runAction(async () => api.saveAdminSettings(values), t('actions.settingsSaved'));
  373. if (!result?.settings) return;
  374. setData((current) => ({ ...current, settings: result.settings, config: { ...current.config, ...result.settings } as RuntimeConfig }));
  375. }
  376. async function logout() {
  377. await api.logout().catch(() => null);
  378. window.location.href = '/login';
  379. }
  380. const breadcrumb = useMemo(() => {
  381. if (activeView === 'domains' && domainMode === 'detail' && selectedDomain) return [t('nav.domains'), selectedDomain.domain];
  382. return [t(viewTitleKeys[activeView])];
  383. }, [activeView, domainMode, selectedDomain, t]);
  384. const runtimeLine = data.config
  385. ? `${data.config.mailHostname} · ${data.config.sendingIp || t('common.unsetSendingIp')}`
  386. : t('common.loadingConfig');
  387. const content = renderContent();
  388. return (
  389. <>
  390. <AdminLayout
  391. activeView={activeView}
  392. breadcrumb={breadcrumb}
  393. user={data.me}
  394. runtimeLine={runtimeLine}
  395. loading={loading}
  396. onViewChange={(view) => {
  397. setActiveView(view);
  398. if (view === 'domains') setDomainMode('list');
  399. }}
  400. onRefresh={loadAll}
  401. onAddDomain={() => setAddOpen(true)}
  402. onLogout={logout}
  403. >
  404. {content}
  405. </AdminLayout>
  406. <AddDomainDrawer
  407. open={addOpen}
  408. loading={actionLoading}
  409. config={data.config}
  410. dnsCredentials={data.dnsCredentials}
  411. smtpRelays={data.smtpRelays}
  412. onClose={() => setAddOpen(false)}
  413. onSubmit={createDomain}
  414. />
  415. <Modal
  416. title={testDomain ? `${t('testMail.title')} · ${testDomain.domain}` : t('testMail.title')}
  417. open={Boolean(testDomain)}
  418. confirmLoading={actionLoading}
  419. onCancel={() => setTestDomain(null)}
  420. onOk={submitTestMail}
  421. >
  422. <Form form={testForm} layout="vertical">
  423. <Form.Item name="from" label="From" rules={[{ required: true, message: t('testMail.fromRequired') }]}>
  424. <Input />
  425. </Form.Item>
  426. <Form.Item name="to" label="To" rules={[{ required: true, message: t('testMail.toRequired') }]}>
  427. <Input placeholder="user@example.com" />
  428. </Form.Item>
  429. <Form.Item name="subject" label="Subject">
  430. <Input />
  431. </Form.Item>
  432. <Form.Item name="text" label="Text">
  433. <Input.TextArea rows={5} />
  434. </Form.Item>
  435. <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
  436. <Select
  437. allowClear
  438. placeholder={t('smtpRelay.useResolutionOrder')}
  439. options={data.smtpRelays.map((relay) => ({
  440. value: relay.id,
  441. label: relayLabel(relay, t)
  442. }))}
  443. />
  444. </Form.Item>
  445. </Form>
  446. </Modal>
  447. </>
  448. );
  449. function renderContent() {
  450. if (activeView === 'dashboard') {
  451. return (
  452. <Dashboard
  453. analytics={data.analytics}
  454. domains={data.domains}
  455. events={data.events}
  456. config={data.config}
  457. smtpCredential={data.smtpCredential}
  458. />
  459. );
  460. }
  461. if (activeView === 'domains') {
  462. if (domainMode === 'detail' && selectedDomain) {
  463. return (
  464. <DomainDetail
  465. key={selectedDomain.id}
  466. domain={selectedDomain}
  467. config={data.config}
  468. smtpCredential={data.smtpCredential}
  469. apiTokens={data.apiTokens}
  470. events={data.events}
  471. dnsCredentials={data.dnsCredentials}
  472. smtpRelays={data.smtpRelays}
  473. actionLoading={actionLoading}
  474. initialTab={initialDomainTab}
  475. onBack={() => setDomainMode('list')}
  476. onApplyDns={applyDns}
  477. onCheck={checkDomain}
  478. onSendTest={openTestModal}
  479. onPatchDomain={patchDomain}
  480. onCopy={copy}
  481. onDelete={deleteDomain}
  482. />
  483. );
  484. }
  485. return (
  486. <DomainsPage
  487. domains={data.domains}
  488. events={data.events}
  489. dnsCredentials={data.dnsCredentials}
  490. actionLoading={actionLoading}
  491. onViewDetail={viewDetail}
  492. onApplyDns={applyDns}
  493. onCheck={checkDomain}
  494. onSendTest={openTestModal}
  495. onDelete={deleteDomain}
  496. onAddDomain={() => setAddOpen(true)}
  497. />
  498. );
  499. }
  500. if (activeView === 'dns-api') {
  501. return (
  502. <DnsApi
  503. credentials={data.dnsCredentials}
  504. loading={actionLoading}
  505. onSave={saveDnsCredential}
  506. onTest={testDnsCredential}
  507. onDelete={deleteDnsCredential}
  508. />
  509. );
  510. }
  511. if (activeView === 'smtp') {
  512. return (
  513. <SmtpCredentials
  514. config={data.config}
  515. credential={data.smtpCredential}
  516. credentials={data.smtpCredentials}
  517. relays={data.smtpRelays}
  518. loading={actionLoading}
  519. onCopy={copy}
  520. onLoadCredential={loadSmtpLoginCredential}
  521. onSaveCredential={saveSmtpLoginCredential}
  522. onDeleteCredential={deleteSmtpLoginCredential}
  523. onLoadRelay={loadSmtpRelay}
  524. onSaveRelay={saveSmtpRelay}
  525. onDeleteRelay={deleteSmtpRelay}
  526. />
  527. );
  528. }
  529. if (activeView === 'tokens') {
  530. return (
  531. <ApiTokens
  532. tokens={data.apiTokens}
  533. config={data.config}
  534. loading={actionLoading}
  535. onCreate={createApiToken}
  536. onDelete={deleteApiToken}
  537. onCopy={copy}
  538. />
  539. );
  540. }
  541. if (activeView === 'logs') {
  542. return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} />;
  543. }
  544. if (activeView === 'admin') {
  545. return <AdminPage me={data.me} />;
  546. }
  547. if (activeView === 'settings') {
  548. return (
  549. <Settings
  550. me={data.me}
  551. settings={data.settings}
  552. users={data.users}
  553. loading={actionLoading}
  554. onSave={saveSettings}
  555. />
  556. );
  557. }
  558. return <PlaceholderPage title={t(viewTitleKeys[activeView])} />;
  559. }
  560. }
  561. function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
  562. return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
  563. }