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, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, 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 { Switch } from "@/components/ui/switch" 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: } function NetflixFolderIcon({ className }: { className?: string }) { return } const customFolderIconOptions = [ { key: "folder", label: "文件夹", icon: Folder }, { key: "mail", label: "邮件", icon: Mail }, { key: "briefcase", label: "工作", icon: Briefcase }, { key: "users", label: "联系人", icon: Users }, { key: "receipt", label: "账单", icon: Receipt }, { key: "shopping", label: "购物", icon: ShoppingBag }, { key: "plane", label: "旅行", icon: Plane }, { key: "graduation", label: "学习", icon: GraduationCap }, { key: "heart", label: "收藏", icon: Heart }, { key: "star", label: "重要", icon: Star }, { key: "bell", label: "提醒", icon: Bell }, { key: "shield", label: "安全", icon: ShieldCheck }, { key: "tag", label: "分类", icon: Tag }, { key: "netflix", label: "Netflix", icon: NetflixFolderIcon }, { key: "chatgpt", label: "ChatGPT", icon: Bot }, ] as const function suggestedFolderIcon(name: string) { const value = name.trim().toLocaleLowerCase() if (/netflix|奈飞|网飞/.test(value)) return "netflix" if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt" if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt" if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping" if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane" if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation" if (/联系人|团队|用户|contact|team|people/.test(value)) return "users" if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase" if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart" if (/重要|紧急|important|urgent/.test(value)) return "star" if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield" if (/提醒|通知|remind|notification/.test(value)) return "bell" if (/邮件|邮箱|mail|email/.test(value)) return "mail" return "folder" } async function prepareFolderIcon(file: File) { if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片") if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB") const bitmap = await createImageBitmap(file) try { const canvas = document.createElement("canvas") canvas.width = 64 canvas.height = 64 const context = canvas.getContext("2d") if (!context) throw new Error("无法处理该图片") const scale = Math.min(64 / bitmap.width, 64 / bitmap.height) const width = Math.max(1, Math.round(bitmap.width * scale)) const height = Math.max(1, Math.round(bitmap.height * scale)) context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height) const result = canvas.toDataURL("image/png") if (result.length > 44_000) throw new Error("处理后的图标过大") return result } finally { bitmap.close() } } 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 [foldersExpanded, setFoldersExpanded] = React.useState(true) const [labelsExpanded, setLabelsExpanded] = React.useState(true) 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 [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 }: { id: string; permanent: boolean }) => api.delete(id), onSuccess: async (_, { permanent }) => { setSelectedId(null) setPendingConfirm(null) await refreshMailData() toast({ title: permanent ? "邮件已永久删除" : "邮件已移入已删除" }) }, 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: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) }) 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, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }), 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} 封新邮件` : `新邮件:${messageSubject(first)}` const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender 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(() => { 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 mailMessagesError = mailView === "external" ? externalMessages.error : mailView === "unknown" ? unknownMessages.error : messages.error 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() const refetchMessages = () => mailView === "external" ? externalMessages.refetch() : mailView === "unknown" ? unknownMessages.refetch() : messages.refetch() 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))) completionTitle = `已永久删除 ${ids.length} 封邮件` } else { const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam" const result = await api.bulkMove(ids, target) completionTitle = result.ok ? target === "Trash" ? `已将 ${result.moved} 封邮件移入已删除` : target === "Spam" ? `已将 ${result.moved} 封邮件移入垃圾邮件` : target === "Inbox" ? `已将 ${result.moved} 封邮件移回收件箱` : `已归档 ${result.moved} 封邮件` : "批量移动部分失败" 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) { const permanent = message.folder === "Trash" const subject = messageSubject(message) setPendingConfirm({ title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?", description: permanent ? `邮件“${subject}”将被永久删除,且无法恢复。` : `邮件“${subject}”将移入已删除。`, confirmText: permanent ? "永久删除" : "移入已删除", onConfirm: () => del.mutate({ id: message.id, permanent }), }) } 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(messageSubject(message), "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { if (!canSendMail) return openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(messageSubject(message), "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 runAfterClosingMobileSidebar(action: () => void) { if (!isMobile) { action() return } if (mobileSidebarOpen) setMobileSidebarOpen(false) window.setTimeout(action, 250) } 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() 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 && publicSettings.isError && { void publicSettings.refetch() }} />} {!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 && } {!sidebarCollapsed && folders.isError && { void folders.refetch() }} />} {!sidebarCollapsed && externalImapEnabled && externalMailAccounts.isError && { void externalMailAccounts.refetch() }} />} {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 && } {!sidebarCollapsed && expanded && account.id === selectedExternalAccountId && externalFolders.isError && { void externalFolders.refetch() }} />} )})} } {(customMailMenuItems.length > 0 || canManageFolders) && {!sidebarCollapsed && (
{canManageFolders && ( )}
)} {foldersExpanded && {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 && ( )}
)}
)} {labelsExpanded && {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 && !labels.isError && labelItems.length === 0 &&
暂无标签
} {canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && ( { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} /> )}
{labels.isLoading && } {!sidebarCollapsed && labels.isError && { void labels.refetch() }} />}
}
} { closeSidebarContextMenu() activateSidebarItem(item) }} onRefresh={() => { closeSidebarContextMenu() void refreshMailData() }} onCreateFolder={() => { closeSidebarContextMenu() runAfterClosingMobileSidebar(() => setFolderDialogOpen(true)) }} onMove={(item, action) => { closeSidebarContextMenu() moveSidebarFolder(item, action) }} onDelete={(item) => { closeSidebarContextMenu() runAfterClosingMobileSidebar(() => confirmDeleteFolder(item)) }} /> ) const mailTransferTools = isTransferView ? (
{mailView !== "unknown" && ( )}
) : null const contentView = !canAccessMail ? ( ) : !canReadMail ? ( ) : mailboxList.isError ? ( { void mailboxList.refetch() }} /> ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? ( navigate("/profile?tab=mailboxes")} /> ) : mailView === "scheduled" && scheduledSends.isError ? ( { void scheduledSends.refetch() }} /> ) : mailView === "scheduled" && canScheduleMail ? ( cancelScheduledSend.mutate(item)} /> ) : mailView === "scheduled" ? ( ) : mailView === "sendQueue" && sendQueue.isError ? ( { void sendQueue.refetch() }} /> ) : 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" ? ( ) : mailMessagesError ? ( { void refetchMessages() }} /> ) : 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} detailError={detail.error} onRetryDetail={() => { void detail.refetch() }} 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={() => m.folder === "Trash" ? confirmDeleteMessage(m) : 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 &&
} {detail.isError && { void detail.refetch() }} />} {!detail.isError && 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"] }) }} /> { void sendQueueAudit.refetch() }} onOpenChange={(open) => { 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 }) }} /> createFolder.mutate(payload)} /> { 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(), icon: "folder", 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) ? customFolderIcon(item.icon) : 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 customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") { if (iconKey?.startsWith("data:image/png;base64,")) return const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0] const Icon = option.icon return } 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 MailQueryFailure({ error, onRetry }: { error: Error; onRetry: () => void }) { return (
邮件数据读取失败
{error.message || "请检查网络连接后重试"}
) } function MailDetailFailure({ error, onRetry }: { error: Error; onRetry: () => void }) { return (
邮件详情读取失败
{error.message || "请检查网络连接后重试"}
) } function SidebarQueryFailure({ label, onRetry }: { label: string; onRetry: () => void }) { return (
{label}
) } 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, error, events, onRetry, onOpenChange }: { open: boolean; loading: boolean; error: Error | null; events: SendQueueAuditEvent[]; onRetry: () => void; onOpenChange: (open: boolean) => void }) { return ( 投递时间线
{loading &&
{Array.from({ length: 4 }).map((_, index) => )}
} {!loading && error &&
投递记录读取失败
{error.message}
} {!loading && !error && events.length === 0 &&
暂无投递事件
} {!loading && !error && 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 permanentlyDelete = currentFolder === "Trash" 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)}> {isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : 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(message.folder === "Trash" ? "永久删除" : "移入已删除", , "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, scope, onOpenChange, onCreate }: { open: boolean; pending: boolean; scope: string; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) { const [name, setName] = React.useState("") const [icon, setIcon] = React.useState("auto") const [uploadError, setUploadError] = React.useState("") const iconInputRef = React.useRef(null) React.useEffect(() => { if (open) { setName("") setIcon("auto") setUploadError("") } }, [open]) const trimmed = name.trim() const suggestedIcon = suggestedFolderIcon(trimmed) const resolvedIcon = icon === "auto" ? suggestedIcon : icon return ( 新建文件夹
{ event.preventDefault() if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon }) }} >
setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />

创建位置:{scope}

图标
{customFolderIconOptions.map((option) => { const Icon = option.icon return ( ) })} { const file = event.target.files?.[0] event.target.value = "" if (!file) return try { setUploadError("") setIcon(await prepareFolderIcon(file)) } catch (error) { setUploadError(error instanceof Error ? error.message : "无法处理该图片") } }} />
{uploadError &&

{uploadError}

}
) } function CompactMailView({ title, currentFolder, icon, messages, total, selectedIds, allSelected, someSelected, loading, hasMore, loadingMore, emptyMessage, selectedId, selected, detailLoading, detailError, labels, labelPending, onSelect, onSelectAll, onToggleSelected, scheduledDraftIds, onLoadMore, onRetryDetail, onCloseReader, onStar, onReply, onForward, onSendTimeline, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, bulkPending, onBulkAction, onContextMenu, canSend, canOrganize, canManageLabels, canDownloadAttachments, language, tools, }: { title: string currentFolder?: 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 detailError: Error | null labels: MailLabel[] labelPending: boolean onSelect: (id: string | null) => void onSelectAll: (checked: boolean) => void onToggleSelected: (id: string, checked: boolean) => void scheduledDraftIds: Set onLoadMore: () => void onRetryDetail: () => 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, error, labels, labelPending, previousMessage, nextMessage, onBack, onRetry, onSelect, onStar, onReply, onForward, onSendTimeline, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, canSend, canOrganize, canManageLabels, canDownloadAttachments, language, }: { selected?: MailMessage loading: boolean error: Error | null labels: MailLabel[] labelPending: boolean previousMessage?: MailMessage nextMessage?: MailMessage onBack: () => void onRetry: () => 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 === "Trash" ? "永久删除" : "移入已删除"}} )}
{selected?.folder === "Drafts" ? ( ) : ( <> {selected && canSend && } {selected && canSend && } {selected?.sendQueueId && } {selected && canOrganize && } {selected && canOrganize && } {selected && canOrganize && } )} {selected && canOrganize && }
{loading &&
} {!loading && error && } {!loading && !error && !selected &&
邮件不存在
} {!error && selected && (

{messageSubject(selected)}

{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 qc = useQueryClient() const [translatedText, setTranslatedText] = React.useState("") const [translatedHtml, setTranslatedHtml] = React.useState("") const [showTranslated, setShowTranslated] = React.useState(false) const [truncated, setTruncated] = React.useState(false) const [autoTranslate, setAutoTranslate] = React.useState(() => { try { return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false" } catch { return true } }) 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 translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage]) const translatedMessage = React.useMemo(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const applyTranslation = React.useCallback((result: Awaited>, display = true) => { setTranslatedText(result.translatedText) setTranslatedHtml(result.translatedHtml || "") setTruncated(result.truncated) setShowTranslated(display) }, []) const translate = useMutation({ mutationFn: async ({ force = false }: { force?: boolean } = {}) => { if (!force) { const cached = qc.getQueryData>>(translationKey) if (cached) return cached } const result = message.externalAccountId ? await api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : await api.translateMessage(message.id, targetLanguage!) qc.setQueryData(translationKey, result) return result }, onSuccess: (result) => applyTranslation(result), onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) React.useEffect(() => { setTranslatedText("") setTranslatedHtml("") setShowTranslated(false) setTruncated(false) translate.reset() const cached = qc.getQueryData>>(translationKey) if (cached) applyTranslation(cached, autoTranslate) }, [message.id, language]) React.useEffect(() => { if (!autoTranslate || !shouldShow || translate.isPending) return const cached = qc.getQueryData>>(translationKey) if (cached) { applyTranslation(cached) return } translate.mutate({ force: false }) }, [autoTranslate, message.id, shouldShow, targetLanguage]) const changeAutoTranslate = (checked: boolean) => { setAutoTranslate(checked) if (!checked) setShowTranslated(false) try { window.localStorage.setItem("newszxcn.mail.auto-translate", String(checked)) } catch { // Keep the setting for this session when browser storage is unavailable. } } 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 (