feat(mail): 增强邮件认证与审计能力
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 新增邮件认证结果解析、存储与前端展示,支持查看 SPF、DKIM、DMARC 及原始头信息。 - 扩展邮件规则条件,支持抄送、附件、大小、日期及嵌套条件组合。 - 增加邮箱容量统计与发送前配额校验,超限时返回明确错误。 - 新增管理端发送审计页面与接口,支持按邮箱、事件、Message-ID 和时间筛选。
This commit is contained in:
@@ -59,9 +59,11 @@ export type Alias = { id: string; domainId: string; source: string; destination:
|
||||
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
|
||||
export type MailMessage = {
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
labels?: MailLabel[]
|
||||
authentication?: MailAuthentication
|
||||
}
|
||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||
@@ -96,7 +98,9 @@ export type SendQueueAuditEvent = {
|
||||
id: string
|
||||
queueId?: string
|
||||
mailboxId?: string
|
||||
mailboxAddress?: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
source?: string
|
||||
status?: SendQueueStatus
|
||||
event?: string
|
||||
@@ -111,11 +115,13 @@ export type SendQueueAuditEvent = {
|
||||
}
|
||||
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
||||
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
||||
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
|
||||
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
|
||||
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
|
||||
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
|
||||
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
|
||||
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
|
||||
@@ -94,6 +94,17 @@ export const api = {
|
||||
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
||||
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.messageId) query.set("messageId", params.messageId)
|
||||
if (params.event) query.set("event", params.event)
|
||||
if (params.from) query.set("from", params.from)
|
||||
if (params.to) query.set("to", params.to)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
|
||||
mailboxes: "邮箱账号",
|
||||
aliases: "别名转发",
|
||||
messages: "全部邮件",
|
||||
sendAudit: "发送审计",
|
||||
settings: "系统设置",
|
||||
}
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
@@ -47,6 +48,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
mailboxes: ["admin.mailboxes.view"],
|
||||
aliases: ["admin.aliases.view"],
|
||||
messages: ["admin.messages.view"],
|
||||
sendAudit: ["admin.messages.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
@@ -108,6 +110,7 @@ export function AdminPage() {
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
@@ -851,6 +854,119 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [event, setEvent] = React.useState("all")
|
||||
const [messageId, setMessageId] = React.useState("")
|
||||
const [from, setFrom] = React.useState("")
|
||||
const [to, setTo] = React.useState("")
|
||||
const audit = useInfiniteQuery({
|
||||
queryKey: ["admin", "send-audit", mailboxId, event, messageId, from, to],
|
||||
queryFn: ({ pageParam }) => api.adminSendAudit({
|
||||
mailboxId: mailboxId === "all" ? "" : mailboxId,
|
||||
event: event === "all" ? "" : event,
|
||||
messageId: messageId.trim(),
|
||||
from,
|
||||
to,
|
||||
cursor: typeof pageParam === "string" ? pageParam : "",
|
||||
}),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
})
|
||||
const items = audit.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送审计</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px_160px_160px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={messageId} onChange={(event) => setMessageId(event.target.value)} placeholder="Message-ID 或已发送邮件 ID" className="pl-9" />
|
||||
</div>
|
||||
<Select value={mailboxId} onValueChange={setMailboxId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部邮箱</SelectItem>
|
||||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={event} onValueChange={setEvent}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部事件</SelectItem>
|
||||
{sendAuditEvents.map((item) => <SelectItem key={item} value={item}>{sendAuditEventLabel(item)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="开始日期" />
|
||||
<Input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="结束日期" />
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{sendAuditEventLabel(item.event || "")}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{item.mailboxAddress || item.mailboxId || "-"}</div>
|
||||
</div>
|
||||
<Badge variant={sendAuditBadgeVariant(item.event)}>{item.status || item.event || "-"}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="truncate">收件人:{(item.recipients || []).join(", ") || "-"}</div>
|
||||
<div className="truncate">Message-ID:{item.messageId || item.sentMessageId || "-"}</div>
|
||||
{item.error && <div className="line-clamp-2 text-destructive">错误:{item.error}</div>}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>事件</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>收件人</TableHead>
|
||||
<TableHead>Message-ID</TableHead>
|
||||
<TableHead>错误</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell><Badge variant={sendAuditBadgeVariant(item.event)}>{sendAuditEventLabel(item.event || "")}</Badge></TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{item.mailboxAddress || item.mailboxId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate" title={(item.recipients || []).join(", ")}>{(item.recipients || []).join(", ") || "-"}</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate" title={item.messageId || item.sentMessageId || ""}>{item.messageId || item.sentMessageId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate text-destructive" title={item.error || ""}>{item.error || "-"}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">{formatDate(item.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
{audit.isFetchingNextPage ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
@@ -1471,6 +1587,26 @@ function adminSenderTitle(message: MailMessage) {
|
||||
return name ? `${name} <${from}>` : from
|
||||
}
|
||||
|
||||
const sendAuditEvents = ["accepted", "queued", "retry", "delivered", "failed", "canceled"]
|
||||
|
||||
function sendAuditEventLabel(event: string) {
|
||||
switch (event) {
|
||||
case "accepted": return "已接受"
|
||||
case "queued": return "已入队"
|
||||
case "retry": return "重试"
|
||||
case "delivered": return "已投递"
|
||||
case "failed": return "失败"
|
||||
case "canceled": return "已取消"
|
||||
default: return event || "-"
|
||||
}
|
||||
}
|
||||
|
||||
function sendAuditBadgeVariant(event?: string) {
|
||||
if (event === "failed") return "destructive"
|
||||
if (event === "delivered" || event === "accepted") return "default"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||||
}
|
||||
|
||||
@@ -1963,6 +1963,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
<MessageMetaRow label="接收时间">
|
||||
<span>{formatDateTime(message.receivedAt)}</span>
|
||||
</MessageMetaRow>
|
||||
<AuthenticationResultRow message={message} />
|
||||
{availableLabels && onAddLabel && onRemoveLabel && (
|
||||
<MessageMetaRow label="标签">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -2024,6 +2025,41 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationResultRow({ message }: { message: MailMessage }) {
|
||||
const auth = message.authentication || { authenticationResults: "", receivedSpf: "", spf: "unknown", dkim: "unknown", dmarc: "unknown" }
|
||||
const title = [auth.authenticationResults, auth.receivedSpf].filter(Boolean).join("\n\n")
|
||||
return (
|
||||
<MessageMetaRow label="Auth">
|
||||
<div className="flex flex-wrap gap-1.5" title={title || undefined}>
|
||||
<AuthStatusBadge label="SPF" value={auth.spf} />
|
||||
<AuthStatusBadge label="DKIM" value={auth.dkim} />
|
||||
<AuthStatusBadge label="DMARC" value={auth.dmarc} />
|
||||
</div>
|
||||
</MessageMetaRow>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthStatusBadge({ label, value }: { label: string; value?: string }) {
|
||||
const status = normalizeAuthStatus(value)
|
||||
return (
|
||||
<Badge variant="outline" className={cn("rounded-md font-mono text-[11px] font-normal", authStatusClassName(status))}>
|
||||
{label}:{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAuthStatus(value?: string) {
|
||||
const status = (value || "").trim().toLowerCase()
|
||||
if (["pass", "fail", "softfail", "neutral", "temperror", "permerror", "none"].includes(status)) return status
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function authStatusClassName(status: string) {
|
||||
if (status === "pass") return "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
if (["fail", "softfail", "permerror"].includes(status)) return "border-red-300 bg-red-50 text-red-700"
|
||||
return "border-slate-300 bg-slate-50 text-slate-600"
|
||||
}
|
||||
|
||||
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
||||
|
||||
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
||||
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
||||
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
|
||||
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
|
||||
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
|
||||
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
|
||||
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
|
||||
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
|
||||
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
|
||||
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
@@ -860,7 +866,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
}, [open, labels])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
|
||||
setConditions((items) => items.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
const next = { ...item, ...patch }
|
||||
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
|
||||
next.operator = defaultConditionOperator(patch.field)
|
||||
}
|
||||
return next
|
||||
}))
|
||||
}
|
||||
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
||||
@@ -870,7 +883,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
|
||||
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
||||
|
||||
@@ -902,15 +915,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
|
||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
|
||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
|
||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return sizeConditionOperators
|
||||
if (field === "date") return dateConditionOperators
|
||||
return textConditionOperators
|
||||
}
|
||||
|
||||
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
|
||||
if (field === "size") return "gte"
|
||||
if (field === "date") return "on"
|
||||
return "contains"
|
||||
}
|
||||
|
||||
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return "例如 10mb"
|
||||
if (field === "date") return "选择日期"
|
||||
if (field === "attachment") return "输入附件名或扩展名"
|
||||
return "输入值"
|
||||
}
|
||||
|
||||
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
||||
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
|
||||
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join(";") || "无条件"
|
||||
return items.map(conditionItemSummary).join(";") || "无条件"
|
||||
}
|
||||
|
||||
function conditionItemSummary(item: MailRuleCondition): string {
|
||||
if (item.conditions?.length) {
|
||||
const mode = item.matchMode === "any" ? "任一" : "全部"
|
||||
return `${mode}(${item.conditions.map(conditionItemSummary).join(";")})`
|
||||
}
|
||||
const field = item.field || "from"
|
||||
const operator = item.operator || defaultConditionOperator(field)
|
||||
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
|
||||
}
|
||||
|
||||
function actionSummary(action: MailRuleAction) {
|
||||
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
||||
}
|
||||
|
||||
function StatsSummary({ stats }: { stats?: MailStats }) {
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
|
||||
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user