import * as React from "react" import DOMPurify from "dompurify" import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Node, mergeAttributes, type Editor } from "@tiptap/core" import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model" import { EditorContent, useEditor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import LinkExtension from "@tiptap/extension-link" import ImageExtension from "@tiptap/extension-image" import TextAlign from "@tiptap/extension-text-align" import Placeholder from "@tiptap/extension-placeholder" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { useNavigate } from "react-router-dom" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react" import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" import { Language, languageOptions, useLanguage } from "@/lib/language" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { ConfirmDialog } from "@/components/confirm-dialog" import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, } from "@/components/ui/sidebar" import { useMe } from "@/hooks/use-me" import { useIsMobile } from "@/hooks/use-mobile" import { useToast } from "@/hooks/use-toast" import { hasPermission } from "@/lib/permissions" const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "已归档", Spam: "垃圾邮件", Trash: "已删除", } type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type MailFilter = "all" | "unread" | "starred" | "attachments" | "recent7" type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" | "unknown" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void } type MessageContextMenuState = { message: MailMessage; x: number; y: number } type SidebarContextMenuState = { item: MailMenuItem; x: number; y: number } type FolderDropTarget = { key: string; edge: "before" | "after" | "end" } type AdvancedMailSearch = { from: string; to: string; subject: string; startDate: string; endDate: string; hasAttachments: boolean; unread: boolean; starred: boolean } type AdvancedMailSearchDraft = AdvancedMailSearch type AdvancedSearchChip = { key: keyof AdvancedMailSearch; label: string } type MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "unknown"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number } const filterLabels: Record = { all: "全部", unread: "未读", starred: "已加旗标", attachments: "有附件", recent7: "最近 7 天", } const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false } const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch } const mailImportBatchBytes = 32 * 1024 * 1024 const mailImportBatchFiles = 20 const mailCompactBreakpoint = 768 const mailDetailBreakpoint = 768 function buildMailImportBatches(files: File[]) { const batches: File[][] = [] let batch: File[] = [] let batchBytes = 0 for (const file of files) { if (batch.length > 0 && (batch.length >= mailImportBatchFiles || batchBytes + file.size > mailImportBatchBytes)) { batches.push(batch) batch = [] batchBytes = 0 } batch.push(file) batchBytes += file.size } if (batch.length > 0) batches.push(batch) return batches } function useMaxViewportWidth(maxWidth: number) { const [matches, setMatches] = React.useState(false) React.useEffect(() => { const media = window.matchMedia(`(max-width: ${maxWidth - 1}px)`) const sync = () => setMatches(media.matches) media.addEventListener("change", sync) sync() return () => media.removeEventListener("change", sync) }, [maxWidth]) return matches } export function MailPage() { const qc = useQueryClient() const { toast } = useToast() const navigate = useNavigate() const me = useMe() const [folder, setFolder] = React.useState("Inbox") const [mailView, setMailView] = React.useState("folder") const [selectedLabelId, setSelectedLabelId] = React.useState("") const [query, setQuery] = React.useState("") const [advancedSearchOpen, setAdvancedSearchOpen] = React.useState(false) const [advancedSearch, setAdvancedSearch] = React.useState(emptyAdvancedSearch) const [advancedSearchDraft, setAdvancedSearchDraft] = React.useState(emptyAdvancedSearchDraft) const [selectedId, setSelectedId] = React.useState(null) const [compactSelectedIds, setCompactSelectedIds] = React.useState([]) const [composeOpen, setComposeOpen] = React.useState(false) const [composeDraft, setComposeDraft] = React.useState() const sidebarCollapsed = false const [mailFilter, setMailFilter] = React.useState("all") const [selectedMailboxId, setSelectedMailboxId] = React.useState("all") const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("") const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState([]) const [externalFolder, setExternalFolder] = React.useState("INBOX") const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [language, setLanguage] = useLanguage() const [displayMode] = useDisplayMode() const isMobile = useIsMobile() const isNarrowMailViewport = useMaxViewportWidth(mailCompactBreakpoint) const isTwoPaneMailViewport = useMaxViewportWidth(mailDetailBreakpoint) const compactMailLayout = isMobile || isNarrowMailViewport || displayMode === "compact" const [refreshing, setRefreshing] = React.useState(false) const [autoRefreshing, setAutoRefreshing] = React.useState(false) const [exportingMail, setExportingMail] = React.useState(false) const [importingMail, setImportingMail] = React.useState(false) const [, setLastAutoRefreshAt] = React.useState(null) const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [sendQueueStatus, setSendQueueStatus] = React.useState("all") const [sendQueueMessageId, setSendQueueMessageId] = React.useState("") const [sendQueueRecipient, setSendQueueRecipient] = React.useState("") const [sendQueueFrom, setSendQueueFrom] = React.useState("") const [sendQueueTo, setSendQueueTo] = React.useState("") const [sendQueueAuditId, setSendQueueAuditId] = React.useState("") const [sendQueuePendingId, setSendQueuePendingId] = React.useState("") const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [labelEditMode, setLabelEditMode] = React.useState(false) const [newLabelEditing, setNewLabelEditing] = React.useState(false) const [messageContextMenu, setMessageContextMenu] = React.useState(null) const [sidebarContextMenu, setSidebarContextMenu] = React.useState(null) const [folderDialogOpen, setFolderDialogOpen] = React.useState(false) const [draggingFolderId, setDraggingFolderId] = React.useState("") const [folderDropTarget, setFolderDropTarget] = React.useState(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) const mailAudioContextRef = React.useRef(null) const mailImportInputRef = React.useRef(null) const user = me.data?.user const canAccessMail = hasPermission(user, "mail.access") const canReadMail = hasPermission(user, "mail.messages.read") const canSendMail = hasPermission(user, "mail.messages.send") const canManageDrafts = hasPermission(user, "mail.messages.drafts") const canScheduleMail = hasPermission(user, "mail.messages.schedule") const canOrganizeMail = hasPermission(user, "mail.messages.organize") const canManageLabels = hasPermission(user, "mail.labels.manage") const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canManageSignatures = hasPermission(user, "mail.signatures.manage") const canViewUnknownMail = user?.role === "admin" const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false React.useEffect(() => { if (!user) return void import("@/pages/profile") }, [user]) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled }) const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId]) const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail && externalImapEnabled }) const selectedMailbox = React.useMemo(() => { const items = mailboxList.data?.items || [] if (selectedMailboxId === "all") return undefined return items.find((item) => item.id === selectedMailboxId) || items[0] }, [mailboxList.data?.items, selectedMailboxId]) const isAllMailboxSelected = selectedMailboxId === "all" const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || "" const composeMailboxes = React.useMemo(() => (mailboxList.data?.items || []).filter((item) => item.status === "active"), [mailboxList.data?.items]) const accountMailbox = React.useMemo(() => { const loginEmail = user?.email.trim().toLowerCase() if (!loginEmail) return undefined return composeMailboxes.find((item) => item.address.trim().toLowerCase() === loginEmail) }, [composeMailboxes, user?.email]) const selectedComposeMailbox = selectedMailbox?.status === "active" ? selectedMailbox : (isAllMailboxSelected ? accountMailbox || composeMailboxes[0] : undefined) const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 const canManageFolders = canOrganizeMail && hasMailboxes const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") }) const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 }) const canViewSendQueue = canReadMail const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus, sendQueueMessageId, sendQueueRecipient, sendQueueFrom, sendQueueTo], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus, messageId: sendQueueMessageId.trim(), recipient: sendQueueRecipient.trim(), from: datetimeLocalToISO(sendQueueFrom), to: datetimeLocalToISO(sendQueueTo) }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000, }) const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue }) const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false const advancedSearchActive = hasAdvancedMailSearch(advancedSearch) const advancedSearchChips = React.useMemo(() => buildAdvancedSearchChips(advancedSearch), [advancedSearch]) const mailSearchParams = React.useMemo(() => ({ q: query.trim(), ...advancedSearch, }), [advancedSearch, query]) React.useEffect(() => { if (externalImapEnabled) return setSelectedExternalAccountId("") setExpandedExternalAccountIds([]) if (mailView === "external") setMailView("folder") }, [externalImapEnabled, mailView]) React.useEffect(() => { const ids = new Set((externalMailAccounts.data?.items || []).map((item) => item.id)) setExpandedExternalAccountIds((current) => { const next = current.filter((id) => ids.has(id)) return next.length === current.length ? current : next }) }, [externalMailAccounts.data?.items]) const inboxProbe = useQuery({ queryKey: ["mail-notifications", activeMailboxId], queryFn: () => api.messages("Inbox", "", "", activeMailboxId), enabled: !!activeMailboxId && canReadMail, refetchInterval: mailRefreshInterval, refetchIntervalInBackground: true, }) const messages = useInfiniteQuery({ queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, mailSearchParams], queryFn: ({ pageParam }) => { const cursor = typeof pageParam === "string" ? pageParam : "" if (mailView === "starred") return api.starredMessages(mailSearchParams, cursor, activeMailboxId) if (mailView === "label") return api.labelMessages(selectedLabelId, mailSearchParams, cursor, activeMailboxId) return api.messages(folder, mailSearchParams, cursor, activeMailboxId) }, initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "unknown" && (mailView !== "label" || !!selectedLabelId), }) const unknownMessages = useInfiniteQuery({ queryKey: ["admin", "unknown-messages", query], queryFn: ({ pageParam }) => api.adminMessages({ mailboxId: "unregistered", q: query.trim(), cursor: typeof pageParam === "string" ? pageParam : "", }), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: canViewUnknownMail && mailView === "unknown", }) const externalMessages = useInfiniteQuery({ queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query], queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled, }) const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : mailView === "unknown" ? api.adminMessage(selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) && (mailView !== "unknown" || canViewUnknownMail), }) function updateCachedMessage(id: string, patch: Partial) { qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => { if (!current?.pages) return current return { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((message) => message.id === id ? { ...message, ...patch } : message), })), } }) qc.setQueriesData({ queryKey: ["external-messages"] }, (current: InfiniteData | undefined) => { if (!current?.pages) return current return { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((message) => message.id === id ? { ...message, ...patch } : message), })), } }) } const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onMutate: ({ id, starred }) => updateCachedMessage(id, { isStarred: starred }), onSuccess: async (_, variables) => { await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["message", variables.id] }) await qc.invalidateQueries({ queryKey: ["mail-stats"] }) await qc.invalidateQueries({ queryKey: ["labels"] }) }, onError: (error) => toast({ title: "操作失败", description: error.message }), }) const markRead = useMutation({ mutationFn: ({ id, read }: { id: string; read: boolean }) => api.markRead(id, read), onMutate: ({ id, read }) => updateCachedMessage(id, { isRead: read }), onSuccess: async (_, variables) => { await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["message", variables.id] }) await qc.invalidateQueries({ queryKey: ["folders"] }) await qc.invalidateQueries({ queryKey: ["mailboxes"] }) await qc.invalidateQueries({ queryKey: ["mail-stats"] }) }, onError: (error) => toast({ title: "操作失败", description: error.message }), }) const markExternalRead = useMutation({ mutationFn: ({ id, remoteId, read }: { id: string; remoteId: string; read: boolean }) => api.markExternalRead(id, remoteId, read), onMutate: ({ remoteId, read }) => updateCachedMessage(remoteId, { isRead: read }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["external-messages"] }) await qc.invalidateQueries({ queryKey: ["message"] }) await qc.invalidateQueries({ queryKey: ["mail-external-folders"] }) }, onError: (error) => toast({ title: "操作失败", description: error.message }), }) const addLabel = useMutation({ mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }), onMutate: async ({ id, label }) => { await qc.cancelQueries({ queryKey: ["messages"] }) if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: [...(current.labels || []), label] } : current) qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => m.id === id ? { ...m, labels: [...(m.labels || []), label] } : m) })) } : current) return { prevMessage } }, onError: (_error, _vars, context) => { if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) qc.invalidateQueries({ queryKey: ["messages"] }) toast({ title: "添加标签失败" }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }, }) const removeLabel = useMutation({ mutationFn: ({ id, labelId }: { id: string; labelId: string }) => api.removeLabel(id, labelId), onMutate: async ({ id, labelId }) => { await qc.cancelQueries({ queryKey: ["messages"] }) if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: (current.labels || []).filter((l) => l.id !== labelId) } : current) qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => m.id === id ? { ...m, labels: (m.labels || []).filter((l) => l.id !== labelId) } : m) })) } : current) return { prevMessage } }, onError: (_error, _vars, context) => { if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) qc.invalidateQueries({ queryKey: ["messages"] }) toast({ title: "移除标签失败" }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }, }) const createLabel = useMutation({ mutationFn: (name: string) => api.createLabel({ mailboxId: activeMailboxId, name }), onMutate: async (name) => { await qc.cancelQueries({ queryKey: ["labels"] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) const tempLabel: MailLabel = { id: `temp-${Date.now()}`, name, color: "" } qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: [...(current.items || []), tempLabel] } : { items: [tempLabel] }) return { prevLabels } }, onError: (_error, _name, context) => { if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) toast({ title: "创建标签失败" }) }, onSettled: () => qc.invalidateQueries({ queryKey: ["labels"] }), }) const deleteLabel = useMutation({ mutationFn: (id: string) => api.deleteLabel(id, activeMailboxId), onMutate: async (id) => { await qc.cancelQueries({ queryKey: ["labels"] }) await qc.cancelQueries({ queryKey: ["messages"] }) if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: (current.items || []).filter((l) => l.id !== id) } : current) if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: (current.labels || []).filter((l) => l.id !== id) } : current) qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => ({ ...m, labels: (m.labels || []).filter((l) => l.id !== id) })) })) } : current) return { prevLabels, prevMessage } }, onSuccess: (_data, id) => { if (mailView === "label" && selectedLabelId === id) { setMailView("folder") setFolder("Inbox") setSelectedLabelId("") setSelectedId(null) } }, onError: (_error, _id, context) => { if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) qc.invalidateQueries({ queryKey: ["messages"] }) toast({ title: "删除标签失败" }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) }, }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } }) const cancelScheduledSend = useMutation({ mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id), onMutate: (item) => setCancelingScheduledId(item.id), onSuccess: async (_, item) => { await qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) toast({ title: item.status === "failed" ? "已移除失败记录" : "已取消定时发送" }) }, onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), onSettled: () => setCancelingScheduledId(""), }) const createFolder = useMutation({ mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }), onSuccess: (created) => { qc.invalidateQueries({ queryKey: ["folders"] }) setFolderDialogOpen(false) openFolder(created.name) toast({ title: "文件夹已创建" }) }, onError: (error) => toast({ title: "创建文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) const reorderFolders = useMutation({ mutationFn: (items: { id: string; sortOrder: number }[]) => api.reorderFolders({ mailboxId: activeMailboxId, folderIds: items.map((item) => item.id), folders: items }), onMutate: async (items) => { await qc.cancelQueries({ queryKey: ["folders", activeMailboxId] }) const previous = qc.getQueryData>(["folders", activeMailboxId]) qc.setQueryData>(["folders", activeMailboxId], (current) => { if (!current?.items) return current const order = new Map(items.map((item) => [item.id, item.sortOrder])) return { ...current, items: current.items .map((item) => order.has(item.id) ? { ...item, sortOrder: order.get(item.id) || item.sortOrder } : item) .sort(compareMailFolders), } }) return { previous } }, onError: (error, _items, context) => { if (context?.previous) qc.setQueryData(["folders", activeMailboxId], context.previous) toast({ title: "文件夹排序失败", description: error instanceof Error ? error.message : "请稍后重试" }) }, onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }), }) const deleteFolder = useMutation({ mutationFn: (item: Extract) => api.deleteFolder(item.folderId, activeMailboxId, item.folderName), onSuccess: async (result, item) => { setPendingConfirm(null) if (mailView === "folder" && folder === item.folderName) { setFolder("Inbox") setSelectedId(null) } await refreshMailData() toast({ title: "文件夹已删除", description: result.moved > 0 ? `已将 ${result.moved} 封邮件移回收件箱` : undefined }) }, onError: (error) => toast({ title: "删除文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) const retrySendQueue = useMutation({ mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id), onMutate: (item) => setSendQueuePendingId(item.id), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["send-queue"] }) await qc.invalidateQueries({ queryKey: ["send-queue-audit"] }) toast({ title: "已重新加入发送队列" }) }, onError: (error) => toast({ title: "重试失败", description: error instanceof Error ? error.message : "请稍后重试" }), onSettled: () => setSendQueuePendingId(""), }) const cancelSendQueue = useMutation({ mutationFn: (item: SendQueueItem) => api.cancelSendQueue(item.id), onMutate: (item) => setSendQueuePendingId(item.id), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["send-queue"] }) await qc.invalidateQueries({ queryKey: ["send-queue-audit"] }) toast({ title: "已取消发送任务" }) }, onError: (error) => toast({ title: "取消失败", description: error instanceof Error ? error.message : "请稍后重试" }), onSettled: () => setSendQueuePendingId(""), }) React.useEffect(() => { if (!mailboxList.isSuccess) return const items = mailboxList.data?.items || [] if (items.length === 0) { if (selectedMailboxId) { setSelectedMailboxId("") setSelectedId(null) } localStorage.removeItem("lanqin:selected-mailbox") return } if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) { setSelectedMailboxId("all") setSelectedExternalAccountId("") setFolder("Inbox") setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") } }, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId]) React.useEffect(() => { localStorage.removeItem("lanqin:selected-mailbox") localStorage.removeItem("lanqin:selected-mailbox-version") }, []) React.useEffect(() => { setSelectedId(null) setMailFilter("all") }, [mailView]) React.useEffect(() => { setCompactSelectedIds([]) }, [selectedMailboxId, mailView, folder, selectedLabelId, query, advancedSearch, displayMode]) React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current) themeMountedRef.current = true }, [darkMode]) React.useEffect(() => { const unlock = () => { const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (AudioContextCtor && !mailAudioContextRef.current) { const ctx = new AudioContextCtor() mailAudioContextRef.current = ctx if (ctx.state === "suspended") void ctx.resume() } if ("Notification" in window && Notification.permission === "default") { void Notification.requestPermission() } } window.addEventListener("pointerdown", unlock, { once: true }) window.addEventListener("keydown", unlock, { once: true }) return () => { window.removeEventListener("pointerdown", unlock) window.removeEventListener("keydown", unlock) } }, []) React.useEffect(() => { if (!activeMailboxId || !inboxProbe.data?.items) return const items = inboxProbe.data.items const latest = items[0] const nextState = { latestId: latest?.id || "", latestReceivedAt: latest?.receivedAt || "" } const prevState = mailNotifyStateRef.current[activeMailboxId] if (!prevState) { mailNotifyStateRef.current[activeMailboxId] = nextState return } const newMessages = items.filter((item) => item.receivedAt > prevState.latestReceivedAt && item.id !== prevState.latestId) mailNotifyStateRef.current[activeMailboxId] = nextState if (newMessages.length === 0) return const first = newMessages[0] const firstSender = senderDisplayName(first) const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}` const openFirstMessage = () => { setMailView("folder") setFolder("Inbox") setSelectedId(first.id) } toast({ title, description, onClick: openFirstMessage, onKeyDown: (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault() openFirstMessage() } }, role: "button", tabIndex: 0, }) playIncomingMailSound(mailAudioContextRef) if ("Notification" in window && Notification.permission === "granted") { const notification = new Notification(title, { body: description, tag: `lanqin-mail-${activeMailboxId}`, }) notification.onclick = () => { window.focus() openFirstMessage() notification.close() } } }, [activeMailboxId, inboxProbe.data?.items, toast]) React.useEffect(() => { const events = new EventSource("/api/events", { withCredentials: true }) events.addEventListener("sync", () => { qc.invalidateQueries({ queryKey: ["messages"] }) qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }) qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["labels"] }) qc.invalidateQueries({ queryKey: ["mail-notifications"] }) }) return () => events.close() }, [qc]) React.useEffect(() => { if (!publicSettings.data?.mailAutoRefresh) return const timer = window.setInterval(() => { setAutoRefreshing(true) Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }), qc.invalidateQueries({ queryKey: ["external-messages"] }), qc.invalidateQueries({ queryKey: ["mail-external-folders"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["send-queue"] }), qc.invalidateQueries({ queryKey: ["mail-notifications"] }), ]).finally(() => { setLastAutoRefreshAt(new Date()) window.setTimeout(() => setAutoRefreshing(false), 600) }) }, mailRefreshInterval || 30000) return () => window.clearInterval(timer) }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) const selected = detail.data const allMessages = (mailView === "external" ? externalMessages.data?.pages : mailView === "unknown" ? unknownMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || [] const visibleMessages = allMessages.filter((message) => { if (!messageMatchesAdvancedSearch(message, advancedSearch)) return false if (mailFilter === "unread") return !message.isRead if (mailFilter === "starred") return message.isStarred if (mailFilter === "attachments") return message.hasAttachments if (mailFilter === "recent7") { const receivedAt = new Date(message.receivedAt).getTime() return Number.isFinite(receivedAt) && receivedAt >= Date.now() - 7 * 24 * 60 * 60 * 1000 } return true }) const unreadCount = allMessages.filter((message) => !message.isRead).length const mailboxUnreadCount = (folders.data?.items || []).find((item) => item.name === "Inbox")?.unreadCount ?? unreadCount const starredCount = mailStats.data?.starredMessages ?? (mailView === "starred" ? allMessages.length : 0) const scheduledItems = scheduledSends.data?.items || [] const scheduledDraftIds = new Set(scheduledItems.map((item) => item.draftId).filter((draftId): draftId is string => Boolean(draftId))) const scheduledCount = scheduledItems.length const scheduledQuery = query.trim().toLowerCase() const visibleScheduledItems = scheduledQuery ? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery)) : scheduledItems const sendQueueItems = sendQueue.data?.items || [] const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const visibleSendQueueItems = sendQueueItems const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue, canViewUnknownMail) const primaryMailMenuItems = mailMenuItems.filter((item) => !isCustomMenuFolder(item)) const customMailMenuItems = mailMenuItems.filter(isCustomMenuFolder) const canOrganizeCurrentMailbox = canOrganizeMail && !isAllMailboxSelected const canManageCurrentMailboxLabels = canManageLabels && !isAllMailboxSelected const externalAccountItems = externalImapEnabled ? externalMailAccounts.data?.items || [] : [] const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : [] const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "unknown" ? "未知收件" : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder const isTransferView = mailView === "folder" || mailView === "starred" || mailView === "label" || mailView === "unknown" const canExportCurrentView = canReadMail && isTransferView const canImportCurrentView = canOrganizeMail && isTransferView && mailView !== "unknown" && !!selectedMailbox const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length) const visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const selectedMessagesOnPage = visibleMessages.filter((message) => compactSelectedIds.includes(message.id)) const bulkReadAction: BulkAction = selectedMessagesOnPage.some((message) => !message.isRead) ? "read" : "unread" const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage() function toggleCompactSelectAll(checked: boolean) { setCompactSelectedIds(checked ? visibleMessageIds : []) } function toggleCompactSelect(messageId: string, checked: boolean) { setCompactSelectedIds((ids) => checked ? Array.from(new Set([...ids, messageId])) : ids.filter((id) => id !== messageId)) } async function refreshMailData() { await Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }), qc.invalidateQueries({ queryKey: ["external-messages"] }), qc.invalidateQueries({ queryKey: ["mail-external-folders"] }), qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mailboxes"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["send-queue"] }), ]) } async function runBulkAction(action: BulkAction) { if (!canOrganizeMail) return const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) if (ids.length === 0) return if (action === "delete") { setPendingConfirm({ title: "删除所选邮件?", description: `将删除当前选中的 ${ids.length} 封邮件,此操作无法从邮件列表中恢复。`, confirmText: "删除邮件", onConfirm: () => runConfirmedBulkAction("delete", ids), }) return } await runConfirmedBulkAction(action, ids) } async function runConfirmedBulkAction(action: BulkAction, ids: string[]) { setBulkPending(true) try { let completionTitle = `已处理 ${ids.length} 封邮件` let completionDescription: string | undefined if (action === "read" || action === "unread") { const read = action === "read" await Promise.all(ids.map((id) => api.markRead(id, read))) } else if (action === "star" || action === "unstar") { const starred = action === "star" await Promise.all(ids.map((id) => api.star(id, starred))) } else if (action === "delete") { await Promise.all(ids.map((id) => api.delete(id))) } else { const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam" const result = await api.bulkMove(ids, target) completionTitle = result.ok ? result.message : "批量移动部分失败" completionDescription = result.ok ? undefined : result.message } if (selectedId && ids.includes(selectedId)) setSelectedId(null) setCompactSelectedIds([]) setPendingConfirm(null) await refreshMailData() toast({ title: completionTitle, description: completionDescription }) } catch (error) { toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setBulkPending(false) } } async function runBulkMoveToFolder(folderName: string) { if (!canOrganizeMail) return const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) if (ids.length === 0) return setBulkPending(true) try { const result = await api.bulkMove(ids, folderName) if (selectedId && ids.includes(selectedId)) setSelectedId(null) setCompactSelectedIds([]) await refreshMailData() toast({ title: result.ok ? (folderName === "Inbox" ? `已将 ${result.moved} 封邮件移回收件箱` : `已移动 ${result.moved} 封邮件`) : "批量移动部分失败", description: result.ok ? undefined : result.message }) } catch (error) { toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setBulkPending(false) } } function confirmDeleteMessage(message: MailMessage) { setPendingConfirm({ title: "删除这封邮件?", description: `邮件“${message.subject || "无主题"}”将被删除。`, confirmText: "删除邮件", onConfirm: () => del.mutate(message.id), }) } function openCompose(draft?: ComposeDraft) { if (draft?.isDraft && !canManageDrafts) return if (!draft?.isDraft && !canSendMail) return setComposeDraft(draft || { key: `new-${Date.now()}` }) setComposeOpen(true) } function openReply(message: MailMessage) { if (!canSendMail) return openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { if (!canSendMail) return openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } async function openDraft(message: MailMessage) { if (!canManageDrafts) return if (scheduledDraftIds.has(message.id)) { toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" }) openScheduled() return } try { const detail = await api.message(message.id, { markRead: false }) openCompose({ key: `draft-${detail.id}-${Date.now()}`, id: detail.id, mailboxId: detail.mailboxId, to: detail.to.join(", "), cc: detail.cc.join(", "), bcc: (detail.bcc || []).join(", "), subject: detail.subject === "(无主题)" ? "" : detail.subject, text: detail.bodyText || "", html: detail.bodyHtml || "", files: await attachmentFilesFromMessage(detail), isDraft: true, }) setSelectedId(null) } catch (error) { toast({ title: "打开草稿失败", description: error instanceof Error ? error.message : "请稍后重试" }) } } function switchMailbox(mailboxId: string) { setSelectedMailboxId(mailboxId) setSelectedExternalAccountId("") setFolder("Inbox") setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") if (mailboxId === "all") { setLabelEditMode(false) setNewLabelEditing(false) } setMobileSidebarOpen(false) } function openFolder(nextFolder: string) { setSelectedExternalAccountId("") setFolder(nextFolder) setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openStarred() { setSelectedExternalAccountId("") setMailView("starred") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openScheduled() { setSelectedExternalAccountId("") setMailView("scheduled") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openUnknownMail() { if (!canViewUnknownMail) return setSelectedExternalAccountId("") setMailView("unknown") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) { if (mailView === "external" || mailView === "unknown") return event.preventDefault() event.stopPropagation() if (message.folder !== "Drafts") setSelectedId(message.id) setMessageContextMenu({ message, x: event.clientX, y: event.clientY }) } function closeMessageContextMenu() { setMessageContextMenu(null) } function openSidebarContextMenu(event: React.MouseEvent, item: MailMenuItem) { event.preventDefault() event.stopPropagation() setSidebarContextMenu({ item, x: event.clientX, y: event.clientY }) } function closeSidebarContextMenu() { setSidebarContextMenu(null) } function activateSidebarItem(item: MailMenuItem) { if (item.type === "starred") openStarred() else if (item.type === "scheduled") openScheduled() else if (item.type === "sendQueue") openSendQueue() else if (item.type === "unknown") openUnknownMail() else openFolder(item.folderName) } function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") { setSelectedExternalAccountId(account.id) setExpandedExternalAccountIds([account.id]) setExternalFolder(folderName) setMailView("external") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function toggleExternalAccount(account: ExternalImapAccount) { if (sidebarCollapsed) { openExternalFolder(account, "INBOX") return } const expanded = expandedExternalAccountIds.includes(account.id) if (expanded) { setExpandedExternalAccountIds((items) => items.filter((id) => id !== account.id)) return } setExpandedExternalAccountIds([account.id]) if (selectedExternalAccountId !== account.id || mailView !== "external") { openExternalFolder(account, "INBOX") } } function reorderCustomFolder(draggedId: string, target: FolderDropTarget) { if (!canOrganizeMail || reorderFolders.isPending) return const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item])) const dragged = foldersByID.get(draggedId) if (!dragged || !isCustomMailFolder(dragged)) return const menuWithoutDragged = mailMenuItems.filter((item) => !(item.type === "folder" && item.folderId === draggedId)) let insertIndex = menuWithoutDragged.length if (target.edge !== "end") { const targetIndex = menuWithoutDragged.findIndex((item) => item.key === target.key) if (targetIndex < 0) return insertIndex = target.edge === "before" ? targetIndex : targetIndex + 1 } const draggedMenuItem: MailMenuItem = { type: "folder", key: dragged.id, folderId: dragged.id, folderName: dragged.name, label: folderLabels[dragged.name] || dragged.name, icon: folderIcons[dragged.role] || , count: dragged.name === "Drafts" ? dragged.totalCount : dragged.unreadCount, custom: true, order: dragged.sortOrder, } const nextMenu = [...menuWithoutDragged] nextMenu.splice(insertIndex, 0, draggedMenuItem) const nextFolders = assignCustomFolderOrders(nextMenu) reorderFolders.mutate(nextFolders) } function moveSidebarFolder(item: MailMenuItem, action: "top" | "up" | "down" | "bottom") { if (item.type !== "folder" || !item.custom) return const currentIndex = mailMenuItems.findIndex((entry) => entry.key === item.key) if (currentIndex < 0) return if (action === "top") { reorderCustomFolder(item.folderId, { key: mailMenuItems[0]?.key || "__end__", edge: "before" }) return } if (action === "bottom") { reorderCustomFolder(item.folderId, { key: "__end__", edge: "end" }) return } const targetIndex = action === "up" ? currentIndex - 1 : currentIndex + 1 const target = mailMenuItems[targetIndex] if (!target) return reorderCustomFolder(item.folderId, { key: target.key, edge: action === "up" ? "before" : "after" }) } function confirmDeleteFolder(item: MailMenuItem) { if (item.type !== "folder" || !item.custom) return setPendingConfirm({ title: `删除文件夹“${item.label}”?`, description: isAllMailboxSelected ? "所有邮箱中的同名文件夹都会删除,文件夹内邮件会移回各自的收件箱。" : "文件夹内的邮件会移回收件箱,不会被删除。", confirmText: "删除文件夹", onConfirm: () => deleteFolder.mutate(item), }) } function handleFolderDragStart(event: React.DragEvent, item: MailMenuItem) { if (item.type !== "folder" || !item.custom || sidebarCollapsed || !canOrganizeMail) return event.dataTransfer.effectAllowed = "move" event.dataTransfer.setData("text/plain", item.folderId) setDraggingFolderId(item.folderId) } function handleFolderDragOver(event: React.DragEvent, item: MailMenuItem) { if (!draggingFolderId || item.key === draggingFolderId) return event.preventDefault() event.dataTransfer.dropEffect = "move" const rect = event.currentTarget.getBoundingClientRect() setFolderDropTarget({ key: item.key, edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after" }) } function handleFolderDrop(event: React.DragEvent, item: MailMenuItem) { if (!draggingFolderId) return event.preventDefault() const draggedId = event.dataTransfer.getData("text/plain") || draggingFolderId const rect = event.currentTarget.getBoundingClientRect() const edge = event.clientY < rect.top + rect.height / 2 ? "before" : "after" setDraggingFolderId("") setFolderDropTarget(null) reorderCustomFolder(draggedId, { key: item.key, edge }) } function handleFolderDropEnd(event: React.DragEvent) { if (!draggingFolderId) return event.preventDefault() const draggedId = event.dataTransfer.getData("text/plain") || draggingFolderId setDraggingFolderId("") setFolderDropTarget(null) reorderCustomFolder(draggedId, { key: "__end__", edge: "end" }) } function clearFolderDragState() { setDraggingFolderId("") setFolderDropTarget(null) } function runMessageContextAction(action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) { closeMessageContextMenu() if (action === "open") { openMessage(message.id) return } if (action === "reply") { openReply(message) return } if (action === "forward") { openForward(message) return } if (action === "read") { markRead.mutate({ id: message.id, read: !message.isRead }) return } if (action === "star") { star.mutate({ id: message.id, starred: !message.isStarred }) return } if (action === "archive") { move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) return } if (action === "trash") { move.mutate({ id: message.id, folder: "Trash" }) return } if (action === "spam") { move.mutate({ id: message.id, folder: "Spam" }) return } confirmDeleteMessage(message) } function openSendQueue() { setSelectedExternalAccountId("") setMailView("sendQueue") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openMessageSendTimeline(message: MailMessage) { if (!message.sendQueueId) return setSendQueueAuditId(message.sendQueueId) } function openLabel(labelId: string) { setSelectedExternalAccountId("") setSelectedLabelId(labelId) setMailView("label") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openMessage(messageId: string | null) { if (!messageId) { setSelectedId(null) return } const message = allMessages.find((item) => item.id === messageId) if (mailView !== "external" && message?.folder === "Drafts") { void openDraft(message) return } setSelectedId(messageId) if (message && !message.isRead && canOrganizeMail && mailView !== "unknown") { if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true }) else markRead.mutate({ id: message.id, read: true }) } } async function refreshMail() { if (refreshing || autoRefreshing) return setRefreshing(true) try { await refreshMailData() const refreshedAt = new Date() setLastAutoRefreshAt(refreshedAt) toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` }) } catch (error) { toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setRefreshing(false) } } async function exportCurrentMail() { if (!canExportCurrentView || exportingMail) return setExportingMail(true) const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder" const selectedMessageIds = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) const anchor = document.createElement("a") anchor.href = api.exportMailUrl({ view: exportView, mailboxId: mailView === "unknown" ? undefined : activeMailboxId, folder: exportView === "folder" ? folder : undefined, labelId: exportView === "label" ? selectedLabelId : undefined, messageIds: selectedMessageIds.length > 0 ? selectedMessageIds : undefined, }) anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip` document.body.appendChild(anchor) anchor.click() anchor.remove() toast({ title: "已开始下载", description: selectedMessageIds.length > 0 ? `正在打包选中的 ${selectedMessageIds.length} 封邮件。` : "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" }) window.setTimeout(() => setExportingMail(false), 1000) } function chooseMailImport() { if (!canImportCurrentView || importingMail) { if (isAllMailboxSelected) toast({ title: "请先选择一个邮箱", description: "导入邮件需要明确目标邮箱。" }) return } mailImportInputRef.current?.click() } async function importSelectedMailFiles(event: React.ChangeEvent) { const files = Array.from(event.target.files || []) event.target.value = "" if (files.length === 0 || !selectedMailbox) return setImportingMail(true) try { const batches = buildMailImportBatches(files) const target = { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" } const result = { imported: 0, skipped: 0, errors: [] as string[] } for (const batch of batches) { try { const current = await api.importMail(batch, target) result.imported += current.imported result.skipped += current.skipped result.errors.push(...current.errors) } catch (error) { result.skipped += batch.length result.errors.push(error instanceof Error ? error.message : "导入请求失败") } } await refreshMailData() if (result.imported === 0 && result.errors.length > 0) throw new Error(result.errors[0]) toast({ title: `已导入 ${result.imported} 封邮件`, description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`, }) } catch (error) { toast({ title: "导入失败", description: error instanceof Error ? error.message : "请检查 EML/MBOX 文件" }) } finally { setImportingMail(false) } } async function copyCurrentMailbox() { if (!selectedMailbox?.address) return await navigator.clipboard.writeText(selectedMailbox.address) toast({ title: "邮箱地址已复制" }) } function openSettings() { void import("@/pages/profile").then(() => navigate("/profile")) } function toggleAdvancedSearch() { setAdvancedSearchDraft(advancedSearch) setAdvancedSearchOpen((open) => !open) } function updateAdvancedSearch(next: AdvancedMailSearch) { setAdvancedSearch(next) setAdvancedSearchDraft(next) setSelectedId(null) setCompactSelectedIds([]) } function applyAdvancedSearch(draft: AdvancedMailSearchDraft) { updateAdvancedSearch({ from: draft.from.trim(), to: draft.to.trim(), subject: draft.subject.trim(), startDate: draft.startDate, endDate: draft.endDate, hasAttachments: draft.hasAttachments, unread: draft.unread, starred: draft.starred, }) setAdvancedSearchOpen(false) } function clearAdvancedSearch() { updateAdvancedSearch(emptyAdvancedSearch) } function removeAdvancedSearchFilter(key: keyof AdvancedMailSearch) { updateAdvancedSearch({ ...advancedSearch, [key]: emptyAdvancedSearch[key] }) } function handleSearchKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") return const parsed = parseSmartSearchQuery(query) if (!parsed.active) return event.preventDefault() setQuery(parsed.query) updateAdvancedSearch({ ...advancedSearch, ...parsed.search }) setAdvancedSearchOpen(false) } const sidebarContent = ( setDarkMode((value) => !value)} language={language} onLanguageChange={setLanguage} onSettings={openSettings} />
{!sidebarCollapsed && showMailboxCopy && ( )}
{canSendMail && ( )}
{!sidebarCollapsed && (
邮件
)} {primaryMailMenuItems.map((item) => ( handleFolderDragStart(event, item)} onDragOver={(event) => handleFolderDragOver(event, item)} onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }} onDrop={(event) => handleFolderDrop(event, item)} onDragEnd={clearFolderDragState} onContextMenu={(event) => openSidebarContextMenu(event, item)} > activateSidebarItem(item)} > {item.icon} {!sidebarCollapsed && {item.label}} {!sidebarCollapsed && } ))} {!sidebarCollapsed && canOrganizeCurrentMailbox && customMailMenuItems.length === 0 && (
{ if (!draggingFolderId) return event.preventDefault() event.dataTransfer.dropEffect = "move" setFolderDropTarget({ key: "__end__", edge: "end" }) }} onDragLeave={() => { if (folderDropTarget?.edge === "end") setFolderDropTarget(null) }} onDrop={handleFolderDropEnd} /> )} {folders.isLoading && } {externalAccountItems.length > 0 && {!sidebarCollapsed && 外部邮箱} {externalAccountItems.map((account) => { const expanded = expandedExternalAccountIds.includes(account.id) return ( toggleExternalAccount(account)} > {!sidebarCollapsed && ( <> {externalAccountLabel(account)} {externalAccountSubtitle(account)} )} {!sidebarCollapsed && expanded && externalFolderItems.map((item) => ( openExternalFolder(account, item.name)} > {folderIcons[item.role.toLowerCase()] || } {folderLabels[item.role] || folderLabels[item.name] || item.name} {item.unreadCount > 0 && {item.unreadCount}} ))} {!sidebarCollapsed && expanded && externalFolders.isLoading && } )})} } {(customMailMenuItems.length > 0 || canManageFolders) && {!sidebarCollapsed && (
文件夹 {canManageFolders && ( )}
)} {customMailMenuItems.map((item) => ( handleFolderDragStart(event, item)} onDragOver={(event) => handleFolderDragOver(event, item)} onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }} onDrop={(event) => handleFolderDrop(event, item)} onDragEnd={clearFolderDragState} onContextMenu={(event) => openSidebarContextMenu(event, item)} > activateSidebarItem(item)} > {item.icon} {!sidebarCollapsed && {item.label}} {!sidebarCollapsed && } ))} {!sidebarCollapsed && canOrganizeCurrentMailbox && (
{ if (!draggingFolderId) return event.preventDefault() event.dataTransfer.dropEffect = "move" setFolderDropTarget({ key: "__end__", edge: "end" }) }} onDragLeave={() => { if (folderDropTarget?.edge === "end") setFolderDropTarget(null) }} onDrop={handleFolderDropEnd} /> )} } {(canReadMail || canManageLabels) && {!sidebarCollapsed && (
标签 {canManageCurrentMailboxLabels && (
{labelEditMode && ( )}
)}
)} {canReadMail && labelItems.map((label) => { const dotColor = labelDotColor(label) return ( { if (!labelEditMode) openLabel(label.id) }} > {sidebarCollapsed ? ( ) : ( {label.name} )} {!sidebarCollapsed && !labelEditMode && !!label.messageCount && ( {label.messageCount} )} {!sidebarCollapsed && labelEditMode && canManageCurrentMailboxLabels && ( )} ) })} {canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 &&
暂无标签
} {canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && ( { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} /> )}
{labels.isLoading && }
} ) const mailTransferTools = isTransferView ? (
{mailView !== "unknown" && ( )}
) : null const contentView = !canAccessMail ? ( ) : !canReadMail ? ( ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? ( navigate("/profile?tab=mailboxes")} /> ) : mailView === "scheduled" && canScheduleMail ? ( cancelScheduledSend.mutate(item)} /> ) : mailView === "scheduled" ? ( ) : mailView === "sendQueue" && canViewSendQueue ? ( { setSendQueueMessageId(""); setSendQueueRecipient(""); setSendQueueFrom(""); setSendQueueTo(""); setSendQueueStatus("all") }} onRetry={(item) => retrySendQueue.mutate(item)} onCancel={(item) => cancelSendQueue.mutate(item)} onAudit={(item) => setSendQueueAuditId(item.id)} canMutate={canSendMail} /> ) : mailView === "sendQueue" ? ( ) : compactMailLayout ? ( {selectedLabel.name} : undefined} messages={visibleMessages} total={allMessages.length} selectedIds={compactSelectedIds} allSelected={compactAllSelected} someSelected={compactSomeSelected} loading={mailMessagesLoading} hasMore={hasMoreMessages} loadingMore={mailMessagesLoadingMore} onLoadMore={loadMoreMessages} emptyMessage={emptyMessage} selectedId={selectedId} selected={selected} detailLoading={detail.isLoading} labels={labelItems} labelPending={addLabel.isPending || removeLabel.isPending} onSelect={openMessage} onSelectAll={toggleCompactSelectAll} onToggleSelected={toggleCompactSelect} scheduledDraftIds={scheduledDraftIds} onCloseReader={() => setSelectedId(null)} onStar={(message) => { if (mailView !== "external") star.mutate({ id: message.id, starred: !message.isStarred }) }} onReply={openReply} onForward={openForward} onSendTimeline={openMessageSendTimeline} onArchive={(message) => { if (mailView !== "external") move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) }} onDelete={(message) => { if (mailView !== "external") confirmDeleteMessage(message) }} onToggleRead={(message) => mailView === "external" && selectedExternalAccountId ? markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: !message.isRead }) : markRead.mutate({ id: message.id, read: !message.isRead })} onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })} bulkPending={bulkPending} onBulkAction={runBulkAction} onContextMenu={openMessageContextMenu} canSend={canSendMail} canOrganize={canOrganizeMail && mailView !== "external" && mailView !== "unknown"} canManageLabels={canManageLabels && mailView !== "external" && mailView !== "unknown"} canDownloadAttachments={canDownloadAttachments} language={language} tools={!isMobile ? mailTransferTools : undefined} /> ) : (
setQuery(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="搜索发件人、主题、内容..." className="h-9 rounded-md bg-background pl-8 text-[13px] shadow-none" />
{advancedSearchOpen && ( )}
{advancedSearchChips.length > 0 && (
{advancedSearchChips.map((chip) => ( removeAdvancedSearchFilter(chip.key)} /> ))}
)}
快捷筛选 {(["attachments", "starred", "recent7"] as MailFilter[]).map((value) => ( ))}
toggleCompactSelectAll(value === true)} />

{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}

{mailTransferTools}
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
已选 {selectedCountOnPage} 封
)} {mailMessagesLoading && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)} {!mailMessagesLoading && visibleMessages.length === 0 &&
{emptyMessage}
} {!mailMessagesLoading && hasMoreMessages && (
)}
{!selectedId && (
选择一封邮件以查看详情
)} {detail.isLoading &&
} {selected &&
{isTwoPaneMailViewport && ( )}

{selected.subject}

{canSendMail && } {canSendMail && } {mailView !== "external" && mailView !== "unknown" && selected.sendQueueId && } {mailView !== "external" && mailView !== "unknown" && canOrganizeMail && (selected.folder === "Archive" ? ( ) : ( ))} {mailView !== "external" && mailView !== "unknown" && canOrganizeMail && }
addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})} />
{selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
}
) return (
{isMobile ? (
{!selectedId && (
邮箱导航
{sidebarContent}
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
{mailTransferTools} {canSendMail && }
setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
)}
{contentView}
) : (
{sidebarContent}
{contentView}
)}
{ setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> { if (!open) setSendQueueAuditId("") }} /> { closeMessageContextMenu() move.mutate({ id: message.id, folder: folderName }) }} onToggleLabel={(message, label) => { const active = (message.labels || []).some((item) => item.id === label.id) active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label }) }} /> { closeSidebarContextMenu() activateSidebarItem(item) }} onRefresh={() => { closeSidebarContextMenu() void refreshMailData() }} onCreateFolder={() => { closeSidebarContextMenu() setFolderDialogOpen(true) }} onMove={(item, action) => { closeSidebarContextMenu() moveSidebarFolder(item, action) }} onDelete={(item) => { closeSidebarContextMenu() confirmDeleteFolder(item) }} /> createFolder.mutate(name)} /> { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
) } function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] { const byName = new Map(folders.map((item) => [item.name, item])) const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 }) for (const item of folders) { if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item) } const folderItems: MailMenuItem[] = normalizedFolders.map((item) => ({ type: "folder", key: item.id, folderId: item.id, folderName: item.name, label: folderLabels[item.name] || item.name, icon: isCustomMailFolder(item) ? : folderIcons[item.role] || , count: item.name === "Drafts" ? item.totalCount : item.unreadCount, custom: isCustomMailFolder(item), order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name), })) const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount, order: 6000 } const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: , count: scheduledCount, order: 7000 } const unknownItem: MailMenuItem = { type: "unknown", key: "unknown", label: "未知收件", icon: , count: 0, order: 8000 } const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 10000 } const specialItems: MailMenuItem[] = [starredItem] if (includeScheduled) specialItems.push(scheduledItem) if (includeUnknown) specialItems.push(unknownItem) if (includeSendQueue && sendQueueCount > 0) specialItems.push(sendQueueItem) return [...folderItems, ...specialItems].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) } function isCustomMailFolder(folder: Pick) { return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase()) } function compareMailFolders(a: MailFolder, b: MailFolder) { return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name) } function assignCustomFolderOrders(items: MailMenuItem[]) { const out: { id: string; sortOrder: number }[] = [] for (let i = 0; i < items.length; i++) { const item = items[i] if (!isCustomMenuFolder(item)) continue let start = 0 for (let j = i - 1; j >= 0; j--) { if (!isCustomMenuFolder(items[j])) { start = items[j].order break } } const groupStart = i let groupEnd = i while (groupEnd + 1 < items.length && isCustomMenuFolder(items[groupEnd + 1])) groupEnd++ let end = start + (groupEnd - groupStart + 2) * 1000 for (let j = groupEnd + 1; j < items.length; j++) { if (!isCustomMenuFolder(items[j])) { end = items[j].order break } } const step = Math.max(1, Math.floor((end - start) / (groupEnd - groupStart + 2))) for (let j = groupStart; j <= groupEnd; j++) { const folder = items[j] as Extract out.push({ id: folder.folderId, sortOrder: start + step * (j - groupStart + 1) }) } i = groupEnd } return out } function isCustomMenuFolder(item: MailMenuItem): item is Extract { return item.type === "folder" && item.custom } function hasAdvancedMailSearch(search: AdvancedMailSearch) { return Boolean( search.from.trim() || search.to.trim() || search.subject.trim() || search.startDate || search.endDate || search.hasAttachments || search.unread || search.starred ) } function messageMatchesAdvancedSearch(message: MailMessage, search: AdvancedMailSearch) { if (!hasAdvancedMailSearch(search)) return true if (search.from.trim() && !searchTextMatches([message.from, message.fromName || ""], search.from)) return false if (search.to.trim() && !searchTextMatches([...(message.to || []), ...(message.cc || []), ...(message.bcc || []), message.recipientAddress || "", message.mailboxAddress || ""], search.to)) return false if (search.subject.trim() && !searchTextMatches([message.subject || ""], search.subject)) return false if (search.hasAttachments && !message.hasAttachments) return false if (search.unread && message.isRead) return false if (search.starred && !message.isStarred) return false const receivedAt = new Date(message.receivedAt).getTime() if (search.startDate) { const start = new Date(`${search.startDate}T00:00:00`).getTime() if (Number.isFinite(start) && (!Number.isFinite(receivedAt) || receivedAt < start)) return false } if (search.endDate) { const end = new Date(`${search.endDate}T23:59:59.999`).getTime() if (Number.isFinite(end) && (!Number.isFinite(receivedAt) || receivedAt > end)) return false } return true } function buildAdvancedSearchChips(search: AdvancedMailSearch): AdvancedSearchChip[] { const chips: AdvancedSearchChip[] = [] if (search.from.trim()) chips.push({ key: "from", label: `发件人: ${search.from.trim()}` }) if (search.to.trim()) chips.push({ key: "to", label: `收件人: ${search.to.trim()}` }) if (search.subject.trim()) chips.push({ key: "subject", label: `主题: ${search.subject.trim()}` }) if (search.startDate) chips.push({ key: "startDate", label: `开始: ${search.startDate}` }) if (search.endDate) chips.push({ key: "endDate", label: `结束: ${search.endDate}` }) if (search.hasAttachments) chips.push({ key: "hasAttachments", label: "有附件" }) if (search.unread) chips.push({ key: "unread", label: "未读" }) if (search.starred) chips.push({ key: "starred", label: "星标" }) return chips } function parseSmartSearchQuery(raw: string): { active: boolean; query: string; search: Partial } { const search: Partial = {} let active = false const query = raw.replace(/\b(from|to|subject|after|before|has|is):(?:"([^"]*)"|'([^']*)'|(\S+))/gi, (match, key: string, quoted: string, singleQuoted: string, bare: string) => { const value = (quoted || singleQuoted || bare || "").trim() const normalizedKey = key.toLowerCase() const normalizedValue = value.toLowerCase() if (!value) return match if (normalizedKey === "from") search.from = value else if (normalizedKey === "to") search.to = value else if (normalizedKey === "subject") search.subject = value else if (normalizedKey === "after" && isDateInputValue(value)) search.startDate = value else if (normalizedKey === "before" && isDateInputValue(value)) search.endDate = value else if (normalizedKey === "has" && ["attachment", "attachments", "file", "files"].includes(normalizedValue)) search.hasAttachments = true else if (normalizedKey === "is" && ["unread", "starred"].includes(normalizedValue)) { if (normalizedValue === "unread") search.unread = true if (normalizedValue === "starred") search.starred = true } else { return match } active = true return " " }).replace(/\s+/g, " ").trim() return { active, query, search } } function isDateInputValue(value: string) { return /^\d{4}-\d{2}-\d{2}$/.test(value) } function searchTextMatches(values: string[], needle: string) { const normalized = needle.trim().toLowerCase() if (!normalized) return true return values.some((value) => value.toLowerCase().includes(normalized)) } function menuAnchorOrder(name: string) { switch (name) { case "Inbox": return 1000 case "Drafts": return 2000 case "Sent": return 3000 case "Archive": return 4000 case "Trash": return 5000 case "Spam": return 9000 default: return 100000 } } function AdvancedSearchPanel({ draft, onDraftChange, onSubmit, onClear }: { draft: AdvancedMailSearchDraft; onDraftChange: (draft: AdvancedMailSearchDraft) => void; onSubmit: (draft: AdvancedMailSearchDraft) => void; onClear: () => void }) { const update = (key: K, value: AdvancedMailSearchDraft[K]) => onDraftChange({ ...draft, [key]: value }) return (
{ event.preventDefault() onSubmit(draft) }} >
update("from", event.target.value)} placeholder="邮箱或名称" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> update("to", event.target.value)} placeholder="邮箱地址" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> update("subject", event.target.value)} placeholder="主题关键词" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> update("startDate", event.target.value)} className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> update("endDate", event.target.value)} className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" />
update("hasAttachments", !draft.hasAttachments)}>有附件 update("unread", !draft.unread)}>未读 update("starred", !draft.starred)}>星标
) } function AdvancedSearchField({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { return ( ) } function AdvancedSearchToggle({ checked, onClick, children }: { checked: boolean; onClick: () => void; children: React.ReactNode }) { return ( ) } function SearchFilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( {label} ) } function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} function getEmptyMessage(mailView: MailView, folder: string, total: number) { if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件" if (mailView === "unknown") return total === 0 ? "暂无未知收件" : "当前筛选条件下没有邮件" if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务" if (total > 0) return "当前筛选条件下没有邮件" if (mailView === "starred") return "暂无星标邮件" if (mailView === "label") return "当前标签没有邮件" if (folder === "Inbox") return "暂无邮件" if (folder === "Drafts") return "还没有草稿" if (folder === "Sent") return "还没有已发送邮件" if (folder === "Trash") return "回收站是空的" if (folder === "Spam") return "暂无垃圾邮件" return "当前文件夹没有邮件" } function externalAccountLabel(account: ExternalImapAccount) { return account.oauthEmail || account.username || account.name || "外部邮箱" } function externalAccountSubtitle(account: ExternalImapAccount) { const label = externalAccountLabel(account) const mode = account.storageMode === "local" ? "同步" : "直连" const name = account.name && account.name !== label ? account.name : "" return [name, account.host, mode].filter(Boolean).join(" · ") } function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) { return (
还没有可用邮箱
请前往邮箱管理,创建、申请或联系管理员分配邮箱。
) } function PermissionEmptyState({ title, description, onOpenSettings }: { title: string; description: string; onOpenSettings: () => void }) { return (
{title}
{description}
) } function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) { const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件" return (
待发送
{items.length} / {total} 封定时邮件
{loading && } {!loading && items.length === 0 &&
{empty}
} {!loading && items.map((item) => ( onCancel(item)} /> ))}
) } function ScheduledSendSkeleton() { return (
{Array.from({ length: 4 }).map((_, index) => (
))}
) } function ScheduledSendRow({ item, compact, pending, onCancel }: { item: ScheduledSend; compact: boolean; pending: boolean; onCancel: () => void }) { const recipients = item.to?.length ? item.to.join(", ") : "未填写收件人" const failed = item.status === "failed" return (
{item.subject || "(无主题)"}
发给 {recipients}
{item.snippet &&
{item.snippet}
} {failed && item.error &&
{item.error}
}
发送时间
{formatDateTime(item.sendAt)}
) } function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) { const label = status === "pending" ? "等待发送" : status === "sending" ? "发送中" : status === "failed" ? "发送失败" : status === "sent" ? "已发送" : "已取消" return ( {label} ) } function attachmentHref(message: MailMessage, attachmentId: string) { if (message.externalAccountId) { return `/api/mail/external-accounts/${encodeURIComponent(message.externalAccountId)}/attachments/${encodeURIComponent(message.id)}/${encodeURIComponent(attachmentId)}` } if (!message.mailboxId) return `/api/admin/attachments/${encodeURIComponent(attachmentId)}` return `/api/mail/attachments/${attachmentId}` } const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [ { value: "all", label: "全部状态" }, { value: "queued", label: "排队中" }, { value: "sending", label: "发送中" }, { value: "failed", label: "发送失败" }, { value: "delivered", label: "已投递" }, { value: "canceled", label: "已取消" }, ] function SendQueueView({ compact, items, total, loading, status, messageId, recipient, from, to, pendingId, onStatusChange, onMessageIdChange, onRecipientChange, onFromChange, onToChange, onClearFilters, onRetry, onCancel, onAudit, canMutate, }: { compact: boolean items: SendQueueItem[] total: number loading: boolean status: SendQueueStatus | "all" messageId: string recipient: string from: string to: string pendingId: string onStatusChange: (status: SendQueueStatus | "all") => void onMessageIdChange: (value: string) => void onRecipientChange: (value: string) => void onFromChange: (value: string) => void onToChange: (value: string) => void onClearFilters: () => void onRetry: (item: SendQueueItem) => void onCancel: (item: SendQueueItem) => void onAudit: (item: SendQueueItem) => void canMutate: boolean }) { const hasFilters = status !== "all" || messageId.trim() || recipient.trim() || from || to const empty = hasFilters ? "当前筛选没有匹配的发送任务" : "发送队列为空" return (
发送队列
{items.length} / {total} 个发送任务
onMessageIdChange(event.target.value)} placeholder="Message-ID" className="h-9" /> onRecipientChange(event.target.value)} placeholder="收件人" className="h-9" /> onFromChange(event.target.value)} className="h-9" /> onToChange(event.target.value)} className="h-9" />
{loading && } {!loading && items.length === 0 &&
{empty}
} {!loading && items.map((item) => ( onRetry(item)} onCancel={() => onCancel(item)} onAudit={() => onAudit(item)} canMutate={canMutate} /> ))}
) } function SendQueueRow({ item, compact, pending, onRetry, onCancel, onAudit, canMutate }: { item: SendQueueItem; compact: boolean; pending: boolean; onRetry: () => void; onCancel: () => void; onAudit: () => void; canMutate: boolean }) { const recipients = item.recipients?.length ? item.recipients.join(", ") : "未记录收件人" const failure = item.lastError || item.error || item.failureReason || "" const canRetry = item.status === "failed" const canCancel = item.status === "queued" || item.status === "failed" return (
{item.subject || "(无主题)"}
发给 {recipients}
来源:{sendQueueSourceLabel(item.source)} 尝试:{item.attemptCount}/{item.maxAttempts} {item.nextAttemptAt && 下次:{formatDateTime(item.nextAttemptAt)}}
{failure &&
{failure}
}
更新时间
{formatDateTime(item.updatedAt || item.createdAt)}
{item.deliveredAt &&
投递于 {formatDateTime(item.deliveredAt)}
}
{canMutate && canRetry && ( )} {canMutate && canCancel && ( )}
) } function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) { const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消" return ( {label} ) } function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) { return ( 投递时间线
{loading &&
{Array.from({ length: 4 }).map((_, index) => )}
} {!loading && events.length === 0 &&
暂无投递事件
} {!loading && events.length > 0 && (
{events.map((event) => (
{event.status && } {event.message || event.event || event.eventType || "队列事件"}
{formatDateTime(event.createdAt)}
{typeof event.attemptCount === "number" && 尝试次数:{event.attemptCount}} {event.error && {event.error}}
))}
)}
) } function sendQueueSourceLabel(source: string) { const normalized = source.toLowerCase() if (normalized === "submission") return "SMTP Submission" if (normalized === "webmail") return "Webmail" if (normalized === "open_api") return "Open API" if (normalized === "scheduled") return "定时发送" return source || "未知" } function datetimeLocalToISO(value: string) { if (!value) return "" const date = new Date(value) return Number.isNaN(date.getTime()) ? "" : date.toISOString() } type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "inbox" | "trash" | "spam" | "delete" function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) { const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive" const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档" const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts") return (
{onMoveToFolder && movableFolders.length > 0 && ( {movableFolders.map((folder) => ( onMoveToFolder(folder.name)}> {folderIcons[folder.role] || } {folderLabels[folder.name] || folder.name} ))} )}
) } function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canCreate: boolean; canReorder: boolean; canDelete: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) { React.useEffect(() => { if (!state) return const close = () => onClose() const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose() } window.addEventListener("pointerdown", close) window.addEventListener("resize", close) window.addEventListener("scroll", close, true) window.addEventListener("keydown", onKeyDown) return () => { window.removeEventListener("pointerdown", close) window.removeEventListener("resize", close) window.removeEventListener("scroll", close, true) window.removeEventListener("keydown", onKeyDown) } }, [state, onClose]) if (!state) return null const item = state.item const customFolder = item.type === "folder" && item.custom const position = contextMenuPosition(state.x, state.y) const itemClass = "h-auto w-full justify-start rounded px-2 py-1.5 text-left font-normal" return (
event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} role="menu" > {canCreate && ( )} {customFolder && (canReorder || canDelete) && ( <>
{canReorder && <> } {canReorder && canDelete &&
} {canDelete && } )}
) } function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canManageLabels, labelPending, onClose, onAction, onMoveToFolder, onToggleLabel }: { state: MessageContextMenuState | null; labels: MailLabel[]; folders: MailFolder[]; canSend: boolean; canOrganize: boolean; canManageLabels: boolean; labelPending: boolean; onClose: () => void; onAction: (action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) => void; onMoveToFolder: (message: MailMessage, folderName: string) => void; onToggleLabel: (message: MailMessage, label: MailLabel) => void }) { React.useEffect(() => { if (!state) return const close = () => onClose() const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose() } window.addEventListener("pointerdown", close) window.addEventListener("resize", close) window.addEventListener("scroll", close, true) window.addEventListener("keydown", onKeyDown) return () => { window.removeEventListener("pointerdown", close) window.removeEventListener("resize", close) window.removeEventListener("scroll", close, true) window.removeEventListener("keydown", onKeyDown) } }, [state, onClose]) if (!state) return null const { message } = state const position = contextMenuPosition(state.x, state.y) const draft = message.folder === "Drafts" const itemClass = "h-auto w-full justify-start rounded-sm px-3 py-2 text-left font-normal" function item(label: string, icon: React.ReactNode, action: Parameters[0], destructive = false) { return ( ) } function toggleLabel(label: MailLabel) { onToggleLabel(message, label) onClose() } function moveToFolder(folderName: string) { onMoveToFolder(message, folderName) onClose() } const movableFolders = folders.filter((folder) => folder.name !== message.folder && folder.name !== "Drafts") return (
event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} role="menu" > {item(draft ? "编辑草稿" : "打开邮件", draft ? : , "open")} {!draft && canSend && ( <> {item("回复", , "reply")} {item("转发", , "forward")} )} {!draft && canOrganize && ( <>
{item(message.isRead ? "标为未读" : "标为已读", , "read")} {item(message.isStarred ? "取消星标" : "添加星标", , "star")} {item(message.folder === "Archive" ? "取消归档" : "归档", , "archive")} {message.folder !== "Trash" && item("移入回收站", , "trash")} {message.folder !== "Spam" && item("移入垃圾邮件", , "spam")} )} {!draft && canOrganize && movableFolders.length > 0 && ( <>
移动到
{movableFolders.map((folder) => ( ))}
)} {canManageLabels && labels.length > 0 && ( <>
标签
{labels.map((label) => { const active = (message.labels || []).some((item) => item.id === label.id) return ( ) })}
)} {canOrganize && ( <>
{item("删除", , "delete", true)} )}
) } function contextMenuPosition(x: number, y: number) { const width = 208 const height = 312 const padding = 8 const maxX = Math.max(padding, window.innerWidth - width - padding) const maxY = Math.max(padding, window.innerHeight - height - padding) return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) } } function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) { const [name, setName] = React.useState("") React.useEffect(() => { if (open) setName("") }, [open]) const trimmed = name.trim() return ( 新建文件夹
{ event.preventDefault() if (trimmed) onCreate(trimmed) }} >
setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
) } function CompactMailView({ title, icon, messages, total, selectedIds, allSelected, someSelected, loading, hasMore, loadingMore, emptyMessage, selectedId, selected, detailLoading, labels, labelPending, onSelect, onSelectAll, onToggleSelected, scheduledDraftIds, onLoadMore, onCloseReader, onStar, onReply, onForward, onSendTimeline, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, bulkPending, onBulkAction, onContextMenu, canSend, canOrganize, canManageLabels, canDownloadAttachments, language, tools, }: { title: string icon?: React.ReactNode messages: MailMessage[] total: number selectedIds: string[] allSelected: boolean someSelected: boolean loading: boolean hasMore: boolean loadingMore: boolean emptyMessage: string selectedId: string | null selected?: MailMessage detailLoading: boolean labels: MailLabel[] labelPending: boolean onSelect: (id: string | null) => void onSelectAll: (checked: boolean) => void onToggleSelected: (id: string, checked: boolean) => void scheduledDraftIds: Set onLoadMore: () => void onCloseReader: () => void onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void onSendTimeline: (message: MailMessage) => void onArchive: (message: MailMessage) => void onDelete: (message: MailMessage) => void onToggleRead: (message: MailMessage) => void onAddLabel: (message: MailMessage, label: MailLabel) => void onRemoveLabel: (message: MailMessage, labelId: string) => void bulkPending: boolean onBulkAction: (action: BulkAction) => void onContextMenu: (event: React.MouseEvent, message: MailMessage) => void canSend: boolean canOrganize: boolean canManageLabels: boolean canDownloadAttachments: boolean language: Language tools?: React.ReactNode }) { const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1 const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined const nextMessage = selectedIndex >= 0 && selectedIndex < messages.length - 1 ? messages[selectedIndex + 1] : undefined if (selectedId) { return ( ) } return (
onSelectAll(value === true)} />
{icon} {title}
{selectedIds.length > 0 ? ( <> 已选 {selectedIds.length} 封 {canOrganize && } ) : (
{messages.length} / {total} 封
{tools}
)}
{loading && } {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onContextMenu={(event) => onContextMenu(event, message)} onStar={() => onStar(message)} canOrganize={canOrganize} />)} {!loading && messages.length === 0 &&
{emptyMessage}
} {!loading && hasMore && (
)}
) } function CompactMessageDetail({ selected, loading, labels, labelPending, previousMessage, nextMessage, onBack, onSelect, onStar, onReply, onForward, onSendTimeline, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, canSend, canOrganize, canManageLabels, canDownloadAttachments, language, }: { selected?: MailMessage loading: boolean labels: MailLabel[] labelPending: boolean previousMessage?: MailMessage nextMessage?: MailMessage onBack: () => void onSelect: (id: string | null) => void onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void onSendTimeline: (message: MailMessage) => void onArchive: (message: MailMessage) => void onDelete: (message: MailMessage) => void onToggleRead: (message: MailMessage) => void onAddLabel: (message: MailMessage, label: MailLabel) => void onRemoveLabel: (message: MailMessage, labelId: string) => void canSend: boolean canOrganize: boolean canManageLabels: boolean canDownloadAttachments: boolean language: Language }) { return (
{selected?.subject || "邮件详情"}
{selected && ( {selected.folder === "Drafts" ? ( onSelect(selected.id)}>编辑草稿 ) : ( <> {canSend && onReply(selected)}>回复} {canSend && onForward(selected)}>转发} {selected.sendQueueId && onSendTimeline(selected)}>投递时间线} {canOrganize && onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}} {canOrganize && onToggleRead(selected)}>{selected.isRead ? "标为未读" : "标为已读"}} {canOrganize && onStar(selected)}>{selected.isStarred ? "取消星标" : "添加星标"}} )} {canOrganize && onDelete(selected)} className="text-destructive">删除} )}
{selected?.folder === "Drafts" ? ( ) : ( <> {selected && canSend && } {selected && canSend && } {selected?.sendQueueId && } {selected && canOrganize && } {selected && canOrganize && } {selected && canOrganize && } )} {selected && canOrganize && }
{loading &&
} {!loading && !selected &&
邮件不存在
} {selected && (

{selected.subject}

{canOrganize && }
onAddLabel(selected, label), onRemoveLabel: (labelId: string) => onRemoveLabel(selected, labelId), labelPending } : {})} />
{selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
)}
) } function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) { const [translatedText, setTranslatedText] = React.useState("") const [translatedHtml, setTranslatedHtml] = React.useState("") const [showTranslated, setShowTranslated] = React.useState(false) const [truncated, setTruncated] = React.useState(false) const { toast } = useToast() const targetLanguage = normalizeTranslationLanguage(language) const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language) const translatedMessage = React.useMemo(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const translate = useMutation({ mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), onSuccess: (result) => { setTranslatedText(result.translatedText) setTranslatedHtml(result.translatedHtml || "") setTruncated(result.truncated) setShowTranslated(true) }, onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) React.useEffect(() => { setTranslatedText("") setTranslatedHtml("") setShowTranslated(false) setTruncated(false) translate.reset() }, [message.id, language]) return ( <> {(shouldShow || translatedText) && (
{translatedText ? `已翻译为 ${translationTargetLabel(language)},当前${showTranslated ? "显示译文" : "显示原文"}` : `检测到邮件可能不是当前语言,可翻译为 ${translationTargetLabel(language)}`} {truncated && (内容较长,仅翻译前半部分)}
{translatedText && }
)} {translatedText && showTranslated ? : } ) } function normalizeTranslationLanguage(language: Language) { if (language === "zh-CN") return "zh-CN" if (language === "zh-TW") return "zh-TW" if (language === "en") return "en" return "" } function translationTargetLabel(language: Language) { if (language === "zh-CN") return "简体中文" if (language === "zh-TW") return "繁體中文" return "English" } function shouldOfferMessageTranslation(text: string, language: Language) { if (!text.trim()) return false const cjkCount = (text.match(/[\u4e00-\u9fff]/g) || []).length const latinCount = (text.match(/[a-zA-Z]/g) || []).length if (language === "zh-CN" || language === "zh-TW") return latinCount > 80 && latinCount > cjkCount * 3 if (language === "en") return cjkCount > 20 return false } function MailHtmlFrame({ message }: { message: MailMessage }) { const iframeRef = React.useRef(null) const [height, setHeight] = React.useState(260) const srcDoc = React.useMemo(() => buildMailFrameSrcDoc(message.bodyHtml || "", message.bodyText || ""), [message.bodyHtml, message.bodyText]) const resize = React.useCallback(() => { const doc = iframeRef.current?.contentDocument if (!doc) return const body = doc.body const html = doc.documentElement const nextHeight = Math.max(180, Math.ceil(Math.max(body?.scrollHeight || 0, body?.offsetHeight || 0, html?.scrollHeight || 0, html?.offsetHeight || 0))) setHeight(nextHeight) }, []) React.useEffect(() => { setHeight(260) const frame = iframeRef.current if (!frame) return let observer: ResizeObserver | undefined const timers = [window.setTimeout(resize, 0), window.setTimeout(resize, 120), window.setTimeout(resize, 600)] const attach = () => { const doc = frame.contentDocument if (!doc) return doc.querySelectorAll("a[href]").forEach((link) => { link.setAttribute("target", "_blank") link.setAttribute("rel", "noopener noreferrer") }) resize() if ("ResizeObserver" in window) { observer = new ResizeObserver(resize) observer.observe(doc.documentElement) if (doc.body) observer.observe(doc.body) } doc.querySelectorAll("img").forEach((img) => img.addEventListener("load", resize, { once: true })) } frame.addEventListener("load", attach) return () => { frame.removeEventListener("load", attach) observer?.disconnect() timers.forEach((timer) => window.clearTimeout(timer)) } }, [resize, srcDoc]) return (