diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx
index e543ab2..c2f289e 100644
--- a/apps/web/src/pages/mail.tsx
+++ b/apps/web/src/pages/mail.tsx
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
-import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
+import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -60,7 +60,7 @@ const folderLabels: Record
= {
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
type MailFilter = "all" | "unread" | "starred" | "attachments" | "recent7"
-type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external"
+type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" | "unknown"
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailNotificationState = { latestId: string; latestReceivedAt: string }
@@ -75,6 +75,7 @@ type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
+ | { type: "unknown"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
| { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number }
const filterLabels: Record = {
@@ -137,6 +138,8 @@ export function MailPage() {
const compactMailLayout = isMobile || isNarrowMailViewport || displayMode === "compact"
const [refreshing, setRefreshing] = React.useState(false)
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
+ const [exportingMail, setExportingMail] = React.useState(false)
+ const [importingMail, setImportingMail] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState(null)
@@ -159,6 +162,7 @@ export function MailPage() {
const themeMountedRef = React.useRef(false)
const mailNotifyStateRef = React.useRef>({})
const mailAudioContextRef = React.useRef(null)
+ const mailImportInputRef = React.useRef(null)
const user = me.data?.user
const canAccessMail = hasPermission(user, "mail.access")
const canReadMail = hasPermission(user, "mail.messages.read")
@@ -169,6 +173,7 @@ export function MailPage() {
const canManageLabels = hasPermission(user, "mail.labels.manage")
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
+ const canViewUnknownMail = user?.role === "admin"
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
@@ -234,7 +239,18 @@ export function MailPage() {
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
- enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
+ enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "unknown" && (mailView !== "label" || !!selectedLabelId),
+ })
+ const unknownMessages = useInfiniteQuery({
+ queryKey: ["admin", "unknown-messages", query],
+ queryFn: ({ pageParam }) => api.adminMessages({
+ mailboxId: "unregistered",
+ q: query.trim(),
+ cursor: typeof pageParam === "string" ? pageParam : "",
+ }),
+ initialPageParam: "",
+ getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
+ enabled: canViewUnknownMail && mailView === "unknown",
})
const externalMessages = useInfiniteQuery({
queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query],
@@ -243,7 +259,11 @@ export function MailPage() {
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled,
})
- const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) })
+ const detail = useQuery({
+ queryKey: ["message", selectedId, mailView, selectedExternalAccountId],
+ queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : mailView === "unknown" ? api.adminMessage(selectedId!) : api.message(selectedId!, { markRead: false }),
+ enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) && (mailView !== "unknown" || canViewUnknownMail),
+ })
function updateCachedMessage(id: string, patch: Partial) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => {
@@ -567,6 +587,8 @@ export function MailPage() {
React.useEffect(() => {
const events = new EventSource("/api/events", { withCredentials: true })
events.addEventListener("sync", () => {
+ qc.invalidateQueries({ queryKey: ["messages"] })
+ qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] })
qc.invalidateQueries({ queryKey: ["folders"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] })
@@ -581,6 +603,9 @@ export function MailPage() {
setAutoRefreshing(true)
Promise.all([
qc.invalidateQueries({ queryKey: ["messages"] }),
+ qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }),
+ qc.invalidateQueries({ queryKey: ["external-messages"] }),
+ qc.invalidateQueries({ queryKey: ["mail-external-folders"] }),
qc.invalidateQueries({ queryKey: ["folders"] }),
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
qc.invalidateQueries({ queryKey: ["labels"] }),
@@ -596,7 +621,7 @@ export function MailPage() {
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
const selected = detail.data
- const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || []
+ const allMessages = (mailView === "external" ? externalMessages.data?.pages : mailView === "unknown" ? unknownMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || []
const visibleMessages = allMessages.filter((message) => {
if (!messageMatchesAdvancedSearch(message, advancedSearch)) return false
if (mailFilter === "unread") return !message.isRead
@@ -621,7 +646,7 @@ export function MailPage() {
const sendQueueItems = sendQueue.data?.items || []
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
const visibleSendQueueItems = sendQueueItems
- const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
+ const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue, canViewUnknownMail)
const primaryMailMenuItems = mailMenuItems.filter((item) => !isCustomMenuFolder(item))
const customMailMenuItems = mailMenuItems.filter(isCustomMenuFolder)
const canOrganizeCurrentMailbox = canOrganizeMail && !isAllMailboxSelected
@@ -630,7 +655,10 @@ export function MailPage() {
const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : []
const labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
- const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
+ const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "unknown" ? "未知收件" : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
+ const isTransferView = mailView === "folder" || mailView === "starred" || mailView === "label" || mailView === "unknown"
+ const canExportCurrentView = canReadMail && isTransferView
+ const canImportCurrentView = canOrganizeMail && isTransferView && mailView !== "unknown" && !!selectedMailbox
const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length)
const visibleMessageIds = visibleMessages.map((message) => message.id)
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
@@ -638,8 +666,11 @@ export function MailPage() {
const bulkReadAction: BulkAction = selectedMessagesOnPage.some((message) => !message.isRead) ? "read" : "unread"
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
- const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage
- const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage
+ const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
+ const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
+ const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
+ const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
+ const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
function toggleCompactSelectAll(checked: boolean) {
setCompactSelectedIds(checked ? visibleMessageIds : [])
}
@@ -649,6 +680,7 @@ export function MailPage() {
async function refreshMailData() {
await Promise.all([
qc.invalidateQueries({ queryKey: ["messages"] }),
+ qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }),
qc.invalidateQueries({ queryKey: ["external-messages"] }),
qc.invalidateQueries({ queryKey: ["mail-external-folders"] }),
qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }),
@@ -806,8 +838,17 @@ export function MailPage() {
setMailFilter("all")
setMobileSidebarOpen(false)
}
+ function openUnknownMail() {
+ if (!canViewUnknownMail) return
+ setSelectedExternalAccountId("")
+ setMailView("unknown")
+ setSelectedLabelId("")
+ setSelectedId(null)
+ setMailFilter("all")
+ setMobileSidebarOpen(false)
+ }
function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) {
- if (mailView === "external") return
+ if (mailView === "external" || mailView === "unknown") return
event.preventDefault()
event.stopPropagation()
if (message.folder !== "Drafts") setSelectedId(message.id)
@@ -828,6 +869,7 @@ export function MailPage() {
if (item.type === "starred") openStarred()
else if (item.type === "scheduled") openScheduled()
else if (item.type === "sendQueue") openSendQueue()
+ else if (item.type === "unknown") openUnknownMail()
else openFolder(item.folderName)
}
function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") {
@@ -1011,20 +1053,76 @@ export function MailPage() {
return
}
setSelectedId(messageId)
- if (message && !message.isRead && canOrganizeMail) {
+ if (message && !message.isRead && canOrganizeMail && mailView !== "unknown") {
if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true })
else markRead.mutate({ id: message.id, read: true })
}
}
async function refreshMail() {
+ if (refreshing || autoRefreshing) return
setRefreshing(true)
try {
await refreshMailData()
- setLastAutoRefreshAt(new Date())
+ const refreshedAt = new Date()
+ setLastAutoRefreshAt(refreshedAt)
+ toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
+ } catch (error) {
+ toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
setRefreshing(false)
}
}
+ async function exportCurrentMail() {
+ if (!canExportCurrentView || exportingMail) return
+ setExportingMail(true)
+ try {
+ const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
+ const blob = await api.exportMail({
+ view: exportView,
+ mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
+ folder: exportView === "folder" ? folder : undefined,
+ labelId: exportView === "label" ? selectedLabelId : undefined,
+ })
+ const href = URL.createObjectURL(blob)
+ const anchor = document.createElement("a")
+ anchor.href = href
+ anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ window.setTimeout(() => URL.revokeObjectURL(href), 1000)
+ toast({ title: "邮件已导出", description: `${viewTitle} 已打包为 ZIP` })
+ } catch (error) {
+ toast({ title: "导出失败", description: error instanceof Error ? error.message : "请稍后重试" })
+ } finally {
+ setExportingMail(false)
+ }
+ }
+ function chooseMailImport() {
+ if (!canImportCurrentView || importingMail) {
+ if (isAllMailboxSelected) toast({ title: "请先选择一个邮箱", description: "导入邮件需要明确目标邮箱。" })
+ return
+ }
+ mailImportInputRef.current?.click()
+ }
+ async function importSelectedMailFiles(event: React.ChangeEvent) {
+ const files = Array.from(event.target.files || [])
+ event.target.value = ""
+ if (files.length === 0 || !selectedMailbox) return
+ setImportingMail(true)
+ try {
+ const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" })
+ await refreshMailData()
+ toast({
+ title: `已导入 ${result.imported} 封邮件`,
+ description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
+ })
+ } catch (error) {
+ toast({ title: "导入失败", description: error instanceof Error ? error.message : "请检查 EML/MBOX 文件" })
+ } finally {
+ setImportingMail(false)
+ }
+ }
async function copyCurrentMailbox() {
if (!selectedMailbox?.address) return
await navigator.clipboard.writeText(selectedMailbox.address)
@@ -1139,7 +1237,7 @@ export function MailPage() {
onContextMenu={(event) => openSidebarContextMenu(event, item)}
>
)
+ const mailTransferTools = isTransferView ? (
+
+
+ {mailView !== "unknown" && (
+
+ )}
+
+
+ ) : null
+
const contentView = !canAccessMail ? (
) : !canReadMail ? (
- ) : !mailboxList.isLoading && !hasMailboxes ? (
+ ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
) : mailView === "scheduled" && canScheduleMail ? (
mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}
+ loadingMore={mailMessagesLoadingMore}
+ onLoadMore={loadMoreMessages}
emptyMessage={emptyMessage}
selectedId={selectedId}
selected={selected}
@@ -1429,10 +1543,11 @@ export function MailPage() {
onBulkAction={runBulkAction}
onContextMenu={openMessageContextMenu}
canSend={canSendMail}
- canOrganize={canOrganizeMail && mailView !== "external"}
- canManageLabels={canManageLabels && mailView !== "external"}
+ canOrganize={canOrganizeMail && mailView !== "external" && mailView !== "unknown"}
+ canManageLabels={canManageLabels && mailView !== "external" && mailView !== "unknown"}
canDownloadAttachments={canDownloadAttachments}
language={language}
+ tools={!isMobile ? mailTransferTools : undefined}
/>
) : (
@@ -1488,12 +1603,8 @@ export function MailPage() {
-
{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}
-
-
-
+
{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}
+ {mailTransferTools}