| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702 |
- import { App as AntApp, ConfigProvider, Form, Input, Modal, Select } from 'antd';
- import { useEffect, useMemo, useState } from 'react';
- import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
- import { AdminLayout } from '../layouts/AdminLayout';
- import AdminPage from '../pages/Admin';
- import ApiTokens from '../pages/ApiTokens';
- import Dashboard from '../pages/Dashboard';
- import DnsApi from '../pages/DnsApi';
- import DomainDetail from '../pages/Domains/DomainDetail';
- import DomainsPage from '../pages/Domains';
- import Inbox from '../pages/Inbox';
- import PlaceholderPage from '../pages/PlaceholderPage';
- import SendingLogs from '../pages/SendingLogs';
- import Settings from '../pages/Settings';
- import SmtpCredentials from '../pages/SmtpCredentials';
- import Webhooks from '../pages/Webhooks';
- import { I18nProvider, useI18n } from './i18n/react';
- import { buildDnsApplyFeedback } from './domain-model.js';
- import { api } from './services/api';
- import './styles.css';
- import { mailhubTheme } from './theme';
- import type {
- AddDomainPayload,
- ApiToken,
- AppData,
- DnsCredential,
- Domain,
- DomainMode,
- DomainPatchPayload,
- InboundMailbox,
- InboundMessage,
- MailboxClientConfig,
- RuntimeConfig,
- SmtpCredential,
- SmtpRelay,
- SmtpRelayPayload,
- User,
- ViewKey
- } from './types';
- const emptyData: AppData = {
- me: null,
- config: null,
- domains: [],
- events: [],
- analytics: null,
- smtpCredential: null,
- smtpCredentials: [],
- smtpRelays: [],
- inboundMailboxes: [],
- inboundMessages: [],
- dnsCredentials: [],
- apiTokens: [],
- settings: null,
- users: []
- };
- const viewTitleKeys: Record<ViewKey, string> = {
- dashboard: 'nav.dashboard',
- domains: 'nav.domains',
- 'dns-api': 'nav.dnsApi',
- smtp: 'nav.smtp',
- inbox: 'nav.inbox',
- tokens: 'nav.tokens',
- logs: 'nav.logs',
- webhooks: 'nav.webhooks',
- admin: 'nav.admin',
- settings: 'nav.settings'
- };
- export default function App() {
- return (
- <ConfigProvider theme={mailhubTheme}>
- <AntApp>
- <I18nProvider>
- <MailHubConsole />
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- }
- function MailHubConsole() {
- const { message } = AntApp.useApp();
- const { t } = useI18n();
- const [data, setData] = useState<AppData>(emptyData);
- const [activeView, setActiveView] = useState<ViewKey>('dashboard');
- const [domainMode, setDomainMode] = useState<DomainMode>('list');
- const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null);
- const [initialDomainTab, setInitialDomainTab] = useState('overview');
- const [addOpen, setAddOpen] = useState(false);
- const [loading, setLoading] = useState(true);
- const [actionLoading, setActionLoading] = useState(false);
- const [testDomain, setTestDomain] = useState<Domain | null>(null);
- const [testForm] = Form.useForm();
- const selectedDomain = data.domains.find((domain) => domain.id === selectedDomainId) || data.domains[0] || null;
- useEffect(() => {
- void loadAll();
- }, []);
- async function loadAll() {
- setLoading(true);
- try {
- const me = await api.me();
- const [
- config,
- domains,
- events,
- inboundMailboxes,
- inboundMessages,
- analytics,
- smtpCredential,
- smtpCredentials,
- smtpRelays,
- dnsCredentials,
- apiTokens
- ] = await Promise.all([
- api.config(),
- api.domains(),
- api.events(),
- api.inboundMailboxes(),
- api.inboundMessages(),
- api.analytics(7),
- api.smtpCredential(),
- api.smtpCredentials(),
- api.smtpRelays(),
- api.dnsCredentials(),
- api.apiTokens()
- ]);
- let settings: RuntimeConfig | null = null;
- let users: User[] = [];
- if (me.user.role === 'admin') {
- const [settingsResult, usersResult] = await Promise.all([api.adminSettings(), api.adminUsers()]);
- settings = settingsResult.settings;
- users = usersResult.users;
- }
- setData({
- me: me.user,
- config,
- domains: domains.domains || [],
- events: events.events || [],
- inboundMailboxes: inboundMailboxes.mailboxes || [],
- inboundMessages: inboundMessages.messages || [],
- analytics: analytics.analytics || null,
- smtpCredential: smtpCredential.credential || null,
- smtpCredentials: smtpCredentials.credentials || [],
- smtpRelays: smtpRelays.relays || [],
- dnsCredentials: dnsCredentials.credentials || [],
- apiTokens: apiTokens.tokens || [],
- settings,
- users
- });
- setSelectedDomainId((current) => {
- if (current && domains.domains.some((domain) => domain.id === current)) return current;
- return domains.domains[0]?.id || null;
- });
- } catch (error) {
- const text = error instanceof Error ? error.message : t('common.error');
- message.error(text);
- if (/Authentication required/i.test(text)) window.location.href = '/login';
- } finally {
- setLoading(false);
- }
- }
- function replaceDomain(domain: Domain) {
- setData((current) => ({
- ...current,
- domains: current.domains.map((item) => item.id === domain.id ? domain : item)
- }));
- }
- async function runAction<T>(fn: () => Promise<T>, success?: string) {
- setActionLoading(true);
- try {
- const result = await fn();
- if (success) message.success(success);
- return result;
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- return null;
- } finally {
- setActionLoading(false);
- }
- }
- async function createDomain(values: AddDomainPayload) {
- const immediateCheck = Boolean(values.immediateCheck);
- const result = await runAction(async () => api.createDomain(values), t('actions.domainCreated'));
- if (!result?.domain) return;
- let nextDomain = result.domain;
- setData((current) => ({ ...current, domains: [nextDomain, ...current.domains] }));
- setSelectedDomainId(nextDomain.id);
- setActiveView('domains');
- setDomainMode('detail');
- setInitialDomainTab('dns');
- setAddOpen(false);
- if (immediateCheck) {
- const checked = await runAction(async () => api.checkDomain(nextDomain.id), t('actions.dnsCheckCompleted'));
- if (checked?.domain) {
- nextDomain = checked.domain;
- replaceDomain(nextDomain);
- }
- }
- }
- function viewDetail(domain: Domain, tab = 'overview') {
- setSelectedDomainId(domain.id);
- setInitialDomainTab(tab);
- setDomainMode('detail');
- setActiveView('domains');
- }
- async function checkDomain(domain: Domain) {
- const result = await runAction(async () => api.checkDomain(domain.id), t('actions.dnsCheckRefreshed'));
- if (result?.domain) replaceDomain(result.domain);
- }
- async function applyDns(domain: Domain) {
- const result = await runAction(async () => api.applyDns(domain.id));
- if (result) {
- const feedback = buildDnsApplyFeedback(result.apply, {
- completed: t('actions.dnsApplyCompleted'),
- partial: t('actions.dnsApplyPartial')
- });
- if (feedback.type === 'warning') {
- message.warning(feedback.message);
- } else {
- message.success(feedback.message);
- }
- }
- if (result?.domain) {
- replaceDomain(result.domain);
- setInitialDomainTab('dns');
- viewDetail(result.domain, 'dns');
- }
- }
- async function patchDomain(domain: Domain, values: DomainPatchPayload) {
- const result = await runAction(async () => api.patchDomain(domain.id, values), t('actions.domainSaved'));
- if (result?.domain) replaceDomain(result.domain);
- }
- async function deleteDomain(domain: Domain) {
- const result = await runAction(async () => api.deleteDomain(domain.id), t('actions.domainDeleted'));
- if (!result?.deleted) return;
- setData((current) => ({ ...current, domains: current.domains.filter((item) => item.id !== domain.id) }));
- if (selectedDomainId === domain.id) {
- setSelectedDomainId(null);
- setDomainMode('list');
- }
- }
- function openTestModal(domain: Domain) {
- setTestDomain(domain);
- testForm.setFieldsValue({
- from: `noreply@${domain.domain}`,
- subject: `MailHub test for ${domain.domain}`,
- text: `This is a MailHub test message from ${domain.domain}.`,
- html: `<p>This is a MailHub test message from ${domain.domain}.</p><p><a href="${window.location.origin}/login">Open MailHub</a></p>`,
- smtpRelayId: domain.smtpRelayId || undefined
- });
- }
- async function submitTestMail() {
- if (!testDomain) return;
- const values = await testForm.validateFields();
- await runAction(async () => api.sendTest(testDomain.id, values), t('actions.testMailQueued'));
- setTestDomain(null);
- const [events, analytics] = await Promise.all([api.events(), api.analytics(7)]);
- setData((current) => ({ ...current, events: events.events || [], analytics: analytics.analytics || current.analytics }));
- }
- async function loadSendEvent(id: number) {
- const result = await api.event(id);
- return result.event;
- }
- async function copy(value: string) {
- if (!value || value === '-') return;
- await navigator.clipboard.writeText(value);
- message.success(t('common.copied'));
- }
- async function saveDnsCredential(values: Record<string, unknown>, id?: number) {
- const result = await runAction(async () => api.saveDnsCredential(values, id), id ? t('actions.dnsApiUpdated') : t('actions.dnsApiCreated'));
- if (!result?.credential) return;
- setData((current) => ({
- ...current,
- dnsCredentials: id
- ? current.dnsCredentials.map((item) => item.id === id ? result.credential : item)
- : [result.credential, ...current.dnsCredentials]
- }));
- }
- async function testDnsCredential(credential: DnsCredential) {
- await runAction(async () => api.testDnsCredential(credential.id), `${credential.name} ${t('actions.dnsApiTestCompleted')}`);
- }
- async function deleteDnsCredential(credential: DnsCredential) {
- const result = await runAction(async () => api.deleteDnsCredential(credential.id), t('actions.dnsApiDeleted'));
- if (!result?.deleted) return;
- setData((current) => ({
- ...current,
- dnsCredentials: current.dnsCredentials.filter((item) => item.id !== credential.id),
- domains: current.domains.map((domain) => domain.dnsCredentialId === credential.id ? { ...domain, dnsCredentialId: null } : domain)
- }));
- }
- async function loadSmtpLoginCredential(id: number) {
- const result = await runAction(async () => api.smtpCredentialDetail(id));
- return result?.credential || null;
- }
- async function saveSmtpLoginCredential(values: { username: string; password?: string }, id?: number) {
- const result = await runAction(
- async () => api.saveSmtpLoginCredential(values, id),
- id ? t('actions.smtpUpdated') : t('actions.smtpCreated')
- );
- if (!result?.credential) return null;
- setData((current) => {
- const credentials = id
- ? current.smtpCredentials.map((item) => item.id === id ? result.credential : item)
- : [result.credential, ...current.smtpCredentials];
- return {
- ...current,
- smtpCredential: credentials[0] || null,
- smtpCredentials: credentials,
- config: current.config?.submission
- ? {
- ...current.config,
- submission: {
- ...current.config.submission,
- username: credentials[0]?.username || '',
- passwordSet: Boolean(credentials[0]?.passwordSet)
- }
- }
- : current.config
- };
- });
- return result.credential;
- }
- async function deleteSmtpLoginCredential(credential: SmtpCredential) {
- const credentialId = credential.id;
- if (!credentialId) return;
- const result = await runAction(async () => api.deleteSmtpCredential(credentialId), t('actions.smtpDeleted'));
- if (!result?.deleted) return;
- setData((current) => {
- const credentials = current.smtpCredentials.filter((item) => item.id !== credentialId);
- return {
- ...current,
- smtpCredential: credentials[0] || null,
- smtpCredentials: credentials,
- config: current.config?.submission
- ? {
- ...current.config,
- submission: {
- ...current.config.submission,
- username: credentials[0]?.username || '',
- passwordSet: Boolean(credentials[0]?.passwordSet)
- }
- }
- : current.config
- };
- });
- }
- async function loadSmtpRelay(id: number) {
- const result = await runAction(async () => api.smtpRelay(id));
- return result?.relay || null;
- }
- async function saveSmtpRelay(values: SmtpRelayPayload, id?: number) {
- const result = await runAction(
- async () => api.saveSmtpRelay(values, id),
- id ? t('actions.smtpRelayUpdated') : t('actions.smtpRelayCreated')
- );
- if (!result?.relay) return null;
- setData((current) => ({
- ...current,
- smtpRelays: id
- ? current.smtpRelays.map((item) => item.id === id ? result.relay : item)
- : [result.relay, ...current.smtpRelays]
- }));
- return result.relay;
- }
- async function deleteSmtpRelay(relay: SmtpRelay) {
- const result = await runAction(async () => api.deleteSmtpRelay(relay.id), t('actions.smtpRelayDeleted'));
- if (!result?.deleted) return;
- setData((current) => ({
- ...current,
- smtpRelays: current.smtpRelays.filter((item) => item.id !== relay.id),
- domains: current.domains.map((domain) => domain.smtpRelayId === relay.id ? { ...domain, smtpRelayId: null } : domain)
- }));
- }
- async function createInboundMailbox(values: {
- address: string;
- displayName?: string;
- password: string;
- aliases?: string;
- forwardTo?: string;
- keepForwarded?: boolean;
- quotaMb?: number | string | null;
- }): Promise<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig } | null> {
- const result = await runAction(async () => api.createInboundMailbox(values), t('actions.inboundMailboxCreated'));
- if (!result?.mailbox) return null;
- setData((current) => ({
- ...current,
- inboundMailboxes: [result.mailbox, ...current.inboundMailboxes]
- }));
- return result;
- }
- async function loadInboundMessages(mailboxId?: number | null) {
- const result = await runAction(async () => api.inboundMessages(mailboxId));
- if (!result?.messages) return [];
- setData((current) => ({ ...current, inboundMessages: result.messages }));
- return result.messages;
- }
- async function loadInboundMessage(id: number): Promise<InboundMessage | null> {
- const result = await api.inboundMessage(id);
- let inboundMessage = result.message;
- if (inboundMessage && !inboundMessage.read) {
- const readResult = await api.markInboundMessageRead(id, true);
- inboundMessage = readResult.message || { ...inboundMessage, read: true };
- }
- if (inboundMessage) {
- setData((current) => {
- const previous = current.inboundMessages.find((item) => item.id === inboundMessage.id);
- const shouldDecrementUnread = Boolean(previous && !previous.read && inboundMessage.read);
- return {
- ...current,
- inboundMessages: current.inboundMessages.map((item) => (
- item.id === inboundMessage.id ? { ...item, ...inboundMessage } : item
- )),
- inboundMailboxes: current.inboundMailboxes.map((mailbox) => (
- mailbox.id === inboundMessage.mailboxId && shouldDecrementUnread
- ? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount - 1) }
- : mailbox
- ))
- };
- });
- }
- return inboundMessage;
- }
- async function createApiToken(values: { name: string; scopes: string[]; expiresAt?: string | null }) {
- const result = await runAction(async () => api.createApiToken(values), t('tokens.createdSuccess'));
- if (!result?.token) return null;
- setData((current) => ({ ...current, apiTokens: [result.token, ...current.apiTokens] }));
- return result.token;
- }
- async function updateApiToken(token: ApiToken, values: { name: string; scopes: string[]; expiresAt?: string | null }) {
- const result = await runAction(async () => api.updateApiToken(token.id, values), t('tokens.updatedSuccess'));
- if (!result?.token) return;
- setData((current) => ({
- ...current,
- apiTokens: current.apiTokens.map((item) => item.id === result.token.id ? result.token : item)
- }));
- }
- async function revokeApiToken(token: ApiToken) {
- const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.revokedSuccess'));
- if (!result?.token) return;
- setData((current) => ({
- ...current,
- apiTokens: current.apiTokens.map((item) => item.id === result.token?.id ? result.token : item)
- }));
- }
- async function saveSettings(values: Partial<RuntimeConfig>) {
- const result = await runAction(async () => api.saveAdminSettings(values), t('actions.settingsSaved'));
- if (!result?.settings) return;
- setData((current) => ({ ...current, settings: result.settings, config: { ...current.config, ...result.settings } as RuntimeConfig }));
- }
- async function logout() {
- await api.logout().catch(() => null);
- window.location.href = '/login';
- }
- const breadcrumb = useMemo(() => {
- if (activeView === 'domains' && domainMode === 'detail' && selectedDomain) return [t('nav.domains'), selectedDomain.domain];
- return [t(viewTitleKeys[activeView])];
- }, [activeView, domainMode, selectedDomain, t]);
- const runtimeLine = data.config
- ? `${data.config.mailHostname} · ${data.config.sendingIp || t('common.unsetSendingIp')}`
- : t('common.loadingConfig');
- const content = renderContent();
- return (
- <>
- <AdminLayout
- activeView={activeView}
- breadcrumb={breadcrumb}
- user={data.me}
- runtimeLine={runtimeLine}
- loading={loading}
- onViewChange={(view) => {
- setActiveView(view);
- if (view === 'domains') setDomainMode('list');
- }}
- onRefresh={loadAll}
- onAddDomain={() => setAddOpen(true)}
- onLogout={logout}
- >
- {content}
- </AdminLayout>
- <AddDomainDrawer
- open={addOpen}
- loading={actionLoading}
- config={data.config}
- dnsCredentials={data.dnsCredentials}
- smtpRelays={data.smtpRelays}
- onClose={() => setAddOpen(false)}
- onSubmit={createDomain}
- />
- <Modal
- title={testDomain ? `${t('testMail.title')} · ${testDomain.domain}` : t('testMail.title')}
- open={Boolean(testDomain)}
- confirmLoading={actionLoading}
- onCancel={() => setTestDomain(null)}
- onOk={submitTestMail}
- >
- <Form form={testForm} layout="vertical">
- <Form.Item name="from" label="From" rules={[{ required: true, message: t('testMail.fromRequired') }]}>
- <Input />
- </Form.Item>
- <Form.Item name="to" label="To" rules={[{ required: true, message: t('testMail.toRequired') }]}>
- <Input placeholder="user@example.com" />
- </Form.Item>
- <Form.Item name="subject" label="Subject">
- <Input />
- </Form.Item>
- <Form.Item name="text" label="Text">
- <Input.TextArea rows={5} />
- </Form.Item>
- <Form.Item name="html" label="HTML">
- <Input.TextArea rows={5} />
- </Form.Item>
- <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
- <Select
- allowClear
- placeholder={t('smtpRelay.useResolutionOrder')}
- options={data.smtpRelays.map((relay) => ({
- value: relay.id,
- label: relayLabel(relay, t)
- }))}
- />
- </Form.Item>
- </Form>
- </Modal>
- </>
- );
- function renderContent() {
- if (activeView === 'dashboard') {
- return (
- <Dashboard
- analytics={data.analytics}
- domains={data.domains}
- events={data.events}
- config={data.config}
- smtpCredential={data.smtpCredential}
- />
- );
- }
- if (activeView === 'domains') {
- if (domainMode === 'detail' && selectedDomain) {
- return (
- <DomainDetail
- key={selectedDomain.id}
- domain={selectedDomain}
- config={data.config}
- smtpCredential={data.smtpCredential}
- apiTokens={data.apiTokens}
- events={data.events}
- dnsCredentials={data.dnsCredentials}
- smtpRelays={data.smtpRelays}
- actionLoading={actionLoading}
- initialTab={initialDomainTab}
- onBack={() => setDomainMode('list')}
- onApplyDns={applyDns}
- onCheck={checkDomain}
- onSendTest={openTestModal}
- onPatchDomain={patchDomain}
- onCopy={copy}
- onDelete={deleteDomain}
- />
- );
- }
- return (
- <DomainsPage
- domains={data.domains}
- events={data.events}
- dnsCredentials={data.dnsCredentials}
- actionLoading={actionLoading}
- onViewDetail={viewDetail}
- onApplyDns={applyDns}
- onCheck={checkDomain}
- onSendTest={openTestModal}
- onDelete={deleteDomain}
- onAddDomain={() => setAddOpen(true)}
- />
- );
- }
- if (activeView === 'dns-api') {
- return (
- <DnsApi
- credentials={data.dnsCredentials}
- loading={actionLoading}
- onSave={saveDnsCredential}
- onTest={testDnsCredential}
- onDelete={deleteDnsCredential}
- />
- );
- }
- if (activeView === 'smtp') {
- return (
- <SmtpCredentials
- config={data.config}
- credential={data.smtpCredential}
- credentials={data.smtpCredentials}
- relays={data.smtpRelays}
- loading={actionLoading}
- onCopy={copy}
- onLoadCredential={loadSmtpLoginCredential}
- onSaveCredential={saveSmtpLoginCredential}
- onDeleteCredential={deleteSmtpLoginCredential}
- onLoadRelay={loadSmtpRelay}
- onSaveRelay={saveSmtpRelay}
- onDeleteRelay={deleteSmtpRelay}
- />
- );
- }
- if (activeView === 'inbox') {
- return (
- <Inbox
- config={data.config}
- domains={data.domains}
- mailboxes={data.inboundMailboxes}
- messages={data.inboundMessages}
- loading={actionLoading}
- onCreateMailbox={createInboundMailbox}
- onPatchDomain={patchDomain}
- onLoadMessages={loadInboundMessages}
- onLoadMessage={loadInboundMessage}
- onCopy={copy}
- onAddDomain={() => setAddOpen(true)}
- />
- );
- }
- if (activeView === 'tokens') {
- return (
- <ApiTokens
- tokens={data.apiTokens}
- config={data.config}
- loading={actionLoading}
- onCreate={createApiToken}
- onUpdate={updateApiToken}
- onRevoke={revokeApiToken}
- onCopy={copy}
- />
- );
- }
- if (activeView === 'logs') {
- return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} onLoadEvent={loadSendEvent} />;
- }
- if (activeView === 'webhooks') {
- return <Webhooks domains={data.domains} onCopy={copy} />;
- }
- if (activeView === 'admin') {
- return <AdminPage me={data.me} />;
- }
- if (activeView === 'settings') {
- return (
- <Settings
- me={data.me}
- settings={data.settings}
- users={data.users}
- loading={actionLoading}
- onSave={saveSettings}
- />
- );
- }
- return <PlaceholderPage title={t(viewTitleKeys[activeView])} />;
- }
- }
- function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
- return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
- }
|