|
@@ -2,6 +2,7 @@ import {
|
|
|
ContainerOutlined,
|
|
ContainerOutlined,
|
|
|
CopyOutlined,
|
|
CopyOutlined,
|
|
|
DeleteOutlined,
|
|
DeleteOutlined,
|
|
|
|
|
+ EditOutlined,
|
|
|
FileTextOutlined,
|
|
FileTextOutlined,
|
|
|
FolderOutlined,
|
|
FolderOutlined,
|
|
|
InboxOutlined,
|
|
InboxOutlined,
|
|
@@ -64,15 +65,19 @@ interface MailboxFormValues {
|
|
|
aliases?: string;
|
|
aliases?: string;
|
|
|
forwardTo?: string;
|
|
forwardTo?: string;
|
|
|
keepForwarded?: boolean;
|
|
keepForwarded?: boolean;
|
|
|
|
|
+ status?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
|
|
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
|
|
|
type MessageTab = 'text' | 'html' | 'raw';
|
|
type MessageTab = 'text' | 'html' | 'raw';
|
|
|
|
|
+type MailFolder = InboundFolder & { countsAvailable: boolean };
|
|
|
|
|
+type MailboxSort = 'activity' | 'address' | 'unread' | 'messages';
|
|
|
|
|
+type RouteSort = 'domain' | 'configured';
|
|
|
|
|
|
|
|
export default function Inbox() {
|
|
export default function Inbox() {
|
|
|
const { message } = AntApp.useApp();
|
|
const { message } = AntApp.useApp();
|
|
|
const screens = Grid.useBreakpoint();
|
|
const screens = Grid.useBreakpoint();
|
|
|
- const isDesktop = useMediaQuery('(min-width: 1024px)');
|
|
|
|
|
|
|
+ const isDesktop = useMediaQuery('(min-width: 1280px)');
|
|
|
const { locale, t } = useI18n();
|
|
const { locale, t } = useI18n();
|
|
|
const { config } = useAppContext();
|
|
const { config } = useAppContext();
|
|
|
const location = useLocation();
|
|
const location = useLocation();
|
|
@@ -83,22 +88,36 @@ export default function Inbox() {
|
|
|
const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
|
|
const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
|
|
|
const [domains, setDomains] = useState<Domain[]>([]);
|
|
const [domains, setDomains] = useState<Domain[]>([]);
|
|
|
const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
|
|
const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
|
|
|
- const [folders, setFolders] = useState<InboundFolder[]>(fallbackFolders());
|
|
|
|
|
|
|
+ const [folders, setFolders] = useState<MailFolder[]>(fallbackFolders());
|
|
|
const [messages, setMessages] = useState<MailMessage[]>([]);
|
|
const [messages, setMessages] = useState<MailMessage[]>([]);
|
|
|
const [total, setTotal] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
|
const [selectedMessage, setSelectedMessage] = useState<MailMessage | null>(null);
|
|
const [selectedMessage, setSelectedMessage] = useState<MailMessage | null>(null);
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
|
+ const [foldersLoading, setFoldersLoading] = useState(false);
|
|
|
const [loadError, setLoadError] = useState('');
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
|
+ const [domainsError, setDomainsError] = useState('');
|
|
|
const [messagesError, setMessagesError] = useState('');
|
|
const [messagesError, setMessagesError] = useState('');
|
|
|
const [detailError, setDetailError] = useState('');
|
|
const [detailError, setDetailError] = useState('');
|
|
|
|
|
+ const [foldersError, setFoldersError] = useState('');
|
|
|
|
|
+ const [readMutationError, setReadMutationError] = useState('');
|
|
|
const [actionKey, setActionKey] = useState('');
|
|
const [actionKey, setActionKey] = useState('');
|
|
|
const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
|
|
const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
|
|
|
const [mailboxOpen, setMailboxOpen] = useState(false);
|
|
const [mailboxOpen, setMailboxOpen] = useState(false);
|
|
|
|
|
+ const [editingMailbox, setEditingMailbox] = useState<InboundMailbox | null>(null);
|
|
|
|
|
+ const [mailboxListQuery, setMailboxListQuery] = useState('');
|
|
|
|
|
+ const [mailboxSort, setMailboxSort] = useState<MailboxSort>('activity');
|
|
|
|
|
+ const [routeListQuery, setRouteListQuery] = useState('');
|
|
|
|
|
+ const [routeSort, setRouteSort] = useState<RouteSort>('domain');
|
|
|
const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
|
|
const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
|
|
|
const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
|
|
const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
|
|
|
const pendingDirectClose = useRef<string | null>(null);
|
|
const pendingDirectClose = useRef<string | null>(null);
|
|
|
|
|
+ const baseRequestId = useRef(0);
|
|
|
|
|
+ const foldersRequestId = useRef(0);
|
|
|
|
|
+ const messagesRequestId = useRef(0);
|
|
|
|
|
+ const detailRequestId = useRef(0);
|
|
|
|
|
+ const readMutationRequestId = useRef(0);
|
|
|
|
|
|
|
|
const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
|
|
const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
|
|
|
const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
|
|
const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
|
|
@@ -109,60 +128,97 @@ export default function Inbox() {
|
|
|
const pageSize = 25;
|
|
const pageSize = 25;
|
|
|
const routeMessageId = Number(params.messageId || 0) || null;
|
|
const routeMessageId = Number(params.messageId || 0) || null;
|
|
|
const messageTab = normalizeMessageTab(searchParams.get('tab'));
|
|
const messageTab = normalizeMessageTab(searchParams.get('tab'));
|
|
|
- const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) || null;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setSearchDraft(query);
|
|
|
|
|
+ }, [query]);
|
|
|
|
|
+
|
|
|
|
|
+ const mailboxOptions = useMemo(() => mailboxes.map((item) => ({
|
|
|
|
|
+ value: item.id,
|
|
|
|
|
+ label: locale.startsWith('en')
|
|
|
|
|
+ ? `${item.address} · ${item.unreadCount} unread / ${item.messageCount} total`
|
|
|
|
|
+ : `${item.address} · ${item.unreadCount} 未读 / ${item.messageCount} 封`
|
|
|
|
|
+ })), [locale, mailboxes]);
|
|
|
|
|
+ const visibleMailboxes = useMemo(
|
|
|
|
|
+ () => sortMailboxes(filterMailboxes(mailboxes, mailboxListQuery), mailboxSort),
|
|
|
|
|
+ [mailboxListQuery, mailboxSort, mailboxes]
|
|
|
|
|
+ );
|
|
|
|
|
+ const visibleRoutes = useMemo(
|
|
|
|
|
+ () => sortRoutes(filterRoutes(domains, routeListQuery), routeSort),
|
|
|
|
|
+ [domains, routeListQuery, routeSort]
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
const loadBase = useCallback(async () => {
|
|
const loadBase = useCallback(async () => {
|
|
|
|
|
+ const requestId = ++baseRequestId.current;
|
|
|
setLoading(true);
|
|
setLoading(true);
|
|
|
setLoadError('');
|
|
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);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ setDomainsError('');
|
|
|
|
|
+ const [domainResult, mailboxResult] = await Promise.allSettled([
|
|
|
|
|
+ api.domains(), api.inboundMailboxes()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ if (requestId !== baseRequestId.current) return;
|
|
|
|
|
+ if (domainResult.status === 'fulfilled') setDomains(domainResult.value.domains || []);
|
|
|
|
|
+ else setDomainsError(domainResult.reason instanceof Error ? domainResult.reason.message : t('common.error'));
|
|
|
|
|
+ if (mailboxResult.status === 'fulfilled') setMailboxes(mailboxResult.value.mailboxes || []);
|
|
|
|
|
+ else setLoadError(mailboxResult.reason instanceof Error ? mailboxResult.reason.message : t('common.error'));
|
|
|
|
|
+ setLoading(false);
|
|
|
}, [t]);
|
|
}, [t]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
void loadBase();
|
|
void loadBase();
|
|
|
|
|
+ return () => { baseRequestId.current += 1; };
|
|
|
}, [loadBase]);
|
|
}, [loadBase]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!mailboxes.length) return;
|
|
if (!mailboxes.length) return;
|
|
|
if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
|
|
if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
|
|
|
if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
|
|
if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
|
|
|
|
|
+ const preferredId = preferredMailboxId(mailboxes, selectedMailboxId);
|
|
|
|
|
+ if (!preferredId) return;
|
|
|
const next = new URLSearchParams(searchParams);
|
|
const next = new URLSearchParams(searchParams);
|
|
|
- next.set('mailboxId', String(mailboxes[0].id));
|
|
|
|
|
|
|
+ next.set('mailboxId', String(preferredId));
|
|
|
next.set('folder', 'INBOX');
|
|
next.set('folder', 'INBOX');
|
|
|
next.set('page', '1');
|
|
next.set('page', '1');
|
|
|
setSearchParams(next, { replace: true });
|
|
setSearchParams(next, { replace: true });
|
|
|
}, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
|
|
}, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
|
|
|
|
|
|
|
|
const loadFolders = useCallback(async () => {
|
|
const loadFolders = useCallback(async () => {
|
|
|
|
|
+ const requestId = ++foldersRequestId.current;
|
|
|
if (!selectedMailboxId) {
|
|
if (!selectedMailboxId) {
|
|
|
setFolders(fallbackFolders());
|
|
setFolders(fallbackFolders());
|
|
|
|
|
+ setFoldersError('');
|
|
|
|
|
+ setFoldersLoading(false);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ setFoldersLoading(true);
|
|
|
|
|
+ setFoldersError('');
|
|
|
|
|
+ setFolders(fallbackFolders());
|
|
|
try {
|
|
try {
|
|
|
const result = await api.inboundFolders(selectedMailboxId);
|
|
const result = await api.inboundFolders(selectedMailboxId);
|
|
|
- setFolders(result.folders?.length ? result.folders : fallbackFolders(selectedMailbox || undefined));
|
|
|
|
|
- } catch {
|
|
|
|
|
- setFolders(fallbackFolders(selectedMailbox || undefined));
|
|
|
|
|
|
|
+ if (requestId !== foldersRequestId.current) return;
|
|
|
|
|
+ if (!result.folders?.length) throw new Error(locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。');
|
|
|
|
|
+ setFolders(result.folders.map((item) => ({ ...item, countsAvailable: true })));
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (requestId !== foldersRequestId.current) return;
|
|
|
|
|
+ setFolders(fallbackFolders());
|
|
|
|
|
+ setFoldersError(error instanceof Error ? error.message : (locale.startsWith('en') ? 'Unable to load folder counts.' : '文件夹计数加载失败。'));
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (requestId === foldersRequestId.current) setFoldersLoading(false);
|
|
|
}
|
|
}
|
|
|
- }, [selectedMailbox, selectedMailboxId]);
|
|
|
|
|
|
|
+ }, [locale, selectedMailboxId]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
void loadFolders();
|
|
void loadFolders();
|
|
|
|
|
+ return () => { foldersRequestId.current += 1; };
|
|
|
}, [loadFolders]);
|
|
}, [loadFolders]);
|
|
|
|
|
|
|
|
const loadMessages = useCallback(async () => {
|
|
const loadMessages = useCallback(async () => {
|
|
|
|
|
+ const requestId = ++messagesRequestId.current;
|
|
|
if (!selectedMailboxId || workspace !== 'messages') {
|
|
if (!selectedMailboxId || workspace !== 'messages') {
|
|
|
setMessages([]);
|
|
setMessages([]);
|
|
|
setTotal(0);
|
|
setTotal(0);
|
|
|
|
|
+ setMessagesError('');
|
|
|
|
|
+ setMessagesLoading(false);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
setMessagesLoading(true);
|
|
setMessagesLoading(true);
|
|
@@ -176,31 +232,38 @@ export default function Inbox() {
|
|
|
q: query || undefined,
|
|
q: query || undefined,
|
|
|
read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
|
|
read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
|
|
|
});
|
|
});
|
|
|
|
|
+ if (requestId !== messagesRequestId.current) return;
|
|
|
setMessages(result.messages || []);
|
|
setMessages(result.messages || []);
|
|
|
setTotal(result.total ?? result.messages?.length ?? 0);
|
|
setTotal(result.total ?? result.messages?.length ?? 0);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- setMessagesError(error instanceof Error ? error.message : t('common.error'));
|
|
|
|
|
|
|
+ if (requestId === messagesRequestId.current) {
|
|
|
|
|
+ setMessagesError(error instanceof Error ? error.message : t('common.error'));
|
|
|
|
|
+ }
|
|
|
} finally {
|
|
} finally {
|
|
|
- setMessagesLoading(false);
|
|
|
|
|
|
|
+ if (requestId === messagesRequestId.current) setMessagesLoading(false);
|
|
|
}
|
|
}
|
|
|
}, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
|
|
}, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
void loadMessages();
|
|
void loadMessages();
|
|
|
|
|
+ return () => { messagesRequestId.current += 1; };
|
|
|
}, [loadMessages]);
|
|
}, [loadMessages]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
|
|
+ const requestId = ++detailRequestId.current;
|
|
|
|
|
+ readMutationRequestId.current += 1;
|
|
|
|
|
+ setReadMutationError('');
|
|
|
if (!routeMessageId) {
|
|
if (!routeMessageId) {
|
|
|
setSelectedMessage(null);
|
|
setSelectedMessage(null);
|
|
|
setDetailError('');
|
|
setDetailError('');
|
|
|
|
|
+ setDetailLoading(false);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- let active = true;
|
|
|
|
|
setDetailLoading(true);
|
|
setDetailLoading(true);
|
|
|
setDetailError('');
|
|
setDetailError('');
|
|
|
void api.inboundMessage(routeMessageId)
|
|
void api.inboundMessage(routeMessageId)
|
|
|
- .then(async (result) => {
|
|
|
|
|
- if (!active) return;
|
|
|
|
|
|
|
+ .then((result) => {
|
|
|
|
|
+ if (requestId !== detailRequestId.current) return;
|
|
|
const detail = result.message as MailMessage | null;
|
|
const detail = result.message as MailMessage | null;
|
|
|
if (!detail) {
|
|
if (!detail) {
|
|
|
setDetailError(t('inbox.messageNotFound'));
|
|
setDetailError(t('inbox.messageNotFound'));
|
|
@@ -208,21 +271,34 @@ export default function Inbox() {
|
|
|
}
|
|
}
|
|
|
setSelectedMessage(detail);
|
|
setSelectedMessage(detail);
|
|
|
if (!detail.read) {
|
|
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();
|
|
|
|
|
|
|
+ const mutationId = ++readMutationRequestId.current;
|
|
|
|
|
+ void api.markInboundMessageRead(detail.id, true)
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ if (requestId !== detailRequestId.current || mutationId !== readMutationRequestId.current) return;
|
|
|
|
|
+ setSelectedMessage((current) => current?.id === detail.id ? { ...current, read: true } : current);
|
|
|
|
|
+ setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
|
|
|
|
|
+ setMailboxes((items) => items.map((item) => item.id === detail.mailboxId
|
|
|
|
|
+ ? { ...item, unreadCount: Math.max(0, item.unreadCount - 1) }
|
|
|
|
|
+ : item));
|
|
|
|
|
+ void loadFolders();
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((error) => {
|
|
|
|
|
+ if (requestId !== detailRequestId.current || mutationId !== readMutationRequestId.current) return;
|
|
|
|
|
+ setReadMutationError(error instanceof Error ? error.message : (locale.startsWith('en') ? 'Unable to mark this message as read.' : '邮件标记已读失败。'));
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
.catch((error) => {
|
|
.catch((error) => {
|
|
|
- if (active) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
|
|
|
|
|
|
|
+ if (requestId === detailRequestId.current) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
|
|
|
})
|
|
})
|
|
|
.finally(() => {
|
|
.finally(() => {
|
|
|
- if (active) setDetailLoading(false);
|
|
|
|
|
|
|
+ if (requestId === detailRequestId.current) setDetailLoading(false);
|
|
|
});
|
|
});
|
|
|
- return () => { active = false; };
|
|
|
|
|
- }, [loadFolders, routeMessageId, t]);
|
|
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ if (detailRequestId.current === requestId) detailRequestId.current += 1;
|
|
|
|
|
+ readMutationRequestId.current += 1;
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [loadFolders, locale, routeMessageId, t]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
|
|
if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
|
|
@@ -336,28 +412,69 @@ export default function Inbox() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openCreateMailbox() {
|
|
function openCreateMailbox() {
|
|
|
|
|
+ setEditingMailbox(null);
|
|
|
mailboxForm.resetFields();
|
|
mailboxForm.resetFields();
|
|
|
- mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true });
|
|
|
|
|
|
|
+ mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true, status: 'active' });
|
|
|
setMailboxOpen(true);
|
|
setMailboxOpen(true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function createMailbox() {
|
|
|
|
|
|
|
+ function openEditMailbox(mailbox: InboundMailbox) {
|
|
|
|
|
+ setEditingMailbox(mailbox);
|
|
|
|
|
+ mailboxForm.resetFields();
|
|
|
|
|
+ mailboxForm.setFieldsValue({
|
|
|
|
|
+ localPart: mailbox.localPart,
|
|
|
|
|
+ domain: mailbox.domain,
|
|
|
|
|
+ password: '',
|
|
|
|
|
+ displayName: mailbox.displayName,
|
|
|
|
|
+ quotaMb: mailbox.quotaMb,
|
|
|
|
|
+ aliases: mailbox.aliases.join('\n'),
|
|
|
|
|
+ forwardTo: mailbox.forwardTo.join('\n'),
|
|
|
|
|
+ keepForwarded: mailbox.keepForwarded,
|
|
|
|
|
+ status: mailbox.status
|
|
|
|
|
+ });
|
|
|
|
|
+ setMailboxOpen(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function closeMailboxDrawer() {
|
|
|
|
|
+ setMailboxOpen(false);
|
|
|
|
|
+ setEditingMailbox(null);
|
|
|
|
|
+ mailboxForm.resetFields();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function saveMailbox() {
|
|
|
const values = await mailboxForm.validateFields();
|
|
const values = await mailboxForm.validateFields();
|
|
|
- setActionKey('mailbox:create');
|
|
|
|
|
|
|
+ const key = editingMailbox ? `mailbox:update:${editingMailbox.id}` : 'mailbox:create';
|
|
|
|
|
+ setActionKey(key);
|
|
|
try {
|
|
try {
|
|
|
- const result = await api.createInboundMailbox({
|
|
|
|
|
- address: `${values.localPart}@${values.domain}`,
|
|
|
|
|
- displayName: values.displayName,
|
|
|
|
|
- password: values.password,
|
|
|
|
|
- aliases: values.aliases,
|
|
|
|
|
- forwardTo: values.forwardTo,
|
|
|
|
|
- keepForwarded: values.keepForwarded,
|
|
|
|
|
- quotaMb: values.quotaMb
|
|
|
|
|
- });
|
|
|
|
|
- message.success(t('actions.inboundMailboxCreated'));
|
|
|
|
|
- setMailboxOpen(false);
|
|
|
|
|
- mailboxForm.resetFields();
|
|
|
|
|
- setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
|
|
|
|
|
|
|
+ if (editingMailbox) {
|
|
|
|
|
+ const password = String(values.password || '').trim();
|
|
|
|
|
+ const result = await api.updateInboundMailbox(editingMailbox.id, {
|
|
|
|
|
+ displayName: values.displayName || '',
|
|
|
|
|
+ aliases: values.aliases || '',
|
|
|
|
|
+ forwardTo: values.forwardTo || '',
|
|
|
|
|
+ keepForwarded: Boolean(values.keepForwarded),
|
|
|
|
|
+ quotaMb: values.quotaMb ?? null,
|
|
|
|
|
+ status: values.status || editingMailbox.status,
|
|
|
|
|
+ ...(password ? { password } : {})
|
|
|
|
|
+ });
|
|
|
|
|
+ message.success(locale.startsWith('en') ? 'Mailbox updated' : '收信邮箱已更新');
|
|
|
|
|
+ if (password) {
|
|
|
|
|
+ setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, password));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const result = await api.createInboundMailbox({
|
|
|
|
|
+ address: `${values.localPart}@${values.domain}`,
|
|
|
|
|
+ displayName: values.displayName,
|
|
|
|
|
+ password: values.password,
|
|
|
|
|
+ aliases: values.aliases,
|
|
|
|
|
+ forwardTo: values.forwardTo,
|
|
|
|
|
+ keepForwarded: values.keepForwarded,
|
|
|
|
|
+ quotaMb: values.quotaMb
|
|
|
|
|
+ });
|
|
|
|
|
+ message.success(t('actions.inboundMailboxCreated'));
|
|
|
|
|
+ setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
|
|
|
|
|
+ }
|
|
|
|
|
+ closeMailboxDrawer();
|
|
|
await loadBase();
|
|
await loadBase();
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
message.error(error instanceof Error ? error.message : t('common.error'));
|
|
message.error(error instanceof Error ? error.message : t('common.error'));
|
|
@@ -396,7 +513,7 @@ export default function Inbox() {
|
|
|
{ title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
|
|
{ title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
|
|
|
{ title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 },
|
|
{ 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>
|
|
|
|
|
|
|
+ title: t('common.actions'), width: 330, render: (_, item) => <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><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>
|
|
|
}
|
|
}
|
|
|
];
|
|
];
|
|
|
|
|
|
|
@@ -416,6 +533,7 @@ export default function Inbox() {
|
|
|
extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
|
|
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}
|
|
{loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
|
|
|
|
|
+ {domainsError ? <Alert type="warning" showIcon message={locale.startsWith('en') ? 'Domain and routing options are temporarily unavailable.' : '域名与路由选项暂不可用。'} description={domainsError} 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}
|
|
{config?.submission?.inboundEnabled === false ? <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} /> : null}
|
|
|
<Tabs
|
|
<Tabs
|
|
|
activeKey={workspace}
|
|
activeKey={workspace}
|
|
@@ -426,15 +544,31 @@ export default function Inbox() {
|
|
|
]}
|
|
]}
|
|
|
/>
|
|
/>
|
|
|
{workspace === 'messages' ? (
|
|
{workspace === 'messages' ? (
|
|
|
- mailboxes.length ? (
|
|
|
|
|
|
|
+ loadError && !mailboxes.length ? null : mailboxes.length ? (
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
|
|
|
- {screens.md ? <FolderPane mailboxes={mailboxes} selectedMailboxId={selectedMailboxId} folders={folders} activeFolder={folder} onMailbox={selectMailbox} onFolder={selectFolder} locale={locale} /> : null}
|
|
|
|
|
|
|
+ {screens.md ? (
|
|
|
|
|
+ <FolderPane
|
|
|
|
|
+ mailboxOptions={mailboxOptions}
|
|
|
|
|
+ selectedMailboxId={selectedMailboxId}
|
|
|
|
|
+ folders={folders}
|
|
|
|
|
+ foldersLoading={foldersLoading}
|
|
|
|
|
+ foldersError={foldersError}
|
|
|
|
|
+ activeFolder={folder}
|
|
|
|
|
+ onMailbox={selectMailbox}
|
|
|
|
|
+ onFolder={selectFolder}
|
|
|
|
|
+ onRetryFolders={loadFolders}
|
|
|
|
|
+ locale={locale}
|
|
|
|
|
+ mailboxLabel={t('inbox.mailboxFilter')}
|
|
|
|
|
+ refreshLabel={t('common.refresh')}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
<Card styles={{ body: { padding: 0, minWidth: 0 } }}>
|
|
<Card styles={{ body: { padding: 0, minWidth: 0 } }}>
|
|
|
<div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
|
|
<div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
|
|
|
{!screens.md ? (
|
|
{!screens.md ? (
|
|
|
<Space direction="vertical" size={8} className="full-width" style={{ marginBottom: 8 }}>
|
|
<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' : '文件夹'} />
|
|
|
|
|
|
|
+ <Select value={selectedMailboxId || undefined} onChange={selectMailbox} options={mailboxOptions} showSearch optionFilterProp="label" className="full-width" aria-label={t('inbox.mailboxFilter')} />
|
|
|
|
|
+ {foldersError ? <Alert type="warning" showIcon message={locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。'} action={<Button size="small" loading={foldersLoading} onClick={() => void loadFolders()}>{t('common.refresh')}</Button>} /> : null}
|
|
|
|
|
+ <Select value={folder} onChange={selectFolder} loading={foldersLoading} options={folders.map((item) => ({ value: item.name, label: folderOptionLabel(item, locale) }))} className="full-width" aria-label={locale.startsWith('en') ? 'Folder' : '文件夹'} />
|
|
|
</Space>
|
|
</Space>
|
|
|
) : null}
|
|
) : null}
|
|
|
<Space.Compact block>
|
|
<Space.Compact block>
|
|
@@ -453,36 +587,107 @@ export default function Inbox() {
|
|
|
<MessageList items={messages} loading={messagesLoading} activeId={routeMessageId} onOpen={openMessage} locale={locale} t={t} />
|
|
<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}
|
|
{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>
|
|
</Card>
|
|
|
- {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
|
|
|
|
|
|
|
+ {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} mutationError={readMutationError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
|
|
|
</div>
|
|
</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>} />
|
|
) : <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">
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
|
<SectionCard title={t('inbox.mailboxes')} extra={<StatusPill tone="neutral">{mailboxes.length}</StatusPill>}>
|
|
<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>} />}
|
|
|
|
|
|
|
+ {mailboxes.length ? (
|
|
|
|
|
+ <Space direction="vertical" size={12} className="full-width">
|
|
|
|
|
+ <Space wrap className="full-width">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ allowClear
|
|
|
|
|
+ aria-label={locale.startsWith('en') ? 'Search mailboxes' : '搜索收信邮箱'}
|
|
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
|
|
+ placeholder={locale.startsWith('en') ? 'Search address, alias, or forwarding target' : '搜索邮箱、别名或转发地址'}
|
|
|
|
|
+ value={mailboxListQuery}
|
|
|
|
|
+ onChange={(event) => setMailboxListQuery(event.target.value)}
|
|
|
|
|
+ style={{ flex: '1 1 280px' }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Select
|
|
|
|
|
+ aria-label={locale.startsWith('en') ? 'Sort mailboxes' : '收信邮箱排序'}
|
|
|
|
|
+ value={mailboxSort}
|
|
|
|
|
+ onChange={setMailboxSort}
|
|
|
|
|
+ style={{ minWidth: 180 }}
|
|
|
|
|
+ options={mailboxSortOptions(locale)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ {screens.md ? (
|
|
|
|
|
+ <Table rowKey="id" columns={mailboxColumns} dataSource={visibleMailboxes} scroll={{ x: 1040 }} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <List dataSource={visibleMailboxes} 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 icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><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>} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ) : loadError ? null : <EmptyState description={t('inbox.noDomain')} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
<SectionCard title={t('inbox.domainRoutes')} extra={<StatusPill tone="neutral">{domains.length}</StatusPill>}>
|
|
<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>} />}
|
|
|
|
|
|
|
+ {domains.length ? (
|
|
|
|
|
+ <Space direction="vertical" size={12} className="full-width">
|
|
|
|
|
+ <Space wrap className="full-width">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ allowClear
|
|
|
|
|
+ aria-label={locale.startsWith('en') ? 'Search receiving routes' : '搜索域名收信路由'}
|
|
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
|
|
+ placeholder={locale.startsWith('en') ? 'Search domain or catch-all address' : '搜索域名或未知地址收件邮箱'}
|
|
|
|
|
+ value={routeListQuery}
|
|
|
|
|
+ onChange={(event) => setRouteListQuery(event.target.value)}
|
|
|
|
|
+ style={{ flex: '1 1 280px' }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Select
|
|
|
|
|
+ aria-label={locale.startsWith('en') ? 'Sort receiving routes' : '域名收信路由排序'}
|
|
|
|
|
+ value={routeSort}
|
|
|
|
|
+ onChange={setRouteSort}
|
|
|
|
|
+ style={{ minWidth: 180 }}
|
|
|
|
|
+ options={routeSortOptions(locale)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ {screens.md ? <Table rowKey="id" columns={routeColumns} dataSource={visibleRoutes} /> : <List dataSource={visibleRoutes} 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>} />}
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ) : domainsError ? null : <EmptyState description={t('inbox.noDomain')} action={<Button onClick={() => navigate('/domains?create=1')}>{t('common.addDomain')}</Button>} />}
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
</Space>
|
|
</Space>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {!isDesktop ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Drawer> : null}
|
|
|
|
|
|
|
+ {!isDesktop ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} mutationError={readMutationError} 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>}>
|
|
|
|
|
|
|
+ <Drawer
|
|
|
|
|
+ title={editingMailbox ? (locale.startsWith('en') ? 'Edit mailbox' : '编辑收信邮箱') : t('inbox.createMailbox')}
|
|
|
|
|
+ width={560}
|
|
|
|
|
+ open={mailboxOpen}
|
|
|
|
|
+ onClose={closeMailboxDrawer}
|
|
|
|
|
+ destroyOnHidden
|
|
|
|
|
+ footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={closeMailboxDrawer}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create' || actionKey === `mailbox:update:${editingMailbox?.id}`} onClick={() => void saveMailbox()}>{editingMailbox ? t('common.save') : t('inbox.createMailbox')}</Button></Space>}
|
|
|
|
|
+ >
|
|
|
{!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
|
|
{!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
|
|
|
<Form form={mailboxForm} layout="vertical">
|
|
<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>
|
|
|
|
|
|
|
+ {editingMailbox ? (
|
|
|
|
|
+ <Descriptions bordered column={1} size="small" style={{ marginBottom: 20 }}>
|
|
|
|
|
+ <Descriptions.Item label={t('inbox.mailboxAddress')}><Typography.Text code>{editingMailbox.address}</Typography.Text></Descriptions.Item>
|
|
|
|
|
+ </Descriptions>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: screens.sm ? 'minmax(0, 1fr) minmax(220px, 0.8fr)' : 'minmax(0, 1fr)', columnGap: screens.sm ? 0 : 8 }}>
|
|
|
|
|
+ <Form.Item name="localPart" label={t('inbox.localPart')} rules={[{ required: true, message: t('inbox.localPartRequired') }, { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }]}><Input autoComplete="off" /></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}` }))} /></Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
<Form.Item name="displayName" label={t('inbox.displayName')}><Input /></Form.Item>
|
|
<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>
|
|
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="password"
|
|
|
|
|
+ label={editingMailbox ? (locale.startsWith('en') ? 'New password' : '新密码') : t('inbox.password')}
|
|
|
|
|
+ extra={editingMailbox ? (locale.startsWith('en') ? 'Leave blank to keep the current password.' : '留空表示保留当前密码。') : undefined}
|
|
|
|
|
+ rules={[{ required: !editingMailbox, 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>
|
|
<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="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="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="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.Item name="keepForwarded" valuePropName="checked"><Checkbox>{t('inbox.keepForwarded')}</Checkbox></Form.Item>
|
|
|
|
|
+ {editingMailbox ? (
|
|
|
|
|
+ <Form.Item name="status" label={t('common.status')} rules={[{ required: true }]}>
|
|
|
|
|
+ <Select options={[{ value: 'active', label: locale.startsWith('en') ? 'Active' : '启用' }, { value: 'disabled', label: locale.startsWith('en') ? 'Disabled' : '停用' }]} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
</Form>
|
|
</Form>
|
|
|
)}
|
|
)}
|
|
|
</Drawer>
|
|
</Drawer>
|
|
@@ -499,15 +704,40 @@ export default function Inbox() {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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 }) {
|
|
|
|
|
|
|
+function FolderPane({ mailboxOptions, selectedMailboxId, folders, foldersLoading, foldersError, activeFolder, onMailbox, onFolder, onRetryFolders, locale, mailboxLabel, refreshLabel }: {
|
|
|
|
|
+ mailboxOptions: Array<{ value: number; label: string }>;
|
|
|
|
|
+ selectedMailboxId: number | null;
|
|
|
|
|
+ folders: MailFolder[];
|
|
|
|
|
+ foldersLoading: boolean;
|
|
|
|
|
+ foldersError: string;
|
|
|
|
|
+ activeFolder: string;
|
|
|
|
|
+ onMailbox: (id: number) => void;
|
|
|
|
|
+ onFolder: (name: string) => void;
|
|
|
|
|
+ onRetryFolders: () => Promise<void>;
|
|
|
|
|
+ locale: string;
|
|
|
|
|
+ mailboxLabel: string;
|
|
|
|
|
+ refreshLabel: string;
|
|
|
|
|
+}) {
|
|
|
return (
|
|
return (
|
|
|
<Card styles={{ body: { padding: 8 } }}>
|
|
<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 }} />
|
|
|
|
|
|
|
+ <Select aria-label={mailboxLabel} value={selectedMailboxId || undefined} onChange={onMailbox} options={mailboxOptions} showSearch optionFilterProp="label" className="full-width" style={{ marginBottom: 12 }} />
|
|
|
|
|
+ {foldersError ? (
|
|
|
|
|
+ <Alert
|
|
|
|
|
+ type="warning"
|
|
|
|
|
+ showIcon
|
|
|
|
|
+ message={locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。'}
|
|
|
|
|
+ description={foldersError}
|
|
|
|
|
+ action={<Button size="small" loading={foldersLoading} onClick={() => void onRetryFolders()}>{refreshLabel}</Button>}
|
|
|
|
|
+ style={{ marginBottom: 8 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
<Space direction="vertical" size={2} className="full-width">
|
|
<Space direction="vertical" size={2} className="full-width">
|
|
|
{folders.map((item) => (
|
|
{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' }}>
|
|
<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>
|
|
<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>}
|
|
|
|
|
|
|
+ {item.countsAvailable
|
|
|
|
|
+ ? (item.unreadCount ? <Badge count={item.unreadCount} size="small" /> : <Typography.Text type="secondary">{item.messageCount}</Typography.Text>)
|
|
|
|
|
+ : <Typography.Text type="secondary" aria-label={locale.startsWith('en') ? 'Count unavailable' : '计数不可用'}>—</Typography.Text>}
|
|
|
</Button>
|
|
</Button>
|
|
|
))}
|
|
))}
|
|
|
</Space>
|
|
</Space>
|
|
@@ -541,12 +771,13 @@ function MessageList({ items, loading, activeId, onOpen, locale, t }: { items: M
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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 }) {
|
|
|
|
|
|
|
+function MessageDetail({ message, loading, error, mutationError, activeTab, onTabChange, onCopy, t }: { message: MailMessage | null; loading: boolean; error: string; mutationError: string; activeTab: MessageTab; onTabChange: (tab: MessageTab) => void; onCopy: (value: string) => void; t: (key: string) => string }) {
|
|
|
if (loading) return <Skeleton active paragraph={{ rows: 12 }} />;
|
|
if (loading) return <Skeleton active paragraph={{ rows: 12 }} />;
|
|
|
if (error) return <Alert type="error" showIcon message={error} />;
|
|
if (error) return <Alert type="error" showIcon message={error} />;
|
|
|
if (!message) return <EmptyState description={t('inbox.messageDetail')} />;
|
|
if (!message) return <EmptyState description={t('inbox.messageDetail')} />;
|
|
|
return (
|
|
return (
|
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
|
|
|
+ {mutationError ? <Alert type="warning" showIcon message={mutationError} /> : null}
|
|
|
<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>
|
|
<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 column={1} size="small">
|
|
|
<Descriptions.Item label={t('inbox.sender')}>{message.sender || '—'}</Descriptions.Item>
|
|
<Descriptions.Item label={t('inbox.sender')}>{message.sender || '—'}</Descriptions.Item>
|
|
@@ -596,8 +827,68 @@ 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>;
|
|
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 preferredMailboxId(mailboxes: InboundMailbox[], requestedId: number | null) {
|
|
|
|
|
+ if (requestedId && mailboxes.some((item) => item.id === requestedId)) return requestedId;
|
|
|
|
|
+ const lastActive = [...mailboxes]
|
|
|
|
|
+ .filter((item) => item.lastMessageAt)
|
|
|
|
|
+ .sort((left, right) => Date.parse(right.lastMessageAt || '') - Date.parse(left.lastMessageAt || ''))[0];
|
|
|
|
|
+ return lastActive?.id || mailboxes.find((item) => item.messageCount > 0)?.id || mailboxes[0]?.id || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function filterMailboxes(mailboxes: InboundMailbox[], query: string) {
|
|
|
|
|
+ const normalized = query.trim().toLocaleLowerCase();
|
|
|
|
|
+ if (!normalized) return mailboxes;
|
|
|
|
|
+ return mailboxes.filter((item) => [item.address, item.displayName, ...item.aliases, ...item.forwardTo]
|
|
|
|
|
+ .some((value) => String(value || '').toLocaleLowerCase().includes(normalized)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sortMailboxes(mailboxes: InboundMailbox[], sort: MailboxSort) {
|
|
|
|
|
+ return [...mailboxes].sort((left, right) => {
|
|
|
|
|
+ if (sort === 'address') return left.address.localeCompare(right.address);
|
|
|
|
|
+ if (sort === 'unread') return right.unreadCount - left.unreadCount || left.address.localeCompare(right.address);
|
|
|
|
|
+ if (sort === 'messages') return right.messageCount - left.messageCount || left.address.localeCompare(right.address);
|
|
|
|
|
+ const leftActivity = Date.parse(left.lastMessageAt || '') || 0;
|
|
|
|
|
+ const rightActivity = Date.parse(right.lastMessageAt || '') || 0;
|
|
|
|
|
+ return rightActivity - leftActivity || left.address.localeCompare(right.address);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mailboxSortOptions(locale: string) {
|
|
|
|
|
+ return locale.startsWith('en')
|
|
|
|
|
+ ? [{ value: 'activity', label: 'Recent activity' }, { value: 'address', label: 'Address' }, { value: 'unread', label: 'Unread count' }, { value: 'messages', label: 'Message count' }]
|
|
|
|
|
+ : [{ value: 'activity', label: '最近收信' }, { value: 'address', label: '邮箱地址' }, { value: 'unread', label: '未读数' }, { value: 'messages', label: '邮件数' }];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function filterRoutes(domains: Domain[], query: string) {
|
|
|
|
|
+ const normalized = query.trim().toLocaleLowerCase();
|
|
|
|
|
+ if (!normalized) return domains;
|
|
|
|
|
+ return domains.filter((item) => [item.domain, item.catchAllAddress]
|
|
|
|
|
+ .some((value) => String(value || '').toLocaleLowerCase().includes(normalized)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sortRoutes(domains: Domain[], sort: RouteSort) {
|
|
|
|
|
+ return [...domains].sort((left, right) => {
|
|
|
|
|
+ if (sort === 'configured') {
|
|
|
|
|
+ const configured = Number(Boolean(right.catchAllAddress)) - Number(Boolean(left.catchAllAddress));
|
|
|
|
|
+ if (configured) return configured;
|
|
|
|
|
+ }
|
|
|
|
|
+ return left.domain.localeCompare(right.domain);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function routeSortOptions(locale: string) {
|
|
|
|
|
+ return locale.startsWith('en')
|
|
|
|
|
+ ? [{ value: 'domain', label: 'Domain' }, { value: 'configured', label: 'Configured first' }]
|
|
|
|
|
+ : [{ value: 'domain', label: '域名' }, { value: 'configured', label: '已配置优先' }];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fallbackFolders(): MailFolder[] {
|
|
|
|
|
+ return standardFolders.map((name) => ({ name, specialUse: folderSpecialUse(name), messageCount: 0, unreadCount: 0, countsAvailable: false }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function folderOptionLabel(folder: MailFolder, locale: string) {
|
|
|
|
|
+ const count = folder.countsAvailable ? folder.unreadCount : '—';
|
|
|
|
|
+ return `${folderLabel(folder.name, locale)} (${count})`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function folderSpecialUse(name: string) {
|
|
function folderSpecialUse(name: string) {
|