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 type { ImperativePanelHandle } from "react-resizable-panels" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } 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 { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { ConfirmDialog } from "@/components/confirm-dialog" import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, } from "@/components/ui/sidebar" import { useMe } from "@/hooks/use-me" import { useIsMobile } from "@/hooks/use-mobile" import { useToast } from "@/hooks/use-toast" import { hasPermission } from "@/lib/permissions" const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站", } type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type MailFilter = "all" | "unread" | "starred" | "attachments" type MailView = "folder" | "starred" | "label" | "scheduled" 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 MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } const filterLabels: Record = { all: "全部邮件", unread: "未读邮件", starred: "星标邮件", attachments: "有附件", } 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 [selectedId, setSelectedId] = React.useState(null) const [compactSelectedIds, setCompactSelectedIds] = React.useState([]) const [composeOpen, setComposeOpen] = React.useState(false) const [composeDraft, setComposeDraft] = React.useState() const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [mailFilter, setMailFilter] = React.useState("all") const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode] = useDisplayMode() const isMobile = useIsMobile() const [refreshing, setRefreshing] = React.useState(false) const [autoRefreshing, setAutoRefreshing] = React.useState(false) const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null) const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [labelEditMode, setLabelEditMode] = React.useState(false) const [newLabelEditing, setNewLabelEditing] = React.useState(false) const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) const mailAudioContextRef = 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 mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const activeMailboxId = selectedMailbox?.id || "" const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 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 mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false 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, query], queryFn: ({ pageParam }) => { const cursor = typeof pageParam === "string" ? pageParam : "" if (mailView === "starred") return api.starredMessages(query, cursor, activeMailboxId) if (mailView === "label") return api.labelMessages(selectedLabelId, query, cursor, activeMailboxId) return api.messages(folder, query, cursor, activeMailboxId) }, initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId), }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail }) 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), })), } }) } 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: ["mail-stats"] }) }, 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: selectedMailboxId, 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), onMutate: async (id) => { await qc.cancelQueries({ queryKey: ["labels"] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: current.items.filter((l) => l.id !== id) } : current) return { prevLabels } }, 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) toast({ title: "删除标签失败" }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) }, }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } }) const cancelScheduledSend = useMutation({ mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id), onMutate: (item) => setCancelingScheduledId(item.id), onSuccess: async (_, item) => { await qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) toast({ title: item.status === "failed" ? "已移除失败记录" : "已取消定时发送" }) }, onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), onSettled: () => setCancelingScheduledId(""), }) const markAllRead = useMutation({ mutationFn: async (items: MailMessage[]) => { const unread = items.filter((message) => !message.isRead) await Promise.all(unread.map((message) => api.markRead(message.id, true))) return unread.length }, onSuccess: async (count) => { await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["folders"] }) await qc.invalidateQueries({ queryKey: ["mail-stats"] }) await qc.invalidateQueries({ queryKey: ["labels"] }) toast({ title: count > 0 ? `已标记 ${count} 封邮件为已读` : "当前没有未读邮件" }) }, onError: (error) => toast({ title: "操作失败", description: error.message }), }) 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 || !items.some((item) => item.id === selectedMailboxId)) { setSelectedMailboxId(items[0].id) } }, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId]) React.useEffect(() => { if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId) else localStorage.removeItem("lanqin:selected-mailbox") }, [selectedMailboxId]) React.useEffect(() => { setSelectedId(null) setMailFilter("all") }, [mailView]) React.useEffect(() => { setCompactSelectedIds([]) }, [selectedMailboxId, mailView, folder, selectedLabelId, query, displayMode]) React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current) themeMountedRef.current = true }, [darkMode]) React.useEffect(() => { const unlock = () => { const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (AudioContextCtor && !mailAudioContextRef.current) { const ctx = new AudioContextCtor() mailAudioContextRef.current = ctx if (ctx.state === "suspended") void ctx.resume() } if ("Notification" in window && Notification.permission === "default") { void Notification.requestPermission() } } window.addEventListener("pointerdown", unlock, { once: true }) window.addEventListener("keydown", unlock, { once: true }) return () => { window.removeEventListener("pointerdown", unlock) window.removeEventListener("keydown", unlock) } }, []) React.useEffect(() => { if (!activeMailboxId || !inboxProbe.data?.items) return const items = inboxProbe.data.items const latest = items[0] const nextState = { latestId: latest?.id || "", latestReceivedAt: latest?.receivedAt || "" } const prevState = mailNotifyStateRef.current[activeMailboxId] if (!prevState) { mailNotifyStateRef.current[activeMailboxId] = nextState return } const newMessages = items.filter((item) => item.receivedAt > prevState.latestReceivedAt && item.id !== prevState.latestId) mailNotifyStateRef.current[activeMailboxId] = nextState if (newMessages.length === 0) return const first = newMessages[0] const firstSender = senderDisplayName(first) const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}` toast({ title, description }) playIncomingMailSound(mailAudioContextRef) if ("Notification" in window && Notification.permission === "granted") { const notification = new Notification(title, { body: description, tag: `lanqin-mail-${activeMailboxId}`, }) notification.onclick = () => { window.focus() setMailView("folder") setFolder("Inbox") setSelectedId(first.id) notification.close() } } }, [activeMailboxId, inboxProbe.data?.items, toast]) React.useEffect(() => { const events = new EventSource("/api/events", { withCredentials: true }) events.addEventListener("sync", () => { 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: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["mail-notifications"] }), ]).finally(() => { setLastAutoRefreshAt(new Date()) window.setTimeout(() => setAutoRefreshing(false), 600) }) }, mailRefreshInterval || 30000) return () => window.clearInterval(timer) }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) const selected = detail.data const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || [] const visibleMessages = allMessages.filter((message) => { if (mailFilter === "unread") return !message.isRead if (mailFilter === "starred") return message.isStarred if (mailFilter === "attachments") return message.hasAttachments return true }) const unreadCount = allMessages.filter((message) => !message.isRead).length 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 mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail) const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length) const visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected const hasMoreMessages = !!messages.hasNextPage const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage 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: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), ]) } 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 { if (action === "read" || action === "unread") { const read = action === "read" await Promise.all(ids.map((id) => api.markRead(id, read))) } else if (action === "star" || action === "unstar") { const starred = action === "star" await Promise.all(ids.map((id) => api.star(id, starred))) } else if (action === "delete") { await Promise.all(ids.map((id) => api.delete(id))) } else { const target = action === "archive" ? "Archive" : action === "trash" ? "Trash" : "Spam" await Promise.all(ids.map((id) => api.move(id, target))) } if (selectedId && ids.includes(selectedId)) setSelectedId(null) setCompactSelectedIds([]) setPendingConfirm(null) await refreshMailData() toast({ title: `已处理 ${ids.length} 封邮件` }) } catch (error) { toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setBulkPending(false) } } function confirmDeleteMessage(message: MailMessage) { setPendingConfirm({ title: "删除这封邮件?", description: `邮件“${message.subject || "无主题"}”将被删除。`, confirmText: "删除邮件", onConfirm: () => del.mutate(message.id), }) } function openCompose(draft?: ComposeDraft) { if (draft?.isDraft && !canManageDrafts) return if (!draft?.isDraft && !canSendMail) return setComposeDraft(draft || { key: `new-${Date.now()}` }) setComposeOpen(true) } function openReply(message: MailMessage) { if (!canSendMail) return openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { if (!canSendMail) return openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } async function openDraft(message: MailMessage) { if (!canManageDrafts) return if (scheduledDraftIds.has(message.id)) { toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" }) openScheduled() return } try { const detail = await api.message(message.id, { markRead: false }) openCompose({ key: `draft-${detail.id}-${Date.now()}`, id: detail.id, mailboxId: detail.mailboxId, to: detail.to.join(", "), cc: detail.cc.join(", "), bcc: (detail.bcc || []).join(", "), subject: detail.subject === "(无主题)" ? "" : detail.subject, text: detail.bodyText || "", html: detail.bodyHtml || "", files: await attachmentFilesFromMessage(detail), isDraft: true, }) setSelectedId(null) } catch (error) { toast({ title: "打开草稿失败", description: error instanceof Error ? error.message : "请稍后重试" }) } } function switchMailbox(mailboxId: string) { setSelectedMailboxId(mailboxId) setFolder("Inbox") setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openFolder(nextFolder: string) { setFolder(nextFolder) setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openStarred() { setMailView("starred") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openScheduled() { setMailView("scheduled") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") setMobileSidebarOpen(false) } function openLabel(labelId: string) { 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 (message?.folder === "Drafts") { void openDraft(message) return } setSelectedId(messageId) if (message && !message.isRead && canOrganizeMail) { markRead.mutate({ id: message.id, read: true }) } } async function refreshMail() { setRefreshing(true) try { await refreshMailData() setLastAutoRefreshAt(new Date()) } finally { setRefreshing(false) } } async function copyCurrentMailbox() { if (!selectedMailbox?.address) return await navigator.clipboard.writeText(selectedMailbox.address) toast({ title: "邮箱地址已复制" }) } function openSettings() { navigate("/profile") } const sidebarContent = ( setDarkMode((value) => !value)} onSettings={openSettings} />
{!sidebarCollapsed && ( )}
{canSendMail && ( )}
{!sidebarCollapsed && 邮件夹} {mailMenuItems.map((item) => ( item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)} > {item.icon} {!sidebarCollapsed && {item.label}} {!sidebarCollapsed && item.count > 0 && {item.count}} ))} {folders.isLoading && } {(canReadMail || canManageLabels) && {!sidebarCollapsed && (
标签 {canManageLabels && (
{labelEditMode && ( )} {labelItems.length > 0 && ( )}
)}
)} {canReadMail && labelItems.map((label) => { const colors = generateLabelColor(label.name) return ( { if (!labelEditMode) openLabel(label.id) }} > {sidebarCollapsed ? ( ) : ( {label.name} )} {!sidebarCollapsed && !labelEditMode && !!label.messageCount && ( {label.messageCount} )} {!sidebarCollapsed && labelEditMode && canManageLabels && ( )} ) })} {canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 &&
暂无标签
} {canManageLabels && labelEditMode && newLabelEditing && ( { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} /> )}
{labels.isLoading && }
}
{!isMobile && (
)}
) function toggleSidebar() { if (sidebarCollapsed) { sidebarPanelRef.current?.expand(14) setSidebarCollapsed(false) } else { sidebarPanelRef.current?.collapse() setSidebarCollapsed(true) } } const contentView = !canAccessMail ? ( ) : !canReadMail ? ( ) : !mailboxList.isLoading && !hasMailboxes ? ( ) : mailView === "scheduled" && canScheduleMail ? ( cancelScheduledSend.mutate(item)} /> ) : mailView === "scheduled" ? ( ) : isMobile || displayMode === "compact" ? ( {selectedLabel.name} : undefined} messages={visibleMessages} total={allMessages.length} selectedIds={compactSelectedIds} allSelected={compactAllSelected} someSelected={compactSomeSelected} loading={messages.isLoading} hasMore={hasMoreMessages} loadingMore={messages.isFetchingNextPage} onLoadMore={() => messages.fetchNextPage()} emptyMessage={emptyMessage} selectedId={selectedId} selected={selected} detailLoading={detail.isLoading} labels={labelItems} labelPending={addLabel.isPending || removeLabel.isPending} onSelect={openMessage} onSelectAll={toggleCompactSelectAll} onToggleSelected={toggleCompactSelect} scheduledDraftIds={scheduledDraftIds} onCloseReader={() => setSelectedId(null)} onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })} onReply={openReply} onForward={openForward} onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })} onDelete={confirmDeleteMessage} onToggleRead={(message) => 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} canSend={canSendMail} canOrganize={canOrganizeMail} canManageLabels={canManageLabels} canDownloadAttachments={canDownloadAttachments} /> ) : (
toggleCompactSelectAll(value === true)} />
{mailView === "label" && selectedLabel && {selectedLabel.name}}{mailView !== "label" && viewTitle}
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
{selectedCountOnPage > 0 && canOrganizeMail && }
{messages.isLoading && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
} {!messages.isLoading && hasMoreMessages && (
)}
{!selectedId &&
选择一封邮件阅读
} {detail.isLoading &&
} {selected &&

{selected.subject}

{canSendMail && } {canSendMail && } {canOrganizeMail && (selected.folder === "Archive" ? ( ) : ( ))} {canOrganizeMail && }
addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})} />
${escapeHtml(selected.bodyText || "")}`) }} /> {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}
{canSendMail && }
setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
)}
{contentView}
) : ( setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> {sidebarContent}
{(publicSettings.data?.mailAutoRefresh || autoRefreshing) && (
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
)} {mailView !== "scheduled" && ( <> {canOrganizeMail && } {(Object.keys(filterLabels) as MailFilter[]).map((value) => ( setMailFilter(value)}> {filterLabels[value]} ))} )}
setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
{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"] }) }} /> { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
) } function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: 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(), unreadCount: 0, totalCount: 0 }) 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, folderName: item.name, label: folderLabels[item.name] || item.name, icon: folderIcons[item.role] || , count: item.name === "Drafts" ? item.totalCount : item.unreadCount, })) const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount } const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount } const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)] return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)] } function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} function getEmptyMessage(mailView: MailView, folder: string, total: number) { if (mailView === "scheduled") 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 NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) { return (
还没有可用邮箱
请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。
) } function PermissionEmptyState({ title, description, onOpenSettings }: { title: string; description: string; onOpenSettings: () => void }) { return (
{title}
{description}
) } function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) { const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件" return (
待发送
{items.length} / {total} 封定时邮件
{loading && } {!loading && items.length === 0 &&
{empty}
} {!loading && items.map((item) => ( onCancel(item)} /> ))}
) } function ScheduledSendSkeleton() { return (
{Array.from({ length: 4 }).map((_, index) => (
))}
) } function ScheduledSendRow({ item, compact, pending, onCancel }: { item: ScheduledSend; compact: boolean; pending: boolean; onCancel: () => void }) { const recipients = item.to?.length ? item.to.join(", ") : "未填写收件人" const failed = item.status === "failed" return (
{item.subject || "(无主题)"}
发给 {recipients}
{item.snippet &&
{item.snippet}
} {failed && item.error &&
{item.error}
}
发送时间
{formatDateTime(item.sendAt)}
) } function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) { const label = status === "pending" ? "等待发送" : status === "sending" ? "发送中" : status === "failed" ? "发送失败" : status === "sent" ? "已发送" : "已取消" return ( {label} ) } type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { return ( onAction("read")}>标为已读 onAction("unread")}>标为未读 onAction("star")}>添加星标 onAction("unstar")}>取消星标 onAction("archive")}>归档 onAction("trash")}>移入回收站 onAction("spam")}>移入垃圾邮件 onAction("delete")} className="text-destructive">删除 ) } function CompactMailView({ title, icon, messages, total, selectedIds, allSelected, someSelected, loading, hasMore, loadingMore, emptyMessage, selectedId, selected, detailLoading, labels, labelPending, onSelect, onSelectAll, onToggleSelected, scheduledDraftIds, onLoadMore, onCloseReader, onStar, onReply, onForward, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, bulkPending, onBulkAction, canSend, canOrganize, canManageLabels, canDownloadAttachments, }: { title: string icon?: React.ReactNode messages: MailMessage[] total: number selectedIds: string[] allSelected: boolean someSelected: boolean loading: boolean hasMore: boolean loadingMore: boolean emptyMessage: string selectedId: string | null selected?: MailMessage detailLoading: boolean labels: MailLabel[] labelPending: boolean onSelect: (id: string | null) => void onSelectAll: (checked: boolean) => void onToggleSelected: (id: string, checked: boolean) => void scheduledDraftIds: Set onLoadMore: () => void onCloseReader: () => void onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void 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 canSend: boolean canOrganize: boolean canManageLabels: boolean canDownloadAttachments: boolean }) { 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} 封
)}
{loading && } {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} canOrganize={canOrganize} />)} {!loading && messages.length === 0 &&
{emptyMessage}
} {!loading && hasMore && (
)}
) } function CompactMessageDetail({ selected, loading, labels, labelPending, previousMessage, nextMessage, onBack, onSelect, onStar, onReply, onForward, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, canSend, canOrganize, canManageLabels, canDownloadAttachments, }: { selected?: MailMessage loading: boolean labels: MailLabel[] labelPending: boolean previousMessage?: MailMessage nextMessage?: MailMessage onBack: () => void onSelect: (id: string | null) => void onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void 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 }) { return (
{selected?.subject || "邮件详情"}
{selected && ( {selected.folder === "Drafts" ? ( onSelect(selected.id)}>编辑草稿 ) : ( <> {canSend && onReply(selected)}>回复} {canSend && onForward(selected)}>转发} {canOrganize && onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}} {canOrganize && onToggleRead(selected)}>{selected.isRead ? "标为未读" : "标为已读"}} {canOrganize && onStar(selected)}>{selected.isStarred ? "取消星标" : "添加星标"}} )} {canOrganize && onDelete(selected)} className="text-destructive">删除} )}
{selected?.folder === "Drafts" ? ( ) : ( <> {selected && canSend && } {selected && canSend && } {selected && canOrganize && } {selected && canOrganize && } {selected && canOrganize && } )} {selected && canOrganize && }
{loading &&
} {!loading && !selected &&
邮件不存在
} {selected && (

{selected.subject}

{canOrganize && }
onAddLabel(selected, label), onRemoveLabel: (labelId: string) => onRemoveLabel(selected, labelId), labelPending } : {})} />
${escapeHtml(selected.bodyText || "")}`) }} /> {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
)}
) } function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void; canOrganize: boolean }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) return (
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" /> {message.isRead ? ( ) : ( )}
{senderName}
{formatDate(message.receivedAt)} {canOrganize && }
{message.subject} {message.snippet} {scheduled && 已定时} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
{message.snippet}
{formatDate(message.receivedAt)}
{canOrganize && }
) } function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void; editing?: boolean; onEditingChange?: (v: boolean) => void }) { const [internalEditing, setInternalEditing] = React.useState(false) const isEditing = editing ?? internalEditing const setEditingState = onEditingChange ?? setInternalEditing const [value, setValue] = React.useState("") if (collapsed) { return ( setEditingState(true)}> ) } if (isEditing) { return (
{ event.preventDefault() const name = value.trim() if (!name) return onCreate(name) setValue("") setEditingState(false) }} > setValue(event.target.value)} onBlur={() => { if (!value.trim()) setEditingState(false) }} placeholder="新建标签" disabled={pending} />
) } return ( setEditingState(true)}> 新建标签 ) } function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) { const displayName = cleanAccountName(name, email) if (collapsed) { return (
{accountInitial(displayName, email)}
) } return (
{accountInitial(displayName, email)}
{displayName}
) } function MailboxSwitcher({ collapsed, mailboxes, selectedMailbox, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailbox?: Mailbox; onSelect: (mailboxId: string) => void }) { return ( {mailboxes.length === 0 && 没有可用邮箱} {mailboxes.map((mailbox) => ( onSelect(mailbox.id)} className="gap-2"> {mailbox.address} ))} ) } function cleanAccountName(name: string, email?: string) { const value = name.trim() if (!value || (email && value.toLowerCase() === email.toLowerCase())) return email?.split("@")[0] || "用户" return value } function accountInitial(name: string, email?: string) { const source = cleanAccountName(name, email) const first = Array.from(source.trim())[0] return (first || "蓝").toUpperCase() } function senderDisplayName(message: MailMessage) { const fromName = decodeMimeHeader(message.fromName?.trim() || "") if (fromName) return fromName return displayNameFromAddress(message.from) } function displayNameFromAddress(value: string) { const text = decodeMimeHeader(value.trim()) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) const name = namedAddress?.[1]?.trim() if (name) return name const address = text.match(/<([^>]+)>/)?.[1]?.trim() || text const localPart = address.split("@")[0]?.trim() return localPart || text || "未知发件人" } function senderTitle(message: MailMessage) { const name = decodeMimeHeader(message.fromName?.trim() || "") const from = decodeMimeHeader(message.from) return name ? `${name} <${from}>` : from } function senderAddress(message: MailMessage) { return extractAddress(message.from) || decodeMimeHeader(message.fromName?.trim() || "") || "未知发件人地址" } function extractAddress(value?: string) { const text = decodeMimeHeader((value || "").trim()) if (!text) return "" const bracketAddress = text.match(/<([^>]+)>/)?.[1]?.trim() return bracketAddress || text } function cleanAddressList(items?: string[]) { return (items || []).map((item) => extractAddress(item)).filter(Boolean) } function sameMailTime(a?: string, b?: string) { if (!a || !b) return false const left = new Date(a).getTime() const right = new Date(b).getTime() return !Number.isNaN(left) && !Number.isNaN(right) && left === right } function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel, labelPending }: { message: MailMessage availableLabels?: MailLabel[] onAddLabel?: (label: MailLabel) => void onRemoveLabel?: (labelId: string) => void labelPending?: boolean }) { const fromName = senderDisplayName(message) const fromAddress = senderAddress(message) const to = cleanAddressList(message.to) const cc = cleanAddressList(message.cc) const bcc = cleanAddressList(message.bcc) const deliveredTo = extractAddress(message.recipientAddress || message.mailboxAddress || "") const showSentAt = Boolean(message.sentAt) && !sameMailTime(message.sentAt, message.receivedAt) const labels = message.labels || [] return (
{accountInitial(fromName, fromAddress)}
{fromName} {fromAddress} {cc.length > 0 && ( )} {bcc.length > 0 && ( )} {deliveredTo && ( {deliveredTo} )} {showSentAt && ( {formatDateTime(message.sentAt)} )} {formatDateTime(message.receivedAt)} {availableLabels && onAddLabel && onRemoveLabel && (
{labels.map((label) => { const colors = generateLabelColor(label.name) return ( {label.name} ) })} {availableLabels.length === 0 && 请先在侧栏新建标签} {availableLabels.map((label) => { const active = labels.some((l) => l.id === label.id) const colors = generateLabelColor(label.name) return ( { event.preventDefault() active ? onRemoveLabel(label.id) : onAddLabel(label) }} > {label.name} ) })}
)}
) } function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
) } function AddressList({ values, empty = "无" }: { values: string[]; empty?: string }) { if (values.length === 0) return {empty} return (
{values.map((value, index) => ( {value} ))}
) } function MessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize, }: { message: MailMessage active: boolean checked: boolean scheduled?: boolean onCheckedChange: (checked: boolean) => void onClick: () => void onStar: () => void canOrganize: boolean }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) return
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0" />
{senderName}
{canOrganize && }
{formatDate(message.receivedAt)}
{message.subject} {scheduled && 已定时} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
{message.snippet}
} function MailLabelBadge({ label }: { label: MailLabel }) { const colors = generateLabelColor(label.name) return ( {label.name} ) } function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const qc = useQueryClient() const [files, setFiles] = React.useState([]) const [draftAttachments, setDraftAttachments] = React.useState([]) const [attachmentsTouched, setAttachmentsTouched] = React.useState(false) const [draftId, setDraftId] = React.useState(draft?.id || "") const [toValue, setToValue] = React.useState(draft?.to || "") const [ccValue, setCcValue] = React.useState(draft?.cc || "") const [bccValue, setBccValue] = React.useState(draft?.bcc || "") const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "") const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle") const [lastSavedAt, setLastSavedAt] = React.useState(null) const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false) const [sendIntent, setSendIntent] = React.useState(null) const sendStartedRef = React.useRef(false) const lastSavedPayloadRef = React.useRef("") const [showCc, setShowCc] = React.useState(Boolean(draft?.cc)) const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc)) const [sendSeparately, setSendSeparately] = React.useState(false) const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id && canManageSignatures }) const signatureText = defaultSignature.data?.signature?.content || "" const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "") const [body, setBody] = React.useState(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText)) const activeMailboxId = draft?.mailboxId || mailbox?.id || "" const composePayload = React.useMemo(() => ({ mailboxId: activeMailboxId, to: splitEmails(toValue), cc: showCc ? splitEmails(ccValue) : [], bcc: showBcc ? splitEmails(bccValue) : [], subject: subjectValue, text: body.text, html: body.html || plainTextToHtml(body.text), ...(attachmentsTouched ? { attachments: draftAttachments } : {}), }), [activeMailboxId, toValue, showCc, ccValue, showBcc, bccValue, subjectValue, body, attachmentsTouched, draftAttachments]) const hasDraftContent = open && !!activeMailboxId && (toValue.trim() || ccValue.trim() || bccValue.trim() || subjectValue.trim() || body.text.trim() || body.html.trim()) const send = useMutation({ mutationFn: async (payloads: SendPayload[]) => { const sent: MailMessage[] = [] for (const payload of payloads) sent.push(await api.send(payload)) return sent }, onSuccess: async (_, payloads) => { if (draftId) { try { await api.deleteDraft(draftId) } catch {} } toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" }) setFiles([]) setDraftId("") onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }), }) const scheduleSend = useMutation({ mutationFn: (payload: SendPayload & { draftId?: string; sendAt: string }) => api.scheduleSend(payload), onSuccess: (scheduled) => { sendStartedRef.current = true toast({ title: `已定时发送 ${formatDateTime(scheduled.sendAt)}` }) setScheduleDialogOpen(false) setFiles([]) void Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), ]) onSent() }, onError: (e) => toast({ title: "定时发送失败", description: e.message }), }) React.useEffect(() => { if (!open) return sendStartedRef.current = false const nextShowCc = Boolean(draft?.cc) const nextShowBcc = Boolean(draft?.bcc) const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText) lastSavedPayloadRef.current = JSON.stringify({ mailboxId: draft?.mailboxId || mailbox?.id || "", to: splitEmails(draft?.to || ""), cc: nextShowCc ? splitEmails(draft?.cc || "") : [], bcc: nextShowBcc ? splitEmails(draft?.bcc || "") : [], subject: draft?.subject || "", text: nextBody.text, html: nextBody.html || plainTextToHtml(nextBody.text), draftId: draft?.id || "", }) setDraftId(draft?.id || "") setToValue(draft?.to || "") setCcValue(draft?.cc || "") setBccValue(draft?.bcc || "") setSubjectValue(draft?.subject || "") setBody(nextBody) setDraftStatus("idle") setLastSavedAt(null) setShowCc(nextShowCc) setShowBcc(nextShowBcc) setSendSeparately(false) setFiles(draft?.files || []) setDraftAttachments([]) setAttachmentsTouched(false) }, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.html, draft?.files, mailbox?.id, composerText]) React.useEffect(() => { let cancelled = false Promise.all(files.map(fileToAttachment)).then((attachments) => { if (!cancelled) setDraftAttachments(attachments) }) return () => { cancelled = true } }, [files]) React.useEffect(() => { if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return const payloadKey = JSON.stringify({ ...composePayload, draftId }) if (payloadKey === lastSavedPayloadRef.current) return const timer = window.setTimeout(async () => { try { setDraftStatus("saving") const saved = await api.saveDraft(composePayload, draftId || undefined) setDraftId(saved.id) lastSavedPayloadRef.current = JSON.stringify({ ...composePayload, draftId: saved.id }) setLastSavedAt(new Date()) setDraftStatus("saved") await Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), ]) } catch { setDraftStatus("error") } }, 5000) return () => window.clearTimeout(timer) }, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts]) function buildSendWarnings(attachmentsCount: number) { const warnings: string[] = [] const normalizedBody = `${body.text}\n${stripHtml(body.html)}`.toLowerCase() if (!subjectValue.trim()) warnings.push("这封邮件还没有主题。") if (!body.text.trim() && !htmlContainsMeaningfulContent(body.html)) warnings.push("正文还是空的。") if (/(附件|附上|见附件|attached|attachment)/i.test(normalizedBody) && attachmentsCount === 0) warnings.push("正文提到了附件,但还没有添加附件。") return warnings } function confirmOrRun(intent: Omit & { warnings: string[]; defaultDescription?: string }) { if (intent.warnings.length === 0) { intent.onConfirm() return } setSendIntent({ title: intent.title, description: intent.defaultDescription ? `${intent.defaultDescription}\n${intent.warnings.join("\n")}` : intent.warnings.join("\n"), confirmText: intent.confirmText, onConfirm: intent.onConfirm, }) } async function prepareSend() { if (!canSend) return if (!mailbox) return const attachments = await Promise.all(files.map(fileToAttachment)) const to = splitEmails(toValue) const cc = showCc ? splitEmails(ccValue) : [] const bcc = showBcc ? splitEmails(bccValue) : [] const text = body.text const html = body.html || plainTextToHtml(text) const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments } const separateRecipients = Array.from(new Set([...to, ...cc, ...bcc])) const payloads = sendSeparately && separateRecipients.length > 0 ? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] })) : [payload] confirmOrRun({ title: "确认发送这封邮件?", confirmText: sendSeparately && payloads.length > 1 ? "继续分别发送" : "继续发送", warnings: buildSendWarnings(attachments.length), onConfirm: () => { sendStartedRef.current = true setSendIntent(null) send.mutate(payloads) }, }) } async function submit(e: React.FormEvent) { e.preventDefault() if (!mailbox) { toast({ title: "请选择发件邮箱" }) return } await prepareSend() } async function scheduleAt(sendAt: string) { if (!canSchedule) return if (!mailbox) { toast({ title: "请选择发件邮箱" }) return } const attachments = await Promise.all(files.map(fileToAttachment)) const payload: SendPayload & { draftId?: string; sendAt: string } = { mailboxId: mailbox.id, to: splitEmails(toValue), cc: showCc ? splitEmails(ccValue) : [], bcc: showBcc ? splitEmails(bccValue) : [], subject: subjectValue, text: body.text, html: body.html || plainTextToHtml(body.text), attachments, draftId: draftId || undefined, sendAt, } confirmOrRun({ title: "确认定时发送?", confirmText: "继续定时发送", defaultDescription: `发送时间:${formatDateTime(sendAt)}`, warnings: buildSendWarnings(attachments.length), onConfirm: () => { sendStartedRef.current = true setSendIntent(null) scheduleSend.mutate(payload) }, }) } return ( event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} >
{draftId ? "编辑草稿" : "写信"} {draftStatus === "saving" ? "正在保存草稿..." : draftStatus === "saved" && lastSavedAt ? `草稿已保存 ${lastSavedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : draftStatus === "error" ? "草稿保存失败" : ""}
setSendSeparately(value === true)} />
} > setToValue(event.target.value)} required className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> {showCc && ( setCcValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> )} {showBcc && ( setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> )} setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> { setAttachmentsTouched(true); setFiles((current) => [...current, ...nextFiles]) }} onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} />
{canSchedule && } {canSend && } { if (!nextOpen) setSendIntent(null) }} onConfirm={() => sendIntent?.onConfirm()} /> ) } function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { return (
{children} {action}
) } function ScheduleSendDialog({ open, pending, onOpenChange, onConfirm }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onConfirm: (sendAt: string) => void }) { const [value, setValue] = React.useState("") const { toast } = useToast() const presets = React.useMemo(() => scheduledSendPresets(), [open]) React.useEffect(() => { if (open) setValue(defaultScheduledSendValue()) }, [open]) function submit(event: React.FormEvent) { event.preventDefault() const date = new Date(value) if (!value || Number.isNaN(date.getTime()) || !date.getTime()) { toast({ title: "请选择发送时间" }) return } if (date.getTime() <= Date.now() + 30_000) { toast({ title: "发送时间需要晚于当前时间" }) return } onConfirm(date.toISOString()) } return (
定时发送
{presets.map((preset) => ( ))}
setValue(event.target.value)} />
) } type ComposerValue = { text: string; html: string } type InsertDialogState = { kind: "link" | "image"; selectedText: string; url?: string; alt?: string; editing?: boolean } type InsertDialogValue = { url: string; text: string; alt: string } const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"] const composerFontSizeOptions = [ ["2", "小号"], ["3", "正文"], ["4", "中号"], ["5", "大号"], ] as const const composerFontSizeValueByKey: Record = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" } const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"] const composerMenuItemClass = "min-h-9 rounded-md px-3 text-sm transition-colors data-[highlighted]:bg-primary/10 data-[highlighted]:font-semibold data-[highlighted]:text-foreground hover:bg-primary/10 hover:font-semibold hover:text-foreground" function normalizeFontName(value: string) { const cleaned = value.replace(/["']/g, "").split(",")[0]?.trim() || "" if (!cleaned || cleaned === "默认字体") return "" if (/microsoft yahei/i.test(cleaned) || cleaned.includes("微软雅黑")) return "Microsoft YaHei" const lower = cleaned.toLowerCase() return composerFontOptions.find((font) => { const option = font.toLowerCase() return lower === option || lower.includes(option) || option.includes(lower) }) || "" } function normalizeFontSize(value: string) { const cleaned = value.trim().toLowerCase() if (!cleaned) return "" if (composerFontSizeOptions.some(([size]) => size === cleaned)) return cleaned const px = Number(cleaned.replace("px", "")) if (Number.isFinite(px)) { if (px <= 13) return "2" if (px <= 17) return "3" if (px <= 22) return "4" return "5" } if (cleaned.includes("small")) return "2" if (cleaned.includes("large") || cleaned.includes("x-large")) return "5" if (cleaned.includes("medium") || cleaned.includes("normal")) return "3" return "" } function fontLabel(value: string) { const normalized = normalizeFontName(value) if (!normalized) return "默认字体" return normalized === "Microsoft YaHei" ? "微软雅黑" : normalized } function fontSizeLabel(value: string) { const normalized = normalizeFontSize(value) || "3" return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文" } function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) { const trimmed = value.trim() if (!trimmed) return "" const allowed = kind === "image" ? /^(https?:|cid:|data:image\/|\/)/i : /^(https?:|mailto:|tel:|#|\/)/i return allowed.test(trimmed) ? trimmed : `https://${trimmed}` } const ScheduleCardNode = Node.create({ name: "scheduleCard", group: "block", atom: true, selectable: true, draggable: false, addAttributes() { return { title: { default: "" }, time: { default: "" }, duration: { default: "" }, reminder: { default: "" }, repeat: { default: "" }, location: { default: "" }, description: { default: "" }, } }, parseHTML() { return [{ tag: "div[data-schedule-card]" }] }, renderHTML({ HTMLAttributes }) { const { title, time, duration, reminder, repeat, location, description } = HTMLAttributes const rows = [ ["时间", time], ["持续", duration], ["提醒", reminder], ["重复", repeat], location ? ["位置", location] : undefined, description ? ["描述", description] : undefined, ].filter(Boolean) as string[][] return [ "div", mergeAttributes(HTMLAttributes, { "data-schedule-card": "true", style: "border:1px solid #d4d4d8;border-radius:8px;padding:14px 16px;margin:16px 0;background:#fafafa;", }), ["div", { style: "font-weight:600;font-size:16px;margin-bottom:10px;" }, title || "日程"], ...rows.map(([label, value]) => ["div", { style: "margin:6px 0;" }, ["span", { style: "color:#71717a;" }, `${label}:`], value]), ] }, }) function composerInitialHtml(defaultValue: string, defaultHtml?: string) { return sanitizeComposerHtml(defaultHtml !== undefined ? defaultHtml : plainTextToHtml(defaultValue)) || "

" } function composerValueFromEditor(editor: Editor): ComposerValue { const text = editor.getText({ blockSeparator: "\n" }).replace(/\u00a0/g, " ").trimEnd() const html = sanitizeComposerHtml(editor.getHTML()) if (!text.trim() && !htmlContainsMeaningfulContent(html)) return { text: "", html: "" } return { text, html: html || plainTextToHtml(text) } } function editorTextSelection(editor: Editor) { const { from, to, empty } = editor.state.selection if (empty) return "" return editor.state.doc.textBetween(from, to, " ").trim() } function selectedImageAttributes(editor: Editor) { const attrs = editor.getAttributes("image") as { src?: string; alt?: string } return attrs.src ? attrs : null } function scheduleToNodeAttributes(schedule: ScheduleDraft) { const start = parseScheduleStart(schedule) const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000) return { title: schedule.title, time: schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`, duration: schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes), reminder: reminderLabel(schedule.reminderMinutes), repeat: repeatLabel(schedule.repeat), location: schedule.location, description: schedule.description, } } function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { const fileInputRef = React.useRef(null) const dirtyRef = React.useRef(false) const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`) const isMobile = useIsMobile() const [formatOpen, setFormatOpen] = React.useState(() => typeof window === "undefined" ? true : window.innerWidth >= 768) const [scheduleOpen, setScheduleOpen] = React.useState(false) const [emojiOpen, setEmojiOpen] = React.useState(false) const [insertDialog, setInsertDialog] = React.useState(null) const [previewOpen, setPreviewOpen] = React.useState(false) const [empty, setEmpty] = React.useState(!defaultValue.trim()) const [selectionVersion, setSelectionVersion] = React.useState(0) React.useEffect(() => { setFormatOpen(!isMobile) }, [isMobile]) const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), TextStyle, Color, BackgroundColor, FontFamily, FontSize, LinkExtension.configure({ openOnClick: false, enableClickSelection: true, HTMLAttributes: { target: "_blank", rel: "noopener noreferrer" }, }), ImageExtension.configure({ allowBase64: true, HTMLAttributes: { style: "max-width:100%;height:auto;border-radius:8px;margin:12px 0;" } }), TextAlign.configure({ types: ["heading", "paragraph"] }), Placeholder.configure({ placeholder: "输入正文" }), ScheduleCardNode, ], content: composerInitialHtml(defaultValue, defaultHtml), editorProps: { attributes: { class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5", "aria-label": "正文", }, handlePaste(view, event) { const clipboard = event.clipboardData if (!clipboard) return false const html = clipboard.getData("text/html") const text = clipboard.getData("text/plain") if (!html && !text) return false event.preventDefault() const content = html ? sanitizeComposerHtml(html) : plainTextToHtml(text) const container = document.createElement("div") container.innerHTML = content || plainTextToHtml(text) const slice = ProseMirrorDOMParser.fromSchema(view.state.schema).parseSlice(container) view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView()) return true }, }, onCreate({ editor }) { const next = composerValueFromEditor(editor) onChange(next) setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html)) }, onUpdate({ editor }) { dirtyRef.current = true const next = composerValueFromEditor(editor) onChange(next) setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html)) }, onSelectionUpdate() { setSelectionVersion((value) => value + 1) }, onTransaction() { setSelectionVersion((value) => value + 1) }, }) React.useEffect(() => { if (!editor) return const defaultKey = `${defaultValue}\n${defaultHtml || ""}` if (defaultKey === lastDefaultRef.current) return lastDefaultRef.current = defaultKey if (!dirtyRef.current || editor.isEmpty) { const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue) editor.commands.setContent(next.html || "

", { emitUpdate: false }) onChange(next) setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html)) } }, [editor, defaultValue, defaultHtml, onChange]) const textStyleAttributes = editor?.getAttributes("textStyle") as { fontFamily?: string; fontSize?: string; color?: string; backgroundColor?: string } | undefined const activeFont = normalizeFontName(textStyleAttributes?.fontFamily || "") const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3" const activeColor = textStyleAttributes?.color || "" const activeHighlight = textStyleAttributes?.backgroundColor || "" void selectionVersion function applyFont(font: string) { editor?.chain().focus().setFontFamily(font).run() } function applyFontSize(size: string) { const value = composerFontSizeValueByKey[size] if (value) editor?.chain().focus().setFontSize(value).run() } function openInsertDialog(kind: InsertDialogState["kind"]) { if (!editor) return if (kind === "link") { const attrs = editor.getAttributes("link") as { href?: string } setInsertDialog({ kind, selectedText: editorTextSelection(editor), url: attrs.href || "", editing: Boolean(attrs.href) }) return } const imageAttrs = selectedImageAttributes(editor) setInsertDialog({ kind, selectedText: "", url: imageAttrs?.src || "", alt: imageAttrs?.alt || "", editing: Boolean(imageAttrs?.src) }) } function confirmInsert(value: InsertDialogValue) { if (!editor || !insertDialog) return const url = normalizeInsertUrl(value.url, insertDialog.kind) if (!url) return if (insertDialog.kind === "link") { const text = value.text.trim() || insertDialog.selectedText || value.url.trim() if (editor.state.selection.empty && !insertDialog.editing) { editor.chain().focus().insertContent(`${escapeHtml(text)}`).run() } else { if (value.text.trim() && value.text.trim() !== insertDialog.selectedText) editor.chain().focus().insertContent(escapeHtml(text)).run() editor.chain().focus().extendMarkRange("link").setLink({ href: url, target: "_blank", rel: "noopener noreferrer" }).run() } return } if (insertDialog.editing) { editor.chain().focus().updateAttributes("image", { src: url, alt: value.alt.trim() }).run() return } editor.chain().focus().setImage({ src: url, alt: value.alt.trim() }).run() } function insertSignature() { if (!editor || !signatureText.trim()) return editor.chain().focus().insertContent(`


--
${plainTextToHtmlFragment(signatureText)}

`).run() } function insertSchedule(schedule: ScheduleDraft) { if (!editor) return const normalized = normalizeSchedule(schedule) editor.chain().focus().insertContent({ type: "scheduleCard", attrs: scheduleToNodeAttributes(normalized) }).run() onPickFiles([scheduleToFile(normalized)]) } function insertEmoji(emoji: string) { editor?.chain().focus().insertContent(emoji).run() setEmojiOpen(false) } function handlePickedFiles(event: React.ChangeEvent) { const nextFiles = Array.from(event.currentTarget.files || []) if (nextFiles.length > 0) onPickFiles(nextFiles) event.currentTarget.value = "" } return (
editor?.chain().focus().undo().run()}> editor?.chain().focus().redo().run()}> fileInputRef.current?.click()}>附件 openInsertDialog("link")}>链接 openInsertDialog("image")}>图片链接 editor?.chain().focus().setHorizontalRule().run()}>分隔线 } onClick={() => setScheduleOpen(true)} />
{composerEmojiOptions.map((emoji) => ( ))}
} active={formatOpen} onClick={() => setFormatOpen((value) => !value)} />
} active={previewOpen} onClick={() => setPreviewOpen(true)} /> } onClick={insertSignature} disabled={!signatureText.trim()} />
{formatOpen && (
editor?.chain().focus().unsetAllMarks().clearNodes().run()}> {composerFontOptions.map((font) => ( applyFont(font)}> {font} ))} {composerFontSizeOptions.map(([size, label]) => ( applyFontSize(size)}> {label} ))} editor?.chain().focus().toggleBold().run()}> editor?.chain().focus().toggleItalic().run()}> editor?.chain().focus().toggleUnderline().run()}> editor?.chain().focus().toggleStrike().run()}> {composerTextColors.map(([color, label]) => ( color === "#111827" ? editor?.chain().focus().unsetColor().run() : editor?.chain().focus().setColor(color).run()}> {label} ))} {composerHighlightColors.map(([color, label]) => ( color === "transparent" ? editor?.chain().focus().unsetBackgroundColor().run() : editor?.chain().focus().setBackgroundColor(color).run()}> {label} ))} editor?.chain().focus().toggleBulletList().run()}> editor?.chain().focus().toggleOrderedList().run()}> editor?.chain().focus().liftListItem("listItem").run()}> editor?.chain().focus().sinkListItem("listItem").run()}> editor?.chain().focus().setTextAlign("left").run()}> editor?.chain().focus().setTextAlign("center").run()}> editor?.chain().focus().setTextAlign("right").run()}> editor?.chain().focus().toggleBlockquote().run()}> editor?.chain().focus().toggleCodeBlock().run()}>
)}
{files.length > 0 && (
{files.map((file, index) => ( {file.name} {formatBytes(file.size)} ))}
)} { if (!open) setInsertDialog(null) }} onConfirm={confirmInsert} /> { insertSchedule(schedule); setScheduleOpen(false) }} /> 邮件预览

" }} />
) } function ToolbarTextButton({ label, icon, active, disabled, onClick }: { label: string; icon: React.ReactNode; active?: boolean; disabled?: boolean; onClick?: () => void }) { return ( ) } function InsertContentDialog({ state, onOpenChange, onConfirm }: { state: InsertDialogState | null; onOpenChange: (open: boolean) => void; onConfirm: (value: InsertDialogValue) => void }) { const kind = state?.kind || "link" const [url, setUrl] = React.useState("") const [text, setText] = React.useState("") const [alt, setAlt] = React.useState("") React.useEffect(() => { if (!state) return setUrl(state.url || "") setText(state.kind === "link" ? state.selectedText : "") setAlt(state.alt || "") }, [state]) function submit(event: React.FormEvent) { event.preventDefault() if (!url.trim()) return onConfirm({ url, text, alt }) onOpenChange(false) } return (
{kind === "link" ? (state?.editing ? "编辑链接" : "插入链接") : (state?.editing ? "编辑图片" : "插入图片")}
setUrl(event.target.value)} placeholder={kind === "link" ? "https://example.com" : "https://example.com/image.png"} autoFocus />
{kind === "link" ? (
setText(event.target.value)} placeholder="默认使用链接地址" />
) : (
setAlt(event.target.value)} placeholder="图片说明" />
)}
) } type ScheduleDraft = { title: string start: string durationMinutes: number reminderMinutes: number repeat: "none" | "daily" | "weekly" | "monthly" | "yearly" allDay: boolean customDuration: boolean customReminder: boolean lunar: boolean location: string description: string } const durationOptions = [ { value: "15", label: "15分钟" }, { value: "30", label: "30分钟" }, { value: "60", label: "1小时" }, { value: "120", label: "2小时" }, { value: "1440", label: "1天" }, ] const reminderOptions = [ { value: "0", label: "准时" }, { value: "5", label: "5分钟前" }, { value: "15", label: "15分钟前" }, { value: "30", label: "30分钟前" }, { value: "60", label: "1小时前" }, { value: "1440", label: "1天前" }, ] const repeatOptions = [ { value: "none", label: "永不" }, { value: "daily", label: "每天" }, { value: "weekly", label: "每周" }, { value: "monthly", label: "每月" }, { value: "yearly", label: "每年" }, ] as const function ScheduleDialog({ open, onOpenChange, onConfirm }: { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: (schedule: ScheduleDraft) => void }) { const [duration, setDuration] = React.useState("60") const [reminder, setReminder] = React.useState("15") const [repeat, setRepeat] = React.useState("none") const [allDay, setAllDay] = React.useState(false) const [customDuration, setCustomDuration] = React.useState(false) const [customReminder, setCustomReminder] = React.useState(false) const [lunar, setLunar] = React.useState(false) const defaultStart = React.useMemo(() => defaultScheduleStartValue(), [open]) const { toast } = useToast() function submit(event: React.FormEvent) { event.preventDefault() const form = new FormData(event.currentTarget) const title = String(form.get("title") || "").trim() if (!title) { toast({ title: "请输入日程主题" }) return } const durationMinutes = customDuration ? Number(form.get("customDuration") || 60) : Number(duration) const reminderMinutes = customReminder ? Number(form.get("customReminder") || 15) : Number(reminder) onConfirm({ title, start: String(form.get("start") || defaultStart), durationMinutes: Math.max(1, durationMinutes || 60), reminderMinutes: Math.max(0, reminderMinutes || 0), repeat, allDay, customDuration, customReminder, lunar, location: String(form.get("location") || ""), description: String(form.get("description") || ""), }) event.currentTarget.reset() setDuration("60") setReminder("15") setRepeat("none") setAllDay(false) setCustomDuration(false) setCustomReminder(false) setLunar(false) } return ( 新建日程
{customDuration ? ( ) : ( )} {customReminder ? ( ) : ( )}
) } function ScheduleRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
) } function CheckLabel({ id, label, checked, onCheckedChange }: { id: string; label: string; checked: boolean; onCheckedChange: (checked: boolean) => void }) { return (
onCheckedChange(value === true)} />
) } function ToolbarButton({ label, children, active, onClick, disabled }: { label: string; children: React.ReactNode; active?: boolean; onClick?: () => void; disabled?: boolean }) { return ( ) } function splitEmails(s: string) { return s.split(/[;,,\s]+/).map((v) => v.trim()).filter(Boolean) } function defaultScheduledSendValue() { const date = new Date(Date.now() + 30 * 60 * 1000) const minute = date.getMinutes() date.setMinutes(minute + (5 - (minute % 5 || 5))) return toDateTimeLocalValue(date) } function scheduledSendPresets() { return [ { label: "30 分钟后", value: toDateTimeLocalValue(new Date(Date.now() + 30 * 60 * 1000)) }, { label: "2 小时后", value: toDateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)) }, { label: "明早 9 点", value: toDateTimeLocalValue(nextMorningAtNine()) }, { label: "下周一 9 点", value: toDateTimeLocalValue(nextMondayAtNine()) }, ] } function nextMorningAtNine() { const date = new Date() date.setDate(date.getDate() + 1) date.setHours(9, 0, 0, 0) return date } function nextMondayAtNine() { const date = new Date() const day = date.getDay() const daysUntilMonday = (8 - day) % 7 || 7 date.setDate(date.getDate() + daysUntilMonday) date.setHours(9, 0, 0, 0) return date } function defaultScheduleStartValue() { const date = new Date() date.setMinutes(date.getMinutes() + (60 - (date.getMinutes() % 60 || 60))) return toDateTimeLocalValue(date) } function toDateTimeLocalValue(date: Date) { const pad = (value: number) => String(value).padStart(2, "0") return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` } function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft { return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() } } function scheduleToHtml(schedule: ScheduleDraft) { const start = parseScheduleStart(schedule) const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000) const rows = [ ["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`], ["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)], ["提醒", reminderLabel(schedule.reminderMinutes)], ["重复", repeatLabel(schedule.repeat)], schedule.location ? ["位置", schedule.location] : undefined, schedule.description ? ["描述", schedule.description] : undefined, ].filter(Boolean) as string[][] return DOMPurify.sanitize(`
${escapeHtml(schedule.title)}
${rows.map(([label, value]) => `
${label}:${escapeHtml(value)}
`).join("")}
`) } function scheduleToFile(schedule: ScheduleDraft) { const ics = scheduleToIcs(schedule) const filename = `${safeFilename(schedule.title || "schedule")}.ics` return new File([ics], filename, { type: "text/calendar;charset=utf-8" }) } function scheduleToIcs(schedule: ScheduleDraft) { const start = parseScheduleStart(schedule) const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000) const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@lanqin-email` const lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//LanQin Email//Webmail//CN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", `UID:${uid}`, `DTSTAMP:${toIcsDateTime(new Date())}`, schedule.allDay ? `DTSTART;VALUE=DATE:${toIcsDate(start)}` : `DTSTART:${toIcsDateTime(start)}`, schedule.allDay ? `DTEND;VALUE=DATE:${toIcsDate(end)}` : `DTEND:${toIcsDateTime(end)}`, `SUMMARY:${escapeIcs(schedule.title)}`, schedule.location ? `LOCATION:${escapeIcs(schedule.location)}` : "", schedule.description ? `DESCRIPTION:${escapeIcs(schedule.description)}` : "", schedule.repeat !== "none" ? `RRULE:FREQ=${schedule.repeat.toUpperCase()}` : "", ].filter(Boolean) if (schedule.reminderMinutes > 0) { lines.push("BEGIN:VALARM", `TRIGGER:-PT${schedule.reminderMinutes}M`, "ACTION:DISPLAY", `DESCRIPTION:${escapeIcs(schedule.title)}`, "END:VALARM") } lines.push("END:VEVENT", "END:VCALENDAR") return `${lines.join("\r\n")}\r\n` } function parseScheduleStart(schedule: ScheduleDraft) { const value = schedule.allDay ? `${schedule.start.slice(0, 10)}T00:00` : schedule.start const date = new Date(value) return Number.isNaN(date.getTime()) ? new Date() : date } function toIcsDateTime(date: Date) { const pad = (value: number) => String(value).padStart(2, "0") return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` } function toIcsDate(date: Date) { const pad = (value: number) => String(value).padStart(2, "0") return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` } function formatTimeOnly(date: Date) { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }) } function durationLabel(minutes: number) { if (minutes % 1440 === 0) return `${minutes / 1440}天` if (minutes % 60 === 0) return `${minutes / 60}小时` return `${minutes}分钟` } function reminderLabel(minutes: number) { if (minutes <= 0) return "准时" return `${durationLabel(minutes)}前` } function repeatLabel(repeat: ScheduleDraft["repeat"]) { return ({ none: "永不", daily: "每天", weekly: "每周", monthly: "每月", yearly: "每年" } as Record)[repeat] } function safeFilename(value: string) { return value.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-").slice(0, 64) || "schedule" } function escapeIcs(value: string) { return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;") } function plainTextComposerValue(value: string): ComposerValue { return { text: value, html: plainTextToHtml(value) } } function htmlComposerValue(value: string): ComposerValue { const html = sanitizeComposerHtml(value || "") const text = stripHtml(html) return { text, html: html || plainTextToHtml(text) } } function plainTextToHtml(value: string) { const normalized = value.replace(/\r\n/g, "\n") if (!normalized.trim()) return "" return sanitizeComposerHtml(normalized.split(/\n{2,}/).map((paragraph) => `

${plainTextToHtmlFragment(paragraph) || "
"}

`).join("")) } function plainTextToHtmlFragment(value: string) { return value.split("\n").map((line) => escapeHtml(line)).join("
") } function escapeHtml(value: string) { return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") } function sanitizeComposerHtml(value: string) { return DOMPurify.sanitize(value || "") } function htmlContainsMeaningfulContent(html: string) { return /<(img|hr|table|ul|ol|li|blockquote|pre|div)[\s>]/i.test(html) || stripHtml(html).trim().length > 0 } function playIncomingMailSound(ref: React.MutableRefObject) { const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (!AudioContextCtor) return const ctx = ref.current || new AudioContextCtor() ref.current = ctx if (ctx.state === "suspended") void ctx.resume() const now = ctx.currentTime const gain = ctx.createGain() gain.gain.setValueAtTime(0.0001, now) gain.gain.exponentialRampToValueAtTime(0.12, now + 0.02) gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45) gain.connect(ctx.destination) for (const [index, frequency] of [880, 1175].entries()) { const osc = ctx.createOscillator() const start = now + index * 0.14 osc.type = "sine" osc.frequency.setValueAtTime(frequency, start) osc.connect(gain) osc.start(start) osc.stop(start + 0.18) } } function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` } function quoteMessage(message: MailMessage) { const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const quote = body.split("\n").map((line) => `> ${line}`).join("\n") return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}` } function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" } async function fileToAttachment(file: File) { const buffer = await file.arrayBuffer() let binary = "" const bytes = new Uint8Array(buffer) for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) return { filename: file.name, contentType: file.type || "application/octet-stream", contentBase64: btoa(binary) } } async function attachmentFilesFromMessage(message: MailMessage) { if (!message.attachments?.length) return [] return Promise.all(message.attachments.map(async (attachment) => { const response = await fetch(`/api/mail/attachments/${attachment.id}`, { credentials: "include" }) const blob = await response.blob() return new File([blob], attachment.filename, { type: attachment.contentType || blob.type || "application/octet-stream" }) })) }