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