import * as React from "react" import DOMPurify from "dompurify" import { marked } from "marked" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react" import { api, Mailbox, MailFolder, MailLabel, MailMessage } from "@/lib/api" import { cn, formatBytes, formatDate, formatDateTime } 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 { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" 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 { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, } from "@/components/ui/sidebar" import { useMe } from "@/hooks/use-me" import { useToast } from "@/hooks/use-toast" const folderIcons: Record = { inbox: , sent: , archive: , trash: } const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站", } type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string } type MailFilter = "all" | "unread" | "starred" | "attachments" type MailView = "folder" | "starred" | "label" type MailMenuItem = | { type: "starred"; 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 [refreshing, setRefreshing] = React.useState(false) const [bulkPending, setBulkPending] = React.useState(false) const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes }) 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 folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId }) const messages = useQuery({ queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query], queryFn: () => { if (mailView === "starred") return api.starredMessages(query, "", activeMailboxId) if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", activeMailboxId) return api.messages(folder, query, "", activeMailboxId) }, enabled: !!activeMailboxId && (mailView !== "label" || !!selectedLabelId), }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId }) function updateCachedMessage(id: string, patch: Partial) { qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueriesData({ queryKey: ["messages"] }, (current: { items?: MailMessage[] } | undefined) => { if (!current?.items) return current return { ...current, items: current.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 }), onSuccess: async (data) => { if (selectedId) qc.setQueryData(["message", selectedId], (current: MailMessage | undefined) => current ? { ...current, labels: data.labels } : current) await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["labels"] }) }, onError: (error) => toast({ title: "添加标签失败", description: error.message }), }) const removeLabel = useMutation({ mutationFn: ({ id, labelId }: { id: string; labelId: string }) => api.removeLabel(id, labelId), onSuccess: async (data) => { if (selectedId) qc.setQueryData(["message", selectedId], (current: MailMessage | undefined) => current ? { ...current, labels: data.labels } : current) await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["labels"] }) }, onError: (error) => toast({ title: "移除标签失败", description: error.message }), }) const createLabel = useMutation({ mutationFn: (name: string) => api.createLabel({ mailboxId: selectedMailboxId, name }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["labels"] }) toast({ title: "标签已创建" }) }, onError: (error) => toast({ title: "创建标签失败", description: error.message }), }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), 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 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 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 events = new EventSource("/api/events", { withCredentials: true }) events.addEventListener("sync", () => { qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["labels"] }) }) return () => events.close() }, [qc]) React.useEffect(() => { if (!publicSettings.data?.mailAutoRefresh) return const interval = Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) const timer = window.setInterval(() => { qc.invalidateQueries({ queryKey: ["messages"] }) qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["labels"] }) }, interval) return () => window.clearInterval(timer) }, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc]) const selected = detail.data const allMessages = messages.data?.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 mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount) const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const viewTitle = mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder const emptyMessage = allMessages.length === 0 ? (mailView === "starred" ? "暂无星标邮件" : mailView === "label" ? "当前标签没有邮件" : "当前文件夹没有邮件") : "当前筛选条件下没有邮件" 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 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"] }), ]) } async function runBulkAction(action: BulkAction) { const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) if (ids.length === 0) return 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([]) await refreshMailData() toast({ title: `已处理 ${ids.length} 封邮件` }) } catch (error) { toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setBulkPending(false) } } function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) } function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } function switchMailbox(mailboxId: string) { setSelectedMailboxId(mailboxId) setFolder("Inbox") setMailView("folder") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") } function openFolder(nextFolder: string) { setFolder(nextFolder) setMailView("folder") setSelectedLabelId("") setSelectedId(null) } function openStarred() { setMailView("starred") setSelectedLabelId("") setSelectedId(null) setMailFilter("all") } function openLabel(labelId: string) { setSelectedLabelId(labelId) setMailView("label") setSelectedId(null) setMailFilter("all") } function openMessage(messageId: string | null) { setSelectedId(messageId) if (!messageId) return const message = allMessages.find((item) => item.id === messageId) if (message && !message.isRead) { markRead.mutate({ id: message.id, read: true }) } } async function refreshMail() { setRefreshing(true) try { await refreshMailData() } finally { setRefreshing(false) } } async function copyCurrentMailbox() { if (!selectedMailbox?.address) return await navigator.clipboard.writeText(selectedMailbox.address) toast({ title: "邮箱地址已复制" }) } function openSettings() { navigate("/profile") } function toggleSidebar() { if (sidebarCollapsed) { sidebarPanelRef.current?.expand(14) setSidebarCollapsed(false) } else { sidebarPanelRef.current?.collapse() setSidebarCollapsed(true) } } return (
setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> setDarkMode((value) => !value)} onSettings={openSettings} />
{!sidebarCollapsed && ( )}
{!sidebarCollapsed && 邮件夹} {mailMenuItems.map((item) => ( item.type === "starred" ? openStarred() : openFolder(item.folderName)} > {item.icon} {!sidebarCollapsed && {item.label}} {!sidebarCollapsed && item.count > 0 && {item.count}} ))} {folders.isLoading && } {!sidebarCollapsed && 标签} {labelItems.map((label) => ( openLabel(label.id)}> {!sidebarCollapsed && {label.name}} {!sidebarCollapsed && !!label.messageCount && {label.messageCount}} ))} {!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 &&
暂无标签
} createLabel.mutate(name)} />
{labels.isLoading && }
{(Object.keys(filterLabels) as MailFilter[]).map((value) => ( setMailFilter(value)}> {filterLabels[value]} ))}
setQuery(e.target.value)} placeholder="搜索邮件" className="pl-9" />
{displayMode === "compact" ? ( : undefined} messages={visibleMessages} total={allMessages.length} selectedIds={compactSelectedIds} allSelected={compactAllSelected} someSelected={compactSomeSelected} loading={messages.isLoading} emptyMessage={emptyMessage} selectedId={selectedId} selected={selected} detailLoading={detail.isLoading} labels={labelItems} labelPending={addLabel.isPending || removeLabel.isPending} onSelect={openMessage} onSelectAll={toggleCompactSelectAll} onToggleSelected={toggleCompactSelect} 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={(message) => del.mutate(message.id)} 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} /> ) : (
toggleCompactSelectAll(value === true)} />
{mailView === "label" && selectedLabel && }{viewTitle}
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
{selectedCountOnPage > 0 && }
{messages.isLoading && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
}
{!selectedId &&
选择一封邮件阅读
} {detail.isLoading &&
} {selected &&

{selected.subject}

{selected.folder === "Archive" ? ( ) : ( )}
{selected.from} 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}
addLabel.mutate({ id: selected.id, label })} onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })} pending={addLabel.isPending || removeLabel.isPending} />
${selected.bodyText || ""}`) }} /> {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => {a.filename}{formatBytes(a.sizeBytes)})}
}
}
)}
{ 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"] }) }} />
) } function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMenuItem[] { const folderItems: MailMenuItem[] = folders.map((item) => ({ type: "folder", key: item.id, folderName: item.name, label: folderLabels[item.name] || item.name, icon: folderIcons[item.role] || , count: item.unreadCount, })) const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount } const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)] } function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} 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, emptyMessage, selectedId, selected, detailLoading, labels, labelPending, onSelect, onSelectAll, onToggleSelected, onCloseReader, onStar, onReply, onForward, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, bulkPending, onBulkAction, }: { title: string icon?: React.ReactNode messages: MailMessage[] total: number selectedIds: string[] allSelected: boolean someSelected: boolean loading: 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 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 }) { 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} 封 ) : (
{messages.length} / {total} 封
)}
{loading && } {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} {!loading && messages.length === 0 &&
{emptyMessage}
}
) } function CompactMessageDetail({ selected, loading, labels, labelPending, previousMessage, nextMessage, onBack, onSelect, onStar, onReply, onForward, onArchive, onDelete, onToggleRead, onAddLabel, onRemoveLabel, }: { 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 }) { return (
{selected && } {selected && } {selected && } {selected && } {selected && } {selected && }
{loading &&
} {!loading && !selected &&
邮件不存在
} {selected && (

{selected.subject}

{accountInitial(selected.from)}
{selected.from}
收件人 {selected.to.join(", ")}
{formatDateTime(selected.receivedAt)}
onAddLabel(selected, label)} onRemove={(labelId) => onRemoveLabel(selected, labelId)} pending={labelPending} />
${selected.bodyText || ""}`) }} /> {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => {a.filename}{formatBytes(a.sizeBytes)})}
}
)}
) } function CompactMessageRow({ message, active, checked, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) { const visibleLabels = (message.labels || []).slice(0, 2) return (
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} />
{message.from}
{message.subject} {message.snippet} {visibleLabels.map((label) => )} {message.hasAttachments && }
{formatDate(message.receivedAt)}
) } function NewLabelButton({ collapsed, pending, onCreate }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void }) { const [editing, setEditing] = React.useState(false) const [value, setValue] = React.useState("") if (collapsed) { return ( setEditing(true)}> ) } if (editing) { return (
{ event.preventDefault() const name = value.trim() if (!name) return onCreate(name) setValue("") setEditing(false) }} > setValue(event.target.value)} onBlur={() => { if (!value.trim()) setEditing(false) }} placeholder="新建标签" disabled={pending} />
) } return ( setEditing(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 MessageRow({ message, active, checked, onCheckedChange, onClick, onStar, }: { message: MailMessage active: boolean checked: boolean onCheckedChange: (checked: boolean) => void onClick: () => void onStar: () => void }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) return
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0" />
{message.from}
{formatDate(message.receivedAt)}
{message.subject} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
{message.snippet}
} function MailLabelBadge({ label, compact }: { label: MailLabel; compact?: boolean }) { return ( {label.name} ) } function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pending }: { messageLabels: MailLabel[]; availableLabels: MailLabel[]; onAdd: (label: MailLabel) => void; onRemove: (labelId: string) => void; pending: boolean }) { const activeIds = new Set(messageLabels.map((label) => label.id)) return (
标签
{messageLabels.map((label) => ( {label.name} ))} {messageLabels.length === 0 && 无标签} {availableLabels.length === 0 && 请先在侧栏新建标签} {availableLabels.map((label) => ( { event.preventDefault() activeIds.has(label.id) ? onRemove(label.id) : onAdd(label) }} > {label.name} ))}
) } function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const [files, setFiles] = React.useState([]) const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) }) async function submit(e: React.FormEvent) { e.preventDefault() if (!mailbox) { toast({ title: "请选择发件邮箱" }) return } const form = new FormData(e.currentTarget) const attachments = await Promise.all(files.map(fileToAttachment)) const text = String(form.get("text") || "") send.mutate({ mailboxId: mailbox.id, to: splitEmails(String(form.get("to") || "")), cc: splitEmails(String(form.get("cc") || "")), bcc: splitEmails(String(form.get("bcc") || "")), subject: String(form.get("subject") || ""), text, html: markdownToHtml(text), attachments }) } return (
写信
setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 &&
{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(",")}
}
) } type MarkdownAction = "bold" | "italic" | "strike" | "ul" | "ol" | "quote" | "code" | "link" | "image" | "hr" type MarkdownMode = "edit" | "split" | "preview" function MarkdownComposer({ defaultValue }: { defaultValue: string }) { const [value, setValue] = React.useState(defaultValue) const [mode, setMode] = React.useState("edit") const textareaRef = React.useRef(null) const previewHtml = React.useMemo(() => markdownToHtml(value), [value]) React.useEffect(() => setValue(defaultValue), [defaultValue]) function focusEditor() { window.requestAnimationFrame(() => textareaRef.current?.focus()) } function updateSelection(next: string, start: number, end: number) { setValue(next) window.requestAnimationFrame(() => { const textarea = textareaRef.current if (!textarea) return textarea.focus() textarea.setSelectionRange(start, end) }) } function wrap(prefix: string, suffix = prefix, placeholder = "文本") { const textarea = textareaRef.current if (!textarea) return const start = textarea.selectionStart const end = textarea.selectionEnd const selected = value.slice(start, end) || placeholder const next = value.slice(0, start) + prefix + selected + suffix + value.slice(end) updateSelection(next, start + prefix.length, start + prefix.length + selected.length) } function prefixLines(prefix: string, ordered = false) { const textarea = textareaRef.current if (!textarea) return const start = textarea.selectionStart const end = textarea.selectionEnd const lineStart = value.lastIndexOf("\n", start - 1) + 1 const lineEndIndex = value.indexOf("\n", end) const lineEnd = lineEndIndex === -1 ? value.length : lineEndIndex const block = value.slice(lineStart, lineEnd) || "列表项" const lines = block.split("\n") const formatted = lines.map((line, index) => `${ordered ? `${index + 1}. ` : prefix}${line || "列表项"}`).join("\n") updateSelection(value.slice(0, lineStart) + formatted + value.slice(lineEnd), lineStart, lineStart + formatted.length) } function insertMarkdown(action: MarkdownAction) { if (mode === "preview") setMode("edit") const textarea = textareaRef.current if (!textarea) { focusEditor() return } switch (action) { case "bold": wrap("**", "**", "加粗文本"); break case "italic": wrap("_", "_", "斜体文本"); break case "strike": wrap("~~", "~~", "删除线文本"); break case "ul": prefixLines("- "); break case "ol": prefixLines("", true); break case "quote": prefixLines("> "); break case "code": wrap("`", "`", "code"); break case "link": wrap("[", "](https://example.com)", "链接文本"); break case "image": wrap("![", "](https://example.com/image.png)", "图片描述"); break case "hr": { const start = textarea.selectionStart const before = value.slice(0, start) const after = value.slice(textarea.selectionEnd) const prefix = before.endsWith("\n") || before === "" ? "" : "\n" const suffix = after.startsWith("\n") || after === "" ? "" : "\n" const insert = `${prefix}---${suffix}` updateSelection(before + insert + after, before.length + insert.length, before.length + insert.length) break } } } function setBlock(type: string) { if (type === "p") return focusEditor() const mark = type === "h2" ? "## " : "### " prefixLines(mark) } return (
insertMarkdown("bold")}> insertMarkdown("italic")}> U insertMarkdown("strike")}> insertMarkdown("ul")}> insertMarkdown("ol")}> insertMarkdown("quote")}> insertMarkdown("code")}> insertMarkdown("link")}> insertMarkdown("image")}> insertMarkdown("hr")}>
Markdown
{(["edit", "split", "preview"] as MarkdownMode[]).map((item) => ( ))}
{mode !== "preview" && (