2026-06-14 01:07:48 +08:00
|
|
|
|
import * as React from "react"
|
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
|
|
|
|
import { useNavigate, useSearchParams } from "react-router-dom"
|
2026-08-03 19:51:27 +08:00
|
|
|
|
import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
|
2026-06-15 00:37:43 +08:00
|
|
|
|
import { QRCodeSVG } from "qrcode.react"
|
2026-08-02 09:08:20 +08:00
|
|
|
|
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { cn, formatBytes } from "@/lib/utils"
|
|
|
|
|
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
2026-06-15 21:50:44 +08:00
|
|
|
|
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { useMe } from "@/hooks/use-me"
|
2026-06-16 10:10:21 +08:00
|
|
|
|
import { useLogout } from "@/hooks/use-logout"
|
2026-06-20 02:13:29 +08:00
|
|
|
|
import { useIsMobile } from "@/hooks/use-mobile"
|
2026-06-16 10:10:21 +08:00
|
|
|
|
import { validatePasswordConfirm } from "@/lib/validation"
|
2026-06-22 15:54:15 +08:00
|
|
|
|
import { hasPermission } from "@/lib/permissions"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { Button } from "@/components/ui/button"
|
2026-06-16 10:39:49 +08:00
|
|
|
|
import { PasswordInput } from "@/components/ui/password-input"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
2026-06-16 23:15:35 +08:00
|
|
|
|
import { Textarea } from "@/components/ui/textarea"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
|
|
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
2026-06-15 21:50:44 +08:00
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
|
|
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
2026-06-20 02:13:29 +08:00
|
|
|
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
|
|
|
|
import { Separator } from "@/components/ui/separator"
|
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
2026-06-16 15:40:38 +08:00
|
|
|
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
2026-06-14 01:07:48 +08:00
|
|
|
|
import { useToast } from "@/hooks/use-toast"
|
|
|
|
|
|
|
2026-08-03 19:51:27 +08:00
|
|
|
|
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
2026-08-02 05:55:53 +08:00
|
|
|
|
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
2026-06-16 15:40:38 +08:00
|
|
|
|
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
2026-08-02 05:55:53 +08:00
|
|
|
|
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
2026-06-14 01:07:48 +08:00
|
|
|
|
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
|
|
|
|
|
contacts: { label: "联系人管理", icon: <Contact className="h-4 w-4" /> },
|
|
|
|
|
|
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
|
2026-08-02 05:55:53 +08:00
|
|
|
|
cleanupQueue: { label: "待清理邮件", icon: <Clock3 className="h-4 w-4" /> },
|
|
|
|
|
|
rules: { label: "收信规则", icon: <SlidersHorizontal className="h-4 w-4" /> },
|
2026-06-14 01:07:48 +08:00
|
|
|
|
blocked: { label: "被拦截邮件", icon: <Ban className="h-4 w-4" /> },
|
|
|
|
|
|
stats: { label: "数据统计", icon: <BarChart3 className="h-4 w-4" /> },
|
2026-08-02 05:55:53 +08:00
|
|
|
|
apiTokens: { label: "开发者", icon: <Code2 className="h-4 w-4" /> },
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
const tabKeys = Object.keys(tabs) as Tab[]
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
|
|
|
|
|
|
{ key: "account", label: "账号" },
|
|
|
|
|
|
{ key: "mail", label: "邮件" },
|
|
|
|
|
|
{ key: "clients", label: "通知与客户端" },
|
|
|
|
|
|
{ key: "security", label: "安全" },
|
|
|
|
|
|
]
|
2026-06-14 01:07:48 +08:00
|
|
|
|
export function ProfilePage() {
|
|
|
|
|
|
const me = useMe()
|
|
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const [params, setParams] = useSearchParams()
|
|
|
|
|
|
const { toast } = useToast()
|
|
|
|
|
|
const passwordFormRef = React.useRef<HTMLFormElement>(null)
|
2026-06-15 00:37:43 +08:00
|
|
|
|
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
2026-08-03 00:16:03 +08:00
|
|
|
|
const [statsRangeDays, setStatsRangeDays] = React.useState(30)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
2026-06-15 21:50:44 +08:00
|
|
|
|
const [displayMode, setDisplayMode] = useDisplayMode()
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
|
2026-06-15 21:50:44 +08:00
|
|
|
|
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
|
2026-06-20 02:13:29 +08:00
|
|
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
2026-06-25 20:20:37 +08:00
|
|
|
|
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
|
2026-06-20 02:13:29 +08:00
|
|
|
|
const isMobile = useIsMobile()
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const themeMountedRef = React.useRef(false)
|
|
|
|
|
|
|
|
|
|
|
|
const rawTab = params.get("tab") as Tab | null
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const rawAccountTab = params.get("accountTab") as AccountSettingsTab | null
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const user = me.data?.user
|
2026-06-22 15:54:15 +08:00
|
|
|
|
const canAccessMail = hasPermission(user, "mail.access")
|
|
|
|
|
|
const canReadMail = hasPermission(user, "mail.messages.read")
|
|
|
|
|
|
const canOrganizeMail = hasPermission(user, "mail.messages.organize")
|
|
|
|
|
|
const canManageLabels = hasPermission(user, "mail.labels.manage")
|
|
|
|
|
|
const canManageContacts = hasPermission(user, "mail.contacts.manage")
|
|
|
|
|
|
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
|
|
|
|
|
const canManageRules = hasPermission(user, "mail.rules.manage")
|
|
|
|
|
|
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
|
|
|
|
|
const canViewStats = hasPermission(user, "mail.stats.view")
|
|
|
|
|
|
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
2026-08-04 00:03:11 +08:00
|
|
|
|
const canConfigureMailboxApply = hasPermission(user, "admin.settings.update")
|
2026-06-22 15:54:15 +08:00
|
|
|
|
const visibleTabKeys = tabKeys.filter((key) => {
|
|
|
|
|
|
if (key === "profile") return true
|
|
|
|
|
|
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
|
|
|
|
|
if (key === "contacts") return canManageContacts
|
|
|
|
|
|
if (key === "cleanup") return canOrganizeMail
|
2026-08-02 05:55:53 +08:00
|
|
|
|
if (key === "cleanupQueue") return canOrganizeMail
|
2026-06-22 15:54:15 +08:00
|
|
|
|
if (key === "rules") return canManageRules
|
|
|
|
|
|
if (key === "blocked") return canManageBlocked
|
|
|
|
|
|
if (key === "stats") return canViewStats
|
2026-08-02 05:55:53 +08:00
|
|
|
|
if (key === "apiTokens") return true
|
2026-06-22 15:54:15 +08:00
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account"
|
2026-06-22 15:54:15 +08:00
|
|
|
|
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
|
|
|
|
|
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
|
2026-06-16 23:15:35 +08:00
|
|
|
|
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
2026-06-29 15:31:20 +08:00
|
|
|
|
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens })
|
2026-06-22 15:54:15 +08:00
|
|
|
|
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
|
|
|
|
|
|
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
|
|
|
|
|
|
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
|
2026-08-03 16:16:20 +08:00
|
|
|
|
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail })
|
|
|
|
|
|
const ruleVerifiedEmails = React.useMemo(() => ruleForwarding.data?.verifiedEmails.filter((item) => item.verified).map((item) => item.email) || [], [ruleForwarding.data?.verifiedEmails])
|
2026-06-22 15:54:15 +08:00
|
|
|
|
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
2026-06-16 00:51:41 +08:00
|
|
|
|
const activeMailboxId = selectedMailbox?.id || ""
|
2026-06-25 22:44:32 +08:00
|
|
|
|
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
|
|
|
|
|
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
|
2026-06-25 20:20:37 +08:00
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!externalRunAccountId) return
|
|
|
|
|
|
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
|
|
|
|
|
setExternalRunAccountId("")
|
|
|
|
|
|
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
|
|
|
|
|
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
2026-06-25 22:44:32 +08:00
|
|
|
|
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
|
|
|
|
|
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) })
|
2026-08-03 00:16:03 +08:00
|
|
|
|
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats })
|
2026-06-14 01:07:48 +08:00
|
|
|
|
|
|
|
|
|
|
const profile = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
|
|
|
|
|
onSuccess: (data) => { qc.setQueryData(["me"], data); toast({ title: "个人资料已保存" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const password = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => {
|
|
|
|
|
|
const newPassword = String(form.get("newPassword") || "")
|
2026-06-16 10:10:21 +08:00
|
|
|
|
validatePasswordConfirm(newPassword, String(form.get("confirmPassword") || ""), "两次输入的新密码不一致")
|
2026-06-14 01:07:48 +08:00
|
|
|
|
return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword })
|
|
|
|
|
|
},
|
|
|
|
|
|
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "修改失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-15 00:37:43 +08:00
|
|
|
|
const setupTwoFactor = useMutation({
|
|
|
|
|
|
mutationFn: api.setupTwoFactor,
|
|
|
|
|
|
onSuccess: () => toast({ title: "双因素密钥已生成" }),
|
|
|
|
|
|
onError: (error) => toast({ title: "生成失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const enableTwoFactor = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")),
|
|
|
|
|
|
onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "启用失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const disableTwoFactor = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")),
|
|
|
|
|
|
onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "关闭失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-29 15:31:20 +08:00
|
|
|
|
const createApiToken = useMutation({
|
2026-07-10 10:47:58 +08:00
|
|
|
|
mutationFn: (payload: { name: string; expiresAt?: string; scopes: string[] }) => api.createApiToken(payload),
|
2026-08-02 23:19:01 +08:00
|
|
|
|
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API 密钥已创建" }); return res },
|
2026-06-29 15:31:20 +08:00
|
|
|
|
onError: (error) => toast({ title: "创建失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const updateApiToken = useMutation({
|
2026-07-10 10:47:58 +08:00
|
|
|
|
mutationFn: ({ id, payload }: { id: string; payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] } }) => api.updateApiToken(id, payload),
|
2026-08-02 23:19:01 +08:00
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API 密钥已更新" }) },
|
2026-06-29 15:31:20 +08:00
|
|
|
|
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteApiToken = useMutation({
|
|
|
|
|
|
mutationFn: api.deleteApiToken,
|
2026-08-02 23:19:01 +08:00
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API 密钥已撤销" }) },
|
2026-06-29 15:31:20 +08:00
|
|
|
|
onError: (error) => toast({ title: "撤销失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const createContact = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
2026-06-16 23:15:35 +08:00
|
|
|
|
const createSignature = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const updateSignature = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, form }: { id: string; form: FormData }) => api.updateSignature(id, { mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已更新" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const setDefaultSignature = useMutation({
|
|
|
|
|
|
mutationFn: api.setDefaultSignature,
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } })
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const createRule = useMutation({
|
2026-06-15 21:50:44 +08:00
|
|
|
|
mutationFn: (payload: {
|
|
|
|
|
|
mailboxId: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
matchMode: "all" | "any"
|
|
|
|
|
|
conditions: MailRuleCondition[]
|
|
|
|
|
|
actions: MailRuleAction[]
|
|
|
|
|
|
applyToExisting: boolean
|
|
|
|
|
|
stopProcessing: boolean
|
|
|
|
|
|
enabled: boolean
|
|
|
|
|
|
}) => api.createRule(payload),
|
|
|
|
|
|
onSuccess: (rule) => {
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["rules"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["messages"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["labels"] })
|
|
|
|
|
|
setRuleDialogOpen(false)
|
|
|
|
|
|
toast({ title: rule.appliedExistingCount ? `收件规则已保存,已应用 ${rule.appliedExistingCount} 封邮件` : "收件规则已保存" })
|
|
|
|
|
|
},
|
2026-06-14 01:07:48 +08:00
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
2026-08-03 16:16:20 +08:00
|
|
|
|
const updateRule = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const moveRule = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) => api.moveRule(id, direction),
|
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
|
|
|
|
|
onError: (error) => toast({ title: "排序失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const applyRule = useMutation({
|
|
|
|
|
|
mutationFn: api.applyRule,
|
|
|
|
|
|
onSuccess: (res) => {
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["messages"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["folders"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["labels"] })
|
|
|
|
|
|
toast({ title: `规则已应用到 ${res.affected} 封现有邮件` })
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "应用失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const createBlocked = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.createBlockedSender({ mailboxId: blockedMailboxId === "all" ? "" : blockedMailboxId, email: String(form.get("email") || ""), reason: String(form.get("reason") || "") }),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const createLabel = useMutation({
|
|
|
|
|
|
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "创建失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteLabel = useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => api.deleteLabel(id, activeMailboxId),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已删除" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const cleanup = useMutation({
|
|
|
|
|
|
mutationFn: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => api.cleanupMail({ mailboxId, target }),
|
|
|
|
|
|
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "清理失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-16 00:51:41 +08:00
|
|
|
|
const applyMailbox = useMutation({
|
|
|
|
|
|
mutationFn: api.applyMailbox,
|
|
|
|
|
|
onSuccess: (mailbox) => {
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["mailboxes", "mine"] })
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["mailbox-apply-options"] })
|
|
|
|
|
|
setMailboxId(mailbox.id)
|
|
|
|
|
|
toast({ title: "邮箱已申请" })
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "申请失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-25 17:09:46 +08:00
|
|
|
|
const createExternalImap = useMutation({
|
|
|
|
|
|
mutationFn: api.createExternalImapAccount,
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已保存" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const updateExternalImap = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, payload }: { id: string; payload: ExternalImapAccountPayload }) => api.updateExternalImapAccount(id, payload),
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已更新" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const deleteExternalImap = useMutation({
|
|
|
|
|
|
mutationFn: api.deleteExternalImapAccount,
|
|
|
|
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已删除" }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const testExternalImap = useMutation({
|
|
|
|
|
|
mutationFn: api.testExternalImapAccount,
|
|
|
|
|
|
onSuccess: (res) => toast({ title: `连接成功,发现 ${res.folders} 个文件夹` }),
|
|
|
|
|
|
onError: (error) => toast({ title: "连接失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const syncExternalImap = useMutation({
|
|
|
|
|
|
mutationFn: api.syncExternalImapAccount,
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
|
|
|
|
|
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const syncExternalImapFolder = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, folder }: { id: string; folder: string }) => api.syncExternalImapFolder(id, folder),
|
|
|
|
|
|
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `${run.folder || "文件夹"} 同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-25 20:20:37 +08:00
|
|
|
|
const startExternalOAuth = useMutation({
|
2026-06-25 21:17:04 +08:00
|
|
|
|
mutationFn: ({ provider, mailboxId, email, storageMode }: { provider: ExternalImapOAuthProvider; mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => api.startExternalImapOAuth(provider, { mailboxId, email, storageMode, syncReadState: true, enabled: true }),
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onSuccess: (res) => { window.location.href = res.url },
|
|
|
|
|
|
onError: (error) => toast({ title: "授权失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-06-14 01:07:48 +08:00
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-06-16 00:51:41 +08:00
|
|
|
|
if (!mailboxes.isSuccess) return
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const items = mailboxes.data?.items || []
|
2026-06-16 00:51:41 +08:00
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
|
if (mailboxId) setMailboxId("")
|
|
|
|
|
|
localStorage.removeItem("lanqin:selected-mailbox")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!mailboxId || !items.some((m) => m.id === mailboxId)) setMailboxId(items[0].id)
|
|
|
|
|
|
}, [mailboxId, mailboxes.isSuccess, mailboxes.data?.items])
|
|
|
|
|
|
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId); else localStorage.removeItem("lanqin:selected-mailbox") }, [mailboxId])
|
2026-06-14 01:07:48 +08:00
|
|
|
|
React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode])
|
|
|
|
|
|
|
2026-06-16 10:10:21 +08:00
|
|
|
|
const logout = useLogout()
|
2026-06-14 01:07:48 +08:00
|
|
|
|
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
|
2026-06-22 15:54:15 +08:00
|
|
|
|
function setTab(next: Tab) {
|
|
|
|
|
|
const visibleNext = visibleTabKeys.includes(next) ? next : "profile"
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const nextParams = new URLSearchParams(params)
|
|
|
|
|
|
if (visibleNext === "profile") nextParams.delete("tab")
|
|
|
|
|
|
else {
|
|
|
|
|
|
nextParams.set("tab", visibleNext)
|
|
|
|
|
|
nextParams.delete("accountTab")
|
|
|
|
|
|
}
|
|
|
|
|
|
setParams(nextParams)
|
2026-06-22 15:54:15 +08:00
|
|
|
|
setMobileSidebarOpen(false)
|
|
|
|
|
|
}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
function setAccountTab(next: AccountSettingsTab) {
|
|
|
|
|
|
const nextParams = new URLSearchParams(params)
|
|
|
|
|
|
nextParams.delete("tab")
|
|
|
|
|
|
if (next === "account") nextParams.delete("accountTab")
|
|
|
|
|
|
else nextParams.set("accountTab", next)
|
|
|
|
|
|
setParams(nextParams)
|
|
|
|
|
|
}
|
2026-06-15 17:37:40 +08:00
|
|
|
|
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">加载中...</div>
|
|
|
|
|
|
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
2026-06-14 01:07:48 +08:00
|
|
|
|
|
2026-06-20 02:13:29 +08:00
|
|
|
|
const sidebarContent = (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="h-[64px] border-b">
|
2026-08-03 17:27:29 +08:00
|
|
|
|
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.loginName || user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<nav className="min-h-0 flex-1 overflow-y-auto p-2">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground">管理</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{visibleTabKeys.map((key) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={cn(
|
2026-08-03 16:16:20 +08:00
|
|
|
|
"flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
|
2026-08-02 23:19:01 +08:00
|
|
|
|
tab === key ? "bg-muted font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
2026-08-02 05:55:53 +08:00
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => setTab(key)}
|
|
|
|
|
|
>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-foreground/70")}>{tabs[key].icon}</span>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<span className="truncate">{tabs[key].label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
<div className="border-t p-2">
|
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-9 w-full justify-start gap-2 px-3 text-destructive hover:text-destructive" onClick={logout}>
|
2026-06-20 02:13:29 +08:00
|
|
|
|
<LogOut className="h-4 w-4" />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<span>退出登录</span>
|
2026-06-20 02:13:29 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
</div>
|
2026-06-20 02:13:29 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-03 19:51:27 +08:00
|
|
|
|
const pageTitle = tabs[tab].label
|
2026-08-03 16:16:20 +08:00
|
|
|
|
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
|
|
|
|
|
|
const pageAction = tab === "stats"
|
|
|
|
|
|
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
|
|
|
|
|
: tab === "apiTokens"
|
|
|
|
|
|
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API 文档</a></Button>
|
|
|
|
|
|
: undefined
|
2026-08-02 23:19:01 +08:00
|
|
|
|
|
2026-06-14 01:07:48 +08:00
|
|
|
|
return (
|
2026-06-21 00:02:30 +08:00
|
|
|
|
<div className="h-svh overflow-hidden bg-background">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{isMobile ? (
|
|
|
|
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
|
|
|
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
|
|
|
|
|
|
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
|
|
|
|
|
<SheetTrigger asChild>
|
|
|
|
|
|
<Button size="icon" variant="ghost" aria-label="打开导航"><PanelLeftOpen className="h-4 w-4" /></Button>
|
|
|
|
|
|
</SheetTrigger>
|
|
|
|
|
|
<SheetContent side="left" className="w-[85vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
|
|
|
|
|
|
<SheetTitle className="sr-only">管理导航</SheetTitle>
|
|
|
|
|
|
<div className="h-svh">{sidebarContent}</div>
|
|
|
|
|
|
</SheetContent>
|
|
|
|
|
|
</Sheet>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="min-w-0 flex-1 text-sm font-semibold">{pageTitle}</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/")} aria-label="返回邮箱"><ArrowLeft className="h-4 w-4" /></Button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<main className="w-full pb-10">
|
|
|
|
|
|
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
|
|
|
|
|
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex h-full min-h-0 w-full">
|
|
|
|
|
|
{sidebarContent}
|
|
|
|
|
|
<section className="min-w-0 flex-1 overflow-y-auto">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<main className="pb-12">
|
|
|
|
|
|
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
|
|
|
|
|
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-14 01:07:48 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
function renderTab() {
|
2026-08-02 05:55:53 +08:00
|
|
|
|
if (tab === "profile") return (
|
|
|
|
|
|
<AccountSettingsSection
|
|
|
|
|
|
activeTab={accountTab}
|
|
|
|
|
|
user={user!}
|
|
|
|
|
|
profile={profile}
|
|
|
|
|
|
password={password}
|
|
|
|
|
|
passwordFormRef={passwordFormRef}
|
|
|
|
|
|
stats={canViewStats ? stats.data : undefined}
|
|
|
|
|
|
showStats={canViewStats}
|
|
|
|
|
|
displayMode={displayMode}
|
|
|
|
|
|
onDisplayModeChange={setDisplayMode}
|
|
|
|
|
|
twoFactorFormRef={twoFactorFormRef}
|
|
|
|
|
|
setupTwoFactor={setupTwoFactor}
|
|
|
|
|
|
enableTwoFactor={enableTwoFactor}
|
|
|
|
|
|
disableTwoFactor={disableTwoFactor}
|
|
|
|
|
|
mailboxes={mailboxes.data?.items || []}
|
|
|
|
|
|
selectedMailboxId={mailboxId}
|
|
|
|
|
|
selectedMailbox={selectedMailbox}
|
|
|
|
|
|
labels={labels.data?.items || []}
|
|
|
|
|
|
labelsLoading={labels.isLoading}
|
|
|
|
|
|
labelsPending={createLabel.isPending || deleteLabel.isPending}
|
|
|
|
|
|
onCreateLabel={(form) => createLabel.mutate(form)}
|
|
|
|
|
|
onDeleteLabel={(id) => deleteLabel.mutate(id)}
|
|
|
|
|
|
signatures={signatures.data?.items || []}
|
|
|
|
|
|
signaturesLoading={signatures.isLoading}
|
|
|
|
|
|
signaturesPending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending}
|
|
|
|
|
|
onCreateSignature={(form) => createSignature.mutate(form)}
|
|
|
|
|
|
onUpdateSignature={(id, form) => updateSignature.mutate({ id, form })}
|
|
|
|
|
|
onSetDefaultSignature={(id) => setDefaultSignature.mutate(id)}
|
|
|
|
|
|
onDeleteSignature={(id) => deleteSignature.mutate(id)}
|
|
|
|
|
|
clientHostname={publicSettings.data?.publicHostname}
|
2026-08-03 19:51:27 +08:00
|
|
|
|
onCopy={copy}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
onSelectMailbox={setMailboxId}
|
|
|
|
|
|
onOpenCleanup={() => setTab("cleanup")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
2026-06-25 20:20:37 +08:00
|
|
|
|
if (tab === "mailboxes") return (
|
|
|
|
|
|
<MailboxManagement
|
|
|
|
|
|
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
|
|
|
|
|
applyOptions={mailboxApplyOptions.data}
|
|
|
|
|
|
applyPending={applyMailbox.isPending}
|
2026-08-04 00:03:11 +08:00
|
|
|
|
canConfigureApply={canConfigureMailboxApply}
|
2026-06-25 20:20:37 +08:00
|
|
|
|
selectedMailboxId={mailboxId}
|
2026-06-25 22:44:32 +08:00
|
|
|
|
externalImapEnabled={externalImapEnabled}
|
2026-06-25 20:20:37 +08:00
|
|
|
|
externalAccounts={externalImapAccounts.data?.items || []}
|
|
|
|
|
|
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
|
|
|
|
|
|
selectedExternalRunAccountId={externalRunAccountId}
|
|
|
|
|
|
externalRunFolders={externalRunFolders.data?.items || []}
|
|
|
|
|
|
externalSyncRuns={externalSyncRuns.data?.items || []}
|
|
|
|
|
|
onSelectExternalRunAccount={setExternalRunAccountId}
|
|
|
|
|
|
onSelect={setMailboxId}
|
|
|
|
|
|
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
|
|
|
|
|
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
2026-08-04 00:03:11 +08:00
|
|
|
|
onConfigureApply={() => navigate("/admin?section=settings&settingsTab=mail")}
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
2026-06-25 21:17:04 +08:00
|
|
|
|
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
|
|
|
|
|
onDeleteExternal={(id) => deleteExternalImap.mutate(id)}
|
|
|
|
|
|
onTestExternal={(id) => testExternalImap.mutate(id)}
|
|
|
|
|
|
onSyncExternal={(id) => syncExternalImap.mutate(id)}
|
|
|
|
|
|
onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
2026-06-22 15:54:15 +08:00
|
|
|
|
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
|
2026-08-03 16:16:20 +08:00
|
|
|
|
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} verifiedEmails={ruleVerifiedEmails} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} />
|
2026-06-22 15:54:15 +08:00
|
|
|
|
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
2026-08-03 00:16:03 +08:00
|
|
|
|
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function contentFrameClass(tab: Tab) {
|
|
|
|
|
|
return cn(
|
|
|
|
|
|
"w-full",
|
|
|
|
|
|
tab === "mailboxes" ? "pt-[34px]" : "pt-6",
|
|
|
|
|
|
tab === "profile" || tab === "mailboxes" ? "mx-auto max-w-[896px]" :
|
|
|
|
|
|
tab === "stats" ? "px-4 sm:px-6" :
|
|
|
|
|
|
tab === "rules" || tab === "apiTokens" ? "mx-auto max-w-[896px] px-4 sm:px-0" :
|
|
|
|
|
|
"mx-auto max-w-[1024px] px-4 sm:px-0",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabChange }: { title: string; subtitle?: string; action?: React.ReactNode; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) {
|
2026-08-02 05:55:53 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="border-b px-4 py-4 sm:px-6">
|
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<h1 className="text-[20px] font-semibold leading-7">{title}</h1>
|
|
|
|
|
|
{subtitle && <p className="text-sm leading-5 text-muted-foreground">{subtitle}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{action && <div className="shrink-0">{action}</div>}
|
|
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{activeTab && (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="mt-3 flex overflow-x-auto border-b">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{accountSettingTabs.map((item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={item.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-[38px] shrink-0 border-b-2 px-4 text-sm font-medium transition-colors",
|
|
|
|
|
|
activeTab === item.key ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => onAccountTabChange(item.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function StatsRangeTabs({ rangeDays, onRangeChange }: { rangeDays: number; onRangeChange: (days: number) => void }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid grid-cols-4 gap-1 sm:flex">
|
|
|
|
|
|
{[
|
|
|
|
|
|
[7, "7天"],
|
|
|
|
|
|
[30, "30天"],
|
|
|
|
|
|
[90, "90天"],
|
|
|
|
|
|
[365, "365天"],
|
|
|
|
|
|
].map(([value, label]) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={cn("h-[30px] rounded-md border px-3 text-xs font-normal transition-colors", rangeDays === value ? "border-primary bg-primary text-primary-foreground" : "bg-background text-foreground hover:bg-muted")}
|
|
|
|
|
|
onClick={() => onRangeChange(Number(value))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 05:55:53 +08:00
|
|
|
|
type AccountSettingsSectionProps = {
|
|
|
|
|
|
activeTab: AccountSettingsTab
|
2026-08-03 17:27:29 +08:00
|
|
|
|
user: { id: string; loginName?: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }
|
2026-08-02 05:55:53 +08:00
|
|
|
|
profile: { mutate: (form: FormData) => void; isPending: boolean }
|
|
|
|
|
|
password: { mutate: (form: FormData) => void; isPending: boolean }
|
|
|
|
|
|
passwordFormRef: React.RefObject<HTMLFormElement>
|
|
|
|
|
|
stats?: MailStats
|
|
|
|
|
|
showStats: boolean
|
|
|
|
|
|
displayMode: DisplayMode
|
|
|
|
|
|
onDisplayModeChange: (mode: DisplayMode) => void
|
|
|
|
|
|
twoFactorFormRef: React.RefObject<HTMLFormElement>
|
|
|
|
|
|
setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }
|
|
|
|
|
|
enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
|
|
|
|
|
|
disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }
|
|
|
|
|
|
onCopy: (text: string) => void
|
|
|
|
|
|
mailboxes: Mailbox[]
|
|
|
|
|
|
selectedMailboxId: string
|
|
|
|
|
|
selectedMailbox?: Mailbox
|
|
|
|
|
|
labels: MailLabel[]
|
|
|
|
|
|
labelsLoading: boolean
|
|
|
|
|
|
labelsPending: boolean
|
|
|
|
|
|
onCreateLabel: (form: FormData) => void
|
|
|
|
|
|
onDeleteLabel: (id: string) => void
|
|
|
|
|
|
signatures: MailSignature[]
|
|
|
|
|
|
signaturesLoading: boolean
|
|
|
|
|
|
signaturesPending: boolean
|
|
|
|
|
|
onCreateSignature: (form: FormData) => void
|
|
|
|
|
|
onUpdateSignature: (id: string, form: FormData) => void
|
|
|
|
|
|
onSetDefaultSignature: (id: string) => void
|
|
|
|
|
|
onDeleteSignature: (id: string) => void
|
|
|
|
|
|
clientHostname?: string
|
|
|
|
|
|
onSelectMailbox: (id: string) => void
|
|
|
|
|
|
onOpenCleanup: () => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AccountSettingsSection(props: AccountSettingsSectionProps) {
|
|
|
|
|
|
if (props.activeTab === "mail") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<MailPreferencesSection
|
|
|
|
|
|
labels={props.labels}
|
|
|
|
|
|
labelsLoading={props.labelsLoading}
|
|
|
|
|
|
labelsPending={props.labelsPending}
|
|
|
|
|
|
onCreateLabel={props.onCreateLabel}
|
|
|
|
|
|
onDeleteLabel={props.onDeleteLabel}
|
|
|
|
|
|
selectedMailbox={props.selectedMailbox}
|
|
|
|
|
|
signatures={props.signatures}
|
|
|
|
|
|
signaturesLoading={props.signaturesLoading}
|
|
|
|
|
|
signaturesPending={props.signaturesPending}
|
|
|
|
|
|
mailboxes={props.mailboxes}
|
|
|
|
|
|
onCreateSignature={props.onCreateSignature}
|
|
|
|
|
|
onUpdateSignature={props.onUpdateSignature}
|
|
|
|
|
|
onSetDefaultSignature={props.onSetDefaultSignature}
|
|
|
|
|
|
onDeleteSignature={props.onDeleteSignature}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (props.activeTab === "clients") {
|
|
|
|
|
|
return <ClientSettingsSection mailboxes={props.mailboxes} selectedMailboxId={props.selectedMailboxId} hostname={props.clientHostname} onSelectMailbox={props.onSelectMailbox} onCopy={props.onCopy} />
|
|
|
|
|
|
}
|
|
|
|
|
|
if (props.activeTab === "security") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SecuritySettingsSection
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
password={props.password}
|
|
|
|
|
|
passwordFormRef={props.passwordFormRef}
|
|
|
|
|
|
twoFactorFormRef={props.twoFactorFormRef}
|
|
|
|
|
|
setupTwoFactor={props.setupTwoFactor}
|
|
|
|
|
|
enableTwoFactor={props.enableTwoFactor}
|
|
|
|
|
|
disableTwoFactor={props.disableTwoFactor}
|
|
|
|
|
|
onCopy={props.onCopy}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<AccountTabSection
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
profile={props.profile}
|
|
|
|
|
|
stats={props.stats}
|
|
|
|
|
|
showStats={props.showStats}
|
|
|
|
|
|
displayMode={props.displayMode}
|
|
|
|
|
|
onDisplayModeChange={props.onDisplayModeChange}
|
|
|
|
|
|
selectedMailbox={props.selectedMailbox}
|
|
|
|
|
|
mailboxes={props.mailboxes}
|
|
|
|
|
|
onOpenCleanup={props.onOpenCleanup}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SettingsCard({ title, subtitle, action, children, className, contentClassName }: { title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; className?: string; contentClassName?: string }) {
|
|
|
|
|
|
return (
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<section className={cn("rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]", className)}>
|
|
|
|
|
|
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-start sm:justify-between">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="min-w-0">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<h2 className="text-[15px] font-semibold leading-6 text-foreground">{title}</h2>
|
|
|
|
|
|
{subtitle && <p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
{action && <div className="w-full shrink-0 sm:w-auto sm:justify-end [&>a]:w-full [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto">{action}</div>}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className={cn("px-6 pb-5", contentClassName)}>{children}</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
2026-08-03 17:27:29 +08:00
|
|
|
|
const accountName = user.loginName || user.email
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
|
|
|
|
|
const storageBytes = stats?.storageBytes || 0
|
|
|
|
|
|
const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<SettingsCard title="账号信息">
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
<InfoLine label="用户名" value={accountName} />
|
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
|
|
|
|
|
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Select defaultValue="Asia/Shanghai">
|
|
|
|
|
|
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="Asia/Shanghai">Asia/Shanghai (UTC+8)</SelectItem>
|
|
|
|
|
|
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
|
|
|
|
|
|
<SelectItem value="Asia/Singapore">Asia/Singapore (UTC+8)</SelectItem>
|
|
|
|
|
|
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
|
|
|
|
|
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
|
|
|
|
|
<span>{quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
|
|
|
|
|
|
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
|
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
2026-08-02 15:07:16 +08:00
|
|
|
|
<QuotaBox title="邮箱创建" lines={[`当前拥有 ${mailboxes.length} 个邮箱`, user.limits?.maxMailboxCount ? `最多可添加 ${user.limits.maxMailboxCount} 个邮箱` : "管理员不限制邮箱数量", user.limits?.maxMailboxCount ? "达到上限后不可继续自助申请" : "可继续添加邮箱"]} highlight={user.limits?.maxMailboxCount ? "普通额度" : "管理员无限"} />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<QuotaBox title="验证邮箱" lines={["已绑定主账号邮箱", "可继续添加验证邮箱"]} />
|
|
|
|
|
|
<QuotaBox title="发信频率" lines={[`每 24 小时 最多 ${user.limits?.smtpDailyLimit || "不限"} 封邮件`, `每分钟最多 ${user.limits?.smtpMinuteLimit || "不限"} 封`]} />
|
|
|
|
|
|
<QuotaBox title="协议访问频率" lines={[`IMAP:每 1 分钟 最多 ${user.limits?.imapMinuteLimit || "不限"} 次命令`, `POP3:每 1 分钟 最多 ${user.limits?.pop3MinuteLimit || "不限"} 次命令`]} />
|
|
|
|
|
|
<QuotaBox className="md:col-span-2" title="附件与应用密码" lines={[`单封附件上限 ${user.limits?.maxAttachmentMb || "不限"} MB`, "客户端访问使用邮箱登录密码或系统分配密码"]} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
|
|
|
|
|
<div className="text-base text-muted-foreground">{label}</div>
|
|
|
|
|
|
<div className="min-w-0 truncate text-base font-semibold sm:text-right">{value}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function QuotaBox({ title, lines, highlight, className }: { title: string; lines: string[]; highlight?: string; className?: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn("rounded-lg border p-4", className)}>
|
|
|
|
|
|
<div className="mb-3 flex items-center gap-2">
|
|
|
|
|
|
<div className="text-base font-semibold">{title}</div>
|
|
|
|
|
|
{highlight && <Badge variant="secondary" className="text-[10px]">{highlight}</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1 text-sm text-muted-foreground">
|
|
|
|
|
|
{lines.map((line) => <div key={line}>{line}</div>)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MailPreferencesSection({
|
|
|
|
|
|
labels,
|
|
|
|
|
|
labelsLoading,
|
|
|
|
|
|
labelsPending,
|
|
|
|
|
|
onCreateLabel,
|
|
|
|
|
|
onDeleteLabel,
|
|
|
|
|
|
selectedMailbox,
|
|
|
|
|
|
signatures,
|
|
|
|
|
|
signaturesLoading,
|
|
|
|
|
|
signaturesPending,
|
|
|
|
|
|
mailboxes,
|
|
|
|
|
|
onCreateSignature,
|
|
|
|
|
|
onUpdateSignature,
|
|
|
|
|
|
onSetDefaultSignature,
|
|
|
|
|
|
onDeleteSignature,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
labels: MailLabel[]
|
|
|
|
|
|
labelsLoading: boolean
|
|
|
|
|
|
labelsPending: boolean
|
|
|
|
|
|
onCreateLabel: (form: FormData) => void
|
|
|
|
|
|
onDeleteLabel: (id: string) => void
|
|
|
|
|
|
selectedMailbox?: Mailbox
|
|
|
|
|
|
signatures: MailSignature[]
|
|
|
|
|
|
signaturesLoading: boolean
|
|
|
|
|
|
signaturesPending: boolean
|
|
|
|
|
|
mailboxes: Mailbox[]
|
|
|
|
|
|
onCreateSignature: (form: FormData) => void
|
|
|
|
|
|
onUpdateSignature: (id: string, form: FormData) => void
|
|
|
|
|
|
onSetDefaultSignature: (id: string) => void
|
|
|
|
|
|
onDeleteSignature: (id: string) => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [labelColor, setLabelColor] = React.useState("#3b82f6")
|
|
|
|
|
|
const [signatureMailboxId, setSignatureMailboxId] = React.useState("all")
|
|
|
|
|
|
const [signatureDefault, setSignatureDefault] = React.useState(false)
|
|
|
|
|
|
const [editingSignature, setEditingSignature] = React.useState<MailSignature | null>(null)
|
|
|
|
|
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
function submitLabel(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
if (!selectedMailbox) return
|
|
|
|
|
|
const form = new FormData(event.currentTarget)
|
|
|
|
|
|
form.set("color", labelColor)
|
|
|
|
|
|
onCreateLabel(form)
|
|
|
|
|
|
event.currentTarget.reset()
|
|
|
|
|
|
setLabelColor("#3b82f6")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitSignature(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
const form = new FormData(event.currentTarget)
|
|
|
|
|
|
form.set("mailboxId", signatureMailboxId === "all" ? "" : signatureMailboxId)
|
|
|
|
|
|
form.set("isDefault", signatureDefault ? "on" : "")
|
|
|
|
|
|
onCreateSignature(form)
|
|
|
|
|
|
event.currentTarget.reset()
|
|
|
|
|
|
setSignatureMailboxId("all")
|
|
|
|
|
|
setSignatureDefault(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<SettingsCard title="标签管理">
|
|
|
|
|
|
<form className="flex gap-2" onSubmit={submitLabel}>
|
|
|
|
|
|
<Input name="name" className="h-[42px] flex-1 text-base" placeholder={selectedMailbox ? "标签名称" : "请先选择邮箱"} disabled={!selectedMailbox || labelsPending} required />
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Input type="color" value={labelColor} onChange={(event) => setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer bg-background p-1" aria-label="标签颜色" />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<Button className="h-10 px-4" disabled={!selectedMailbox || labelsPending}>{labelsPending ? "创建中" : "创建"}</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
|
|
|
|
{labels.map((label) => (
|
|
|
|
|
|
<span key={label.id} className="inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm">
|
|
|
|
|
|
<span className="size-3 rounded-full" style={{ backgroundColor: label.color || "#64748b" }} />
|
|
|
|
|
|
{label.name}
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 p-0 text-muted-foreground shadow-none hover:text-destructive" disabled={labelsPending} onClick={() => onDeleteLabel(label.id)} aria-label={`删除标签 ${label.name}`}>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<X className="h-3.5 w-3.5" />
|
2026-08-03 16:16:20 +08:00
|
|
|
|
</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{!labelsLoading && labels.length === 0 && <span className="text-sm text-muted-foreground">暂无标签</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="签名管理" subtitle="支持全局签名和按发件邮箱绑定的默认签名。" action={<span className="pt-1 text-sm text-muted-foreground">共 {signatures.length} 个签名</span>}>
|
|
|
|
|
|
<form className="rounded-lg border p-4" onSubmit={submitSignature}>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
|
|
|
|
<Field label="签名名称"><Input name="name" className="h-10" required placeholder="例如:默认签名" /></Field>
|
|
|
|
|
|
<Field label="绑定邮箱"><MailboxSelect value={signatureMailboxId} mailboxes={mailboxes} onChange={setSignatureMailboxId} /></Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label="签名内容">
|
|
|
|
|
|
<Textarea name="content" required className="min-h-[138px] text-base" placeholder="支持多行文本,写信时会自动转换为 HTML" />
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<label className="mb-4 mt-4 flex items-center gap-2 text-sm">
|
|
|
|
|
|
<Checkbox checked={signatureDefault} onCheckedChange={(checked) => setSignatureDefault(checked === true)} />
|
|
|
|
|
|
设为默认签名
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Button disabled={signaturesPending}>{signaturesPending ? "保存中..." : "创建签名"}</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div className="mt-4 space-y-3">
|
|
|
|
|
|
{signatures.map((item) => {
|
|
|
|
|
|
const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address || "未知邮箱" : "全局签名"
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={item.id} className="rounded-lg border p-4">
|
|
|
|
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
|
|
|
|
|
<span>{item.name}</span>
|
|
|
|
|
|
{item.isDefault && <Badge>默认</Badge>}
|
|
|
|
|
|
<Badge variant="outline">{mailbox}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{item.content}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 gap-1">
|
|
|
|
|
|
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}>设为默认</Button>}
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-8" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
{!signaturesLoading && signatures.length === 0 && <div className="py-5 text-center text-sm text-muted-foreground">暂无签名</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={!!editingSignature} onOpenChange={(open) => { if (!open) setEditingSignature(null) }}>
|
|
|
|
|
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
|
|
|
|
|
|
<DialogHeader><DialogTitle>编辑签名</DialogTitle></DialogHeader>
|
|
|
|
|
|
{editingSignature && (
|
|
|
|
|
|
<EditSignatureForm
|
|
|
|
|
|
key={editingSignature.id}
|
|
|
|
|
|
item={editingSignature}
|
|
|
|
|
|
mailboxes={mailboxes}
|
|
|
|
|
|
pending={signaturesPending}
|
|
|
|
|
|
onCancel={() => setEditingSignature(null)}
|
|
|
|
|
|
onSubmit={(id, form) => { onUpdateSignature(id, form); setEditingSignature(null) }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive={!!pendingConfirm?.destructive} pending={signaturesPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function EditSignatureForm({ item, mailboxes, pending, onCancel, onSubmit }: { item: MailSignature; mailboxes: Mailbox[]; pending: boolean; onCancel: () => void; onSubmit: (id: string, form: FormData) => void }) {
|
|
|
|
|
|
const [mailboxId, setMailboxId] = React.useState(item.mailboxId || "all")
|
|
|
|
|
|
const [isDefault, setIsDefault] = React.useState(item.isDefault)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); const form = new FormData(event.currentTarget); form.set("mailboxId", mailboxId === "all" ? "" : mailboxId); form.set("isDefault", isDefault ? "on" : ""); onSubmit(item.id, form) }}>
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
|
<Field label="签名名称"><Input name="name" defaultValue={item.name} required /></Field>
|
|
|
|
|
|
<Field label="绑定邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label="签名内容"><Textarea name="content" required className="min-h-44" defaultValue={item.content} /></Field>
|
|
|
|
|
|
<label className="flex items-center gap-3 text-sm font-medium">
|
|
|
|
|
|
<Checkbox checked={isDefault} onCheckedChange={(value) => setIsDefault(value === true)} />
|
|
|
|
|
|
<span>设为当前范围默认签名</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={onCancel}>取消</Button>
|
|
|
|
|
|
<Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject<HTMLFormElement>; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; onCopy: (text: string) => void }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<SettingsCard title="当前登录" contentClassName="border-t py-5">
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
|
|
|
|
|
|
<div>
|
2026-08-03 17:27:29 +08:00
|
|
|
|
<div className="font-semibold">{user.loginName || user.email}</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="text-sm text-muted-foreground">上次登录:刚刚</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="密码管理">
|
|
|
|
|
|
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
|
|
|
|
|
<Field label="当前密码"><PasswordInput name="currentPassword" required /></Field>
|
2026-08-03 22:39:51 +08:00
|
|
|
|
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
|
|
|
|
|
|
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="两步验证">
|
|
|
|
|
|
<div className="mb-4 flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm"><KeyRound className="h-4 w-4" />认证状态</div>
|
|
|
|
|
|
<Badge variant={user.twoFactorEnabled ? "default" : "secondary"}>{user.twoFactorEnabled ? "已启用" : "未启用"}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!user.twoFactorEnabled && !setupTwoFactor.data && (
|
|
|
|
|
|
<Button onClick={() => setupTwoFactor.mutate()} disabled={setupTwoFactor.isPending}>{setupTwoFactor.isPending ? "生成中..." : "启用两步验证"}</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!user.twoFactorEnabled && setupTwoFactor.data && (
|
|
|
|
|
|
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); enableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
|
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
|
|
|
|
<div className="flex justify-center rounded-lg border bg-white p-4">
|
|
|
|
|
|
<QRCodeSVG value={setupTwoFactor.data.otpauthUrl} size={184} level="M" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<Field label="密钥">
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Input value={setupTwoFactor.data.secret} readOnly />
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.secret)}><Copy className="h-4 w-4" />复制</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="绑定地址">
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Input value={setupTwoFactor.data.otpauthUrl} readOnly />
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.otpauthUrl)}><Copy className="h-4 w-4" />复制</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label="验证码"><Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required /></Field>
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setupTwoFactor.reset()}>取消</Button>
|
|
|
|
|
|
<Button disabled={enableTwoFactor.isPending}>{enableTwoFactor.isPending ? "启用中..." : "确认启用"}</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{user.twoFactorEnabled && (
|
|
|
|
|
|
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
|
|
|
|
|
|
<Field label="当前验证码"><Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required /></Field>
|
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
|
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭两步验证"}</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SettingsCard title="临时发信申请">
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{[selectedRequestRow(user.email, "已批准", "6小时"), selectedRequestRow(user.email, "已过期", "24小时")].map((item, index) => (
|
|
|
|
|
|
<div key={`${item.status}-${index}`} className="rounded-lg border px-4 py-3 text-sm">
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<span className="font-medium">{item.email}</span>
|
|
|
|
|
|
<Badge variant="secondary">{item.status}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1 text-muted-foreground">申请时长:{item.duration} · 原因:临时客户端发信测试</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectedRequestRow(email: string, status: string, duration: string) {
|
|
|
|
|
|
return { email, status, duration }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
|
|
|
|
|
|
const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SettingsCard title="待清理邮件" subtitle={mailbox ? `当前邮箱:${mailbox.address}` : "请先选择邮箱"}>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{rows.map((row) => (
|
|
|
|
|
|
<div key={row.name} className="grid grid-cols-[1fr_auto_auto] items-center gap-3 rounded-lg border p-3 text-sm">
|
|
|
|
|
|
<span className="font-medium">{row.name}</span>
|
|
|
|
|
|
<Badge variant="secondary">{row.count} 封</Badge>
|
|
|
|
|
|
<span className="text-muted-foreground">{formatBytes(row.bytes)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{rows.length === 0 && <EmptyState text="暂无待清理邮件" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 17:09:46 +08:00
|
|
|
|
function MailboxManagement({
|
|
|
|
|
|
mailboxes,
|
|
|
|
|
|
applyOptions,
|
|
|
|
|
|
applyPending,
|
2026-08-04 00:03:11 +08:00
|
|
|
|
canConfigureApply,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
selectedMailboxId,
|
2026-06-25 22:44:32 +08:00
|
|
|
|
externalImapEnabled,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
externalAccounts,
|
|
|
|
|
|
externalPending,
|
2026-06-25 20:20:37 +08:00
|
|
|
|
selectedExternalRunAccountId,
|
|
|
|
|
|
externalRunFolders,
|
|
|
|
|
|
externalSyncRuns,
|
|
|
|
|
|
onSelectExternalRunAccount,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onSelect,
|
|
|
|
|
|
onOpen,
|
|
|
|
|
|
onApply,
|
2026-08-04 00:03:11 +08:00
|
|
|
|
onConfigureApply,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onCreateExternal,
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onStartExternalOAuth,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onUpdateExternal,
|
|
|
|
|
|
onDeleteExternal,
|
|
|
|
|
|
onTestExternal,
|
|
|
|
|
|
onSyncExternal,
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onSyncExternalFolder,
|
2026-06-25 17:09:46 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
mailboxes: Mailbox[]
|
|
|
|
|
|
applyOptions?: MailboxApplyOptions
|
|
|
|
|
|
applyPending: boolean
|
2026-08-04 00:03:11 +08:00
|
|
|
|
canConfigureApply: boolean
|
2026-06-25 17:09:46 +08:00
|
|
|
|
selectedMailboxId: string
|
2026-06-25 22:44:32 +08:00
|
|
|
|
externalImapEnabled: boolean
|
2026-06-25 17:09:46 +08:00
|
|
|
|
externalAccounts: ExternalImapAccount[]
|
|
|
|
|
|
externalPending: boolean
|
2026-06-25 20:20:37 +08:00
|
|
|
|
selectedExternalRunAccountId: string
|
|
|
|
|
|
externalRunFolders: ExternalImapFolder[]
|
|
|
|
|
|
externalSyncRuns: ExternalImapSyncRun[]
|
|
|
|
|
|
onSelectExternalRunAccount: (id: string) => void
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onSelect: (id: string) => void
|
|
|
|
|
|
onOpen: (id: string) => void
|
|
|
|
|
|
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
2026-08-04 00:03:11 +08:00
|
|
|
|
onConfigureApply: () => void
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
2026-06-25 21:17:04 +08:00
|
|
|
|
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
2026-06-25 17:09:46 +08:00
|
|
|
|
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
|
|
|
|
|
onDeleteExternal: (id: string) => void
|
|
|
|
|
|
onTestExternal: (id: string) => void
|
|
|
|
|
|
onSyncExternal: (id: string) => void
|
2026-06-25 20:20:37 +08:00
|
|
|
|
onSyncExternalFolder: (id: string, folder: string) => void
|
2026-06-25 17:09:46 +08:00
|
|
|
|
}) {
|
2026-08-02 07:37:07 +08:00
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
const { toast } = useToast()
|
2026-06-16 00:51:41 +08:00
|
|
|
|
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const [domainId, setDomainId] = React.useState(() => applyOptions?.domains?.[0]?.id || "")
|
|
|
|
|
|
const [localPart, setLocalPart] = React.useState("")
|
|
|
|
|
|
const [mailboxSearch, setMailboxSearch] = React.useState("")
|
|
|
|
|
|
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
|
2026-08-02 18:33:25 +08:00
|
|
|
|
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
|
|
|
|
|
|
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
|
|
|
|
|
|
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
|
2026-08-03 19:51:27 +08:00
|
|
|
|
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
|
2026-08-02 07:37:07 +08:00
|
|
|
|
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
|
|
|
|
|
|
const verifiedEmailItems = forwarding.data?.verifiedEmails || []
|
2026-08-02 09:08:20 +08:00
|
|
|
|
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
|
2026-08-02 19:10:34 +08:00
|
|
|
|
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
|
2026-08-02 18:33:25 +08:00
|
|
|
|
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
|
|
|
|
|
|
const next: Record<string, string[]> = {}
|
2026-08-02 07:37:07 +08:00
|
|
|
|
for (const rule of forwarding.data?.mailboxRules || []) {
|
2026-08-02 18:33:25 +08:00
|
|
|
|
const targets = forwardingTargetsFromRule(rule)
|
|
|
|
|
|
if (targets.length > 0) next[rule.mailboxId] = targets
|
2026-08-02 07:37:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
return next
|
|
|
|
|
|
}, [forwarding.data?.mailboxRules])
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const normalizedMailboxSearch = mailboxSearch.trim().toLowerCase()
|
|
|
|
|
|
const domainOptions = applyOptions?.domains || []
|
|
|
|
|
|
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
|
2026-08-03 19:51:27 +08:00
|
|
|
|
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
|
2026-08-02 05:55:53 +08:00
|
|
|
|
const filteredMailboxes = normalizedMailboxSearch
|
2026-08-03 19:51:27 +08:00
|
|
|
|
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
|
2026-08-02 05:55:53 +08:00
|
|
|
|
: mailboxes
|
2026-08-02 07:37:07 +08:00
|
|
|
|
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
|
|
|
|
|
|
qc.setQueryData(["forwarding-settings"], settings)
|
|
|
|
|
|
}, [qc])
|
2026-08-02 19:10:34 +08:00
|
|
|
|
const refreshForwardingSettings = React.useCallback(() => {
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ["forwarding-settings"] })
|
|
|
|
|
|
}, [qc])
|
2026-08-02 07:37:07 +08:00
|
|
|
|
const addVerifiedEmail = useMutation({
|
|
|
|
|
|
mutationFn: api.addForwardingVerifiedEmail,
|
|
|
|
|
|
onSuccess: (settings, email) => {
|
|
|
|
|
|
setForwardingCache(settings)
|
2026-08-02 19:10:34 +08:00
|
|
|
|
refreshForwardingSettings()
|
|
|
|
|
|
window.setTimeout(refreshForwardingSettings, 2500)
|
|
|
|
|
|
window.setTimeout(refreshForwardingSettings, 7000)
|
2026-08-02 07:37:07 +08:00
|
|
|
|
setVerifiedEmailDraft("")
|
2026-08-02 09:08:20 +08:00
|
|
|
|
const item = settings.verifiedEmails.find((entry) => entry.email.toLowerCase() === email.trim().toLowerCase())
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: item?.deliveryStatus === "failed" ? "验证邮箱已添加,邮件发送失败" : "验证邮件已发送",
|
|
|
|
|
|
description: item?.deliveryStatus === "failed" ? item.deliveryError || "请稍后重发验证邮件" : "请前往目标邮箱点击确认验证",
|
|
|
|
|
|
})
|
2026-08-02 07:37:07 +08:00
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "添加失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-08-02 09:08:20 +08:00
|
|
|
|
const resendVerifiedEmail = useMutation({
|
|
|
|
|
|
mutationFn: ({ id }: { id: string; email: string }) => api.resendForwardingVerifiedEmail(id),
|
|
|
|
|
|
onSuccess: (settings, item) => {
|
|
|
|
|
|
setForwardingCache(settings)
|
2026-08-02 19:10:34 +08:00
|
|
|
|
refreshForwardingSettings()
|
|
|
|
|
|
window.setTimeout(refreshForwardingSettings, 2500)
|
|
|
|
|
|
window.setTimeout(refreshForwardingSettings, 7000)
|
2026-08-02 09:08:20 +08:00
|
|
|
|
const next = settings.verifiedEmails.find((entry) => entry.id === item.id)
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: next?.deliveryStatus === "failed" ? "重发失败" : "验证邮件已重发",
|
|
|
|
|
|
description: next?.deliveryStatus === "failed" ? next.deliveryError || "请稍后再试" : "请前往目标邮箱点击确认验证",
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "重发失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-08-02 07:37:07 +08:00
|
|
|
|
const deleteVerifiedEmail = useMutation({
|
|
|
|
|
|
mutationFn: ({ id }: { id: string; email: string }) => api.deleteForwardingVerifiedEmail(id),
|
2026-08-03 19:51:27 +08:00
|
|
|
|
onSuccess: (settings) => {
|
2026-08-02 07:37:07 +08:00
|
|
|
|
setForwardingCache(settings)
|
|
|
|
|
|
toast({ title: "验证邮箱已移除" })
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "移除失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const saveAccountForwarding = useMutation({
|
|
|
|
|
|
mutationFn: api.updateAccountForwarding,
|
2026-08-03 19:51:27 +08:00
|
|
|
|
onSuccess: (settings) => {
|
2026-08-02 07:37:07 +08:00
|
|
|
|
setForwardingCache(settings)
|
|
|
|
|
|
toast({ title: "账号级转发已保存" })
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
|
|
|
|
|
const saveMailboxForwarding = useMutation({
|
2026-08-02 18:33:25 +08:00
|
|
|
|
mutationFn: ({ mailboxId, targetEmails }: { mailboxId: string; targetEmails: string[] }) => api.updateMailboxForwarding(mailboxId, targetEmails),
|
2026-08-03 19:51:27 +08:00
|
|
|
|
onSuccess: (settings) => {
|
2026-08-02 07:37:07 +08:00
|
|
|
|
setForwardingCache(settings)
|
|
|
|
|
|
setForwardingMailbox(null)
|
2026-08-02 18:33:25 +08:00
|
|
|
|
setForwardDraft([])
|
2026-08-02 07:37:07 +08:00
|
|
|
|
toast({ title: "邮箱转发已保存" })
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
|
|
|
|
|
})
|
2026-08-02 09:08:20 +08:00
|
|
|
|
const forwardingBusy = forwarding.isLoading || addVerifiedEmail.isPending || resendVerifiedEmail.isPending || deleteVerifiedEmail.isPending || saveAccountForwarding.isPending || saveMailboxForwarding.isPending
|
2026-08-02 05:55:53 +08:00
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!domainOptions.length) return
|
|
|
|
|
|
setDomainId((current) => domainOptions.some((domain) => domain.id === current) ? current : domainOptions[0].id)
|
|
|
|
|
|
}, [domainOptions])
|
|
|
|
|
|
|
2026-08-02 07:37:07 +08:00
|
|
|
|
React.useEffect(() => {
|
2026-08-02 18:33:25 +08:00
|
|
|
|
setAccountForwardTargets(forwardingTargetsFromSettings(forwarding.data))
|
|
|
|
|
|
}, [forwarding.data?.accountTargetEmail, forwarding.data?.accountTargetEmails])
|
2026-08-02 19:10:34 +08:00
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!hasPendingVerifiedEmails) return
|
|
|
|
|
|
const timer = window.setInterval(() => { void forwarding.refetch() }, verifiedDialogOpen ? 3000 : 10000)
|
|
|
|
|
|
return () => window.clearInterval(timer)
|
|
|
|
|
|
}, [forwarding, hasPendingVerifiedEmails, verifiedDialogOpen])
|
2026-08-02 07:37:07 +08:00
|
|
|
|
|
2026-08-02 05:55:53 +08:00
|
|
|
|
async function submitMailbox(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
if (!selectedDomain || !localPart.trim()) return
|
|
|
|
|
|
await onApply({ domainId: selectedDomain.id, localPart: localPart.trim(), displayName: "" })
|
|
|
|
|
|
setLocalPart("")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openMailboxForward(mailbox: Mailbox) {
|
|
|
|
|
|
setForwardingMailbox(mailbox)
|
2026-08-02 18:33:25 +08:00
|
|
|
|
setForwardDraft(mailboxForwards[mailbox.id] || [])
|
2026-08-02 05:55:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveMailboxForward() {
|
|
|
|
|
|
if (!forwardingMailbox) return
|
2026-08-02 18:33:25 +08:00
|
|
|
|
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
|
2026-08-02 05:55:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
const value = verifiedEmailDraft.trim()
|
2026-08-02 09:08:20 +08:00
|
|
|
|
if (!value) return
|
2026-08-02 07:37:07 +08:00
|
|
|
|
addVerifiedEmail.mutate(value)
|
2026-08-02 05:55:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 07:37:07 +08:00
|
|
|
|
function removeVerifiedEmail(id: string, email: string) {
|
2026-08-02 18:33:25 +08:00
|
|
|
|
setAccountForwardTargets((items) => items.filter((item) => item !== email))
|
|
|
|
|
|
setForwardDraft((items) => items.filter((item) => item !== email))
|
2026-08-02 07:37:07 +08:00
|
|
|
|
deleteVerifiedEmail.mutate({ id, email })
|
2026-08-02 05:55:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 09:08:20 +08:00
|
|
|
|
function resendVerification(item: ForwardingVerifiedEmail) {
|
|
|
|
|
|
resendVerifiedEmail.mutate({ id: item.id, email: item.email })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 00:51:41 +08:00
|
|
|
|
return (
|
2026-06-25 17:09:46 +08:00
|
|
|
|
<div className="space-y-6">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<section className="rounded-lg border bg-card px-6 py-6">
|
|
|
|
|
|
<h2 className="mb-4 text-lg font-semibold leading-7">创建新邮箱</h2>
|
|
|
|
|
|
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_160px_80px]" onSubmit={submitMailbox}>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={localPart}
|
|
|
|
|
|
onChange={(event) => setLocalPart(event.target.value)}
|
|
|
|
|
|
className="h-[42px] text-base shadow-none"
|
|
|
|
|
|
placeholder="输入邮箱地址前缀"
|
|
|
|
|
|
disabled={!canApply || applyPending}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedDomain?.id || ""}
|
|
|
|
|
|
onChange={(event) => setDomainId(event.target.value)}
|
|
|
|
|
|
className="h-[42px] rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
|
disabled={!canApply || applyPending}
|
|
|
|
|
|
>
|
|
|
|
|
|
{domainOptions.map((domain) => <option key={domain.id} value={domain.id}>@{domain.name}</option>)}
|
|
|
|
|
|
{domainOptions.length === 0 && <option value="">暂无域名</option>}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
|
|
|
|
|
|
</form>
|
2026-08-04 00:03:11 +08:00
|
|
|
|
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
|
|
|
|
|
<span>{canApply
|
|
|
|
|
|
? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。"
|
|
|
|
|
|
: canConfigureApply && applyOptions?.enabled
|
|
|
|
|
|
? "提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。"
|
|
|
|
|
|
: canConfigureApply
|
|
|
|
|
|
? "提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。"
|
|
|
|
|
|
: "提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。"}</span>
|
|
|
|
|
|
{!canApply && canConfigureApply && <Button type="button" variant="link" className="h-auto p-0 text-sm" onClick={onConfigureApply}>前往设置</Button>}
|
|
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="rounded-lg border bg-card">
|
|
|
|
|
|
<div className="flex items-center justify-between gap-4 px-6 py-4">
|
|
|
|
|
|
<h2 className="text-lg font-semibold leading-7">我的邮箱 ({mailboxes.length})</h2>
|
|
|
|
|
|
<div className="relative w-64 shrink-0">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
2026-08-03 19:51:27 +08:00
|
|
|
|
<Input value={mailboxSearch} onChange={(event) => setMailboxSearch(event.target.value)} className="h-[34px] pl-9 text-sm shadow-none" placeholder="搜索邮箱地址..." />
|
2026-06-25 17:09:46 +08:00
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="divide-y">
|
|
|
|
|
|
{filteredMailboxes.map((mailbox) => {
|
2026-08-02 18:33:25 +08:00
|
|
|
|
const forwardTargets = mailboxForwards[mailbox.id] || []
|
|
|
|
|
|
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
|
|
|
|
|
|
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
|
|
|
|
|
|
const forwardingActive = effectiveForwardTargets.length > 0
|
2026-06-25 20:20:37 +08:00
|
|
|
|
return (
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-4">
|
|
|
|
|
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-950 text-white">
|
|
|
|
|
|
<Mail className="h-5 w-5" />
|
|
|
|
|
|
</div>
|
2026-06-25 20:20:37 +08:00
|
|
|
|
<div className="min-w-0">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
2026-08-02 18:33:25 +08:00
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-auto min-w-0 max-w-full truncate justify-start p-0 text-left text-sm font-semibold hover:bg-transparent hover:underline" onClick={() => { onSelect(mailbox.id); onOpen(mailbox.id) }}>{mailbox.address}</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{selectedMailboxId === mailbox.id && <Badge variant="secondary" className="h-5 rounded-md px-1.5 text-[10px]">当前</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
|
|
|
|
<span>创建于 {formatDateTime(mailbox.createdAt)}</span>
|
2026-08-02 18:33:25 +08:00
|
|
|
|
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">转发:{forwardTargets.join("、")}</span>}
|
|
|
|
|
|
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">转发:使用账号级 {accountForwardTargets.join("、")}</span>}
|
2026-06-25 20:20:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-25 17:09:46 +08:00
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
2026-08-02 18:33:25 +08:00
|
|
|
|
<Button type="button" variant="outline" size="sm" className={cn("h-[30px] w-[72px] gap-1 px-0", forwardingActive && "border-foreground/20 bg-foreground text-background hover:bg-foreground/90 hover:text-background")} onClick={() => { onSelect(mailbox.id); openMailboxForward(mailbox) }}><SendHorizontal className="h-3.5 w-3.5" />{forwardingActive ? "转发中" : "转发"}</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
2026-06-25 17:09:46 +08:00
|
|
|
|
</div>
|
2026-06-25 20:20:37 +08:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{filteredMailboxes.length === 0 && <div className="px-6 py-10 text-center text-sm text-muted-foreground">{mailboxes.length === 0 ? "暂无邮箱,请创建一个新邮箱" : "没有匹配邮箱"}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section id="mailbox-forwarding-section" className="rounded-lg border bg-card px-6 py-5">
|
|
|
|
|
|
<div className="mb-5 flex items-center justify-between gap-4">
|
|
|
|
|
|
<h2 className="text-lg font-semibold leading-7">邮件转发</h2>
|
|
|
|
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setVerifiedDialogOpen(true)}>管理验证邮箱</Button>
|
|
|
|
|
|
</div>
|
2026-08-02 09:08:20 +08:00
|
|
|
|
<div className="rounded-xl bg-muted/20 px-5 py-5">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<div className="mb-3 text-sm font-medium">账号级转发</div>
|
2026-08-02 18:33:25 +08:00
|
|
|
|
<div className="mb-4 text-sm text-muted-foreground">对所有邮箱生效,可同时转发到多个已验证邮箱;邮箱单独设置优先级更高</div>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
|
|
|
|
|
|
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
|
|
|
|
|
|
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-02 09:08:20 +08:00
|
|
|
|
{verifiedEmailItems.length > 0 && (
|
|
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
|
|
|
|
{verifiedEmailItems.map((item) => {
|
|
|
|
|
|
const tone = forwardingEmailTone(item)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span key={item.id} className={cn("inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1 text-sm", tone.chipClass)}>
|
|
|
|
|
|
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
|
|
|
|
|
|
<span className="min-w-0 truncate">{item.email} · {tone.shortLabel}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
2026-08-02 18:33:25 +08:00
|
|
|
|
<p className="mt-3 text-sm text-muted-foreground">提示:每个邮箱可单独设置多个转发目标(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。</p>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-03 19:51:27 +08:00
|
|
|
|
{externalImapEnabled && (
|
|
|
|
|
|
<section className="rounded-lg border bg-card">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold leading-7">外部邮箱</h2>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">{selectedMailbox ? `同步到 ${selectedMailbox.address}` : "请先选择邮箱"}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
<ExternalImapOAuthDialog provider="gmail" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
|
|
|
|
|
|
<ExternalImapOAuthDialog provider="outlook" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
|
|
|
|
|
|
<ExternalImapDialog mailboxId={selectedMailbox?.id || ""} disabled={!selectedMailbox || externalPending} pending={externalPending} onSubmit={onCreateExternal} />
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-03 19:51:27 +08:00
|
|
|
|
<div className="divide-y border-t">
|
|
|
|
|
|
{externalAccounts.map((account) => {
|
|
|
|
|
|
const expanded = selectedExternalRunAccountId === account.id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={account.id} className="px-6 py-4">
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<span className="truncate font-semibold">{account.name || account.username}</span>
|
|
|
|
|
|
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
|
|
|
|
|
|
<Badge variant="outline">{account.authMode === "oauth2" ? externalOAuthProviderLabel(account.oauthProvider) : "IMAP"}</Badge>
|
|
|
|
|
|
<Badge variant={account.lastStatus === "error" ? "destructive" : "outline"}>{externalStatusLabel(account.lastStatus)}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1 truncate text-sm text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.storageMode === "local" ? "同步到本地" : "远端直连"}</div>
|
|
|
|
|
|
{account.lastError && <div className="mt-1 truncate text-xs text-destructive">{account.lastError}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{account.authMode !== "oauth2" && <ExternalImapDialog account={account} mailboxId={account.mailboxId} disabled={externalPending} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />}
|
|
|
|
|
|
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}>测试</Button>
|
|
|
|
|
|
<Button type="button" variant="outline" size="sm" disabled={externalPending || !account.enabled} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" />同步</Button>
|
|
|
|
|
|
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSelectExternalRunAccount(expanded ? "" : account.id)}>{expanded ? "收起" : "详情"}</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive hover:text-destructive" disabled={externalPending} onClick={() => setPendingExternalDelete(account)} aria-label={`删除 ${account.name || account.username}`}><Trash2 className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{expanded && <div className="mt-4"><ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} /></div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
{externalAccounts.length === 0 && <div className="px-6 py-10 text-center text-sm text-muted-foreground">当前邮箱尚未添加外部账号</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
|
|
|
|
|
|
<Dialog open={!!forwardingMailbox} onOpenChange={(open) => { if (!open) setForwardingMailbox(null) }}>
|
2026-08-02 19:46:36 +08:00
|
|
|
|
<DialogContent className="sm:max-w-lg">
|
2026-08-02 05:55:53 +08:00
|
|
|
|
<DialogHeader><DialogTitle>邮件转发</DialogTitle></DialogHeader>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="truncate text-sm text-muted-foreground">{forwardingMailbox?.address}</div>
|
2026-08-02 19:46:36 +08:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label className="text-sm font-medium">转发到</Label>
|
|
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
|
|
|
|
|
|
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
|
|
|
|
|
|
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}>取消</Button>
|
|
|
|
|
|
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
|
2026-08-02 09:08:20 +08:00
|
|
|
|
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
|
|
|
|
|
|
<DialogHeader className="px-8 pt-8">
|
|
|
|
|
|
<DialogTitle className="text-2xl leading-8">验证邮箱管理</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
<div className="px-8 pt-5 text-[17px] leading-8 text-muted-foreground">
|
|
|
|
|
|
添加并验证外部邮箱地址后,才能用作转发目标。这里只展示投递状态摘要,不展示验证邮件内容。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
|
|
|
|
|
|
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} />
|
|
|
|
|
|
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</form>
|
2026-08-02 09:08:20 +08:00
|
|
|
|
<div className="mx-8 mt-6 max-h-[360px] overflow-y-auto rounded-lg border">
|
2026-08-02 07:37:07 +08:00
|
|
|
|
{verifiedEmailItems.map((item) => (
|
2026-08-02 09:08:20 +08:00
|
|
|
|
<div key={item.id} className="grid min-h-[82px] gap-3 border-b px-4 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="truncate text-lg font-semibold leading-6">{item.email}</div>
|
|
|
|
|
|
<div className="mt-1 text-sm text-muted-foreground">{item.verified ? `已验证 - ${formatDateTime(item.verifiedAt || item.createdAt)}` : "待验证"}</div>
|
|
|
|
|
|
{!item.verified && (
|
|
|
|
|
|
<div className={cn("mt-1 text-sm leading-5", forwardingEmailTone(item).detailClass)}>
|
|
|
|
|
|
{forwardingEmailStatusText(item)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
|
|
|
|
<span className={cn("size-2.5 rounded-full", forwardingEmailTone(item).dotClass)} />
|
|
|
|
|
|
{!item.verified && (
|
|
|
|
|
|
<Button type="button" variant="outline" className="h-10 px-4" disabled={forwardingBusy} onClick={() => resendVerification(item)}>重发</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button type="button" variant="outline" className="h-10 px-4 text-destructive hover:text-destructive" disabled={forwardingBusy} onClick={() => removeVerifiedEmail(item.id, item.email)} aria-label={`移除 ${item.email}`}>
|
|
|
|
|
|
删除
|
2026-08-02 07:37:07 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-08-02 09:08:20 +08:00
|
|
|
|
{verifiedEmailItems.length === 0 && <div className="py-10 text-center text-sm text-muted-foreground">暂无验证邮箱</div>}
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</div>
|
2026-08-02 09:08:20 +08:00
|
|
|
|
<DialogFooter className="border-t px-8 py-6">
|
|
|
|
|
|
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => setVerifiedDialogOpen(false)}>关闭</Button>
|
2026-08-02 05:55:53 +08:00
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-08-03 19:51:27 +08:00
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
open={!!pendingExternalDelete}
|
|
|
|
|
|
title="删除外部邮箱?"
|
|
|
|
|
|
description={pendingExternalDelete ? `${pendingExternalDelete.name || pendingExternalDelete.username} 的连接配置和同步记录将被删除。` : undefined}
|
|
|
|
|
|
confirmText="删除"
|
|
|
|
|
|
destructive
|
|
|
|
|
|
pending={externalPending}
|
|
|
|
|
|
onOpenChange={(open) => { if (!open) setPendingExternalDelete(null) }}
|
|
|
|
|
|
onConfirm={() => {
|
|
|
|
|
|
if (!pendingExternalDelete) return
|
|
|
|
|
|
onDeleteExternal(pendingExternalDelete.id)
|
|
|
|
|
|
setPendingExternalDelete(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-06-16 00:51:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 21:17:04 +08:00
|
|
|
|
function ExternalImapOAuthDialog({ provider, selectedMailbox, disabled, pending, onStart }: { provider: ExternalImapOAuthProvider; selectedMailbox?: Mailbox; disabled?: boolean; pending: boolean; onStart: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void }) {
|
|
|
|
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
|
|
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>("local")
|
|
|
|
|
|
const label = provider === "gmail" ? "Gmail OAuth" : "Microsoft 365 / Outlook OAuth"
|
|
|
|
|
|
|
|
|
|
|
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
if (!selectedMailbox) return
|
|
|
|
|
|
const form = new FormData(event.currentTarget)
|
|
|
|
|
|
onStart(provider, {
|
|
|
|
|
|
mailboxId: selectedMailbox.id,
|
|
|
|
|
|
email: String(form.get("email") || ""),
|
|
|
|
|
|
storageMode,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<Button type="button" variant="outline" disabled={disabled || pending} onClick={() => setOpen(true)}>{label}</Button>
|
|
|
|
|
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-lg">
|
|
|
|
|
|
<DialogHeader><DialogTitle>{label}</DialogTitle></DialogHeader>
|
|
|
|
|
|
<form className="space-y-4" onSubmit={submit}>
|
|
|
|
|
|
<div className="rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground">
|
|
|
|
|
|
OAuth 只适用于 {provider === "gmail" ? "Google Gmail" : "Microsoft 365 / Outlook / Exchange Online"} 托管邮箱。自建域名邮箱请使用“添加外部邮箱”的普通 IMAP 方式。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label="外部邮箱地址(可选)"><Input name="email" type="email" placeholder={selectedMailbox?.address || "name@example.com"} /></Field>
|
|
|
|
|
|
<div className="text-xs text-muted-foreground">留空时会以 OAuth 服务商返回的真实授权邮箱为准;填写后,回调时会校验它和真实授权邮箱一致。</div>
|
|
|
|
|
|
<Field label="存储模式">
|
|
|
|
|
|
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent><SelectItem value="local">同步到本地</SelectItem><SelectItem value="remote">远端直连</SelectItem></SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
|
|
|
|
|
<Button disabled={pending || !selectedMailbox}>{pending ? "跳转中..." : "前往授权"}</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 17:09:46 +08:00
|
|
|
|
function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) {
|
|
|
|
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
|
|
const [tlsMode, setTlsMode] = React.useState<ExternalImapTlsMode>(account?.tlsMode || "tls")
|
|
|
|
|
|
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>(account?.storageMode || "local")
|
|
|
|
|
|
const [syncReadState, setSyncReadState] = React.useState(account?.syncReadState ?? true)
|
|
|
|
|
|
const [enabled, setEnabled] = React.useState(account?.enabled ?? true)
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!open) return
|
|
|
|
|
|
setTlsMode(account?.tlsMode || "tls")
|
|
|
|
|
|
setStorageMode(account?.storageMode || "local")
|
|
|
|
|
|
setSyncReadState(account?.syncReadState ?? true)
|
|
|
|
|
|
setEnabled(account?.enabled ?? true)
|
|
|
|
|
|
}, [account, open])
|
|
|
|
|
|
|
|
|
|
|
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
const form = new FormData(event.currentTarget)
|
|
|
|
|
|
const payload: ExternalImapAccountPayload = {
|
|
|
|
|
|
mailboxId,
|
|
|
|
|
|
name: String(form.get("name") || ""),
|
|
|
|
|
|
host: String(form.get("host") || ""),
|
|
|
|
|
|
port: Number(form.get("port") || (tlsMode === "tls" ? 993 : 143)),
|
|
|
|
|
|
tlsMode,
|
|
|
|
|
|
username: String(form.get("username") || ""),
|
|
|
|
|
|
password: String(form.get("password") || ""),
|
|
|
|
|
|
storageMode,
|
|
|
|
|
|
syncReadState,
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
}
|
|
|
|
|
|
onSubmit(payload)
|
|
|
|
|
|
if (!pending) setOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
|
|
|
|
|
|
{account ? "编辑" : <><Plus className="h-4 w-4" />添加外部邮箱</>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
|
|
|
|
|
|
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
|
|
|
|
|
|
<form className="space-y-4" onSubmit={submit}>
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
|
<Field label="显示名称"><Input name="name" defaultValue={account?.name || ""} placeholder="Gmail / 工作邮箱" /></Field>
|
|
|
|
|
|
<Field label="用户名"><Input name="username" defaultValue={account?.username || ""} required placeholder="name@example.com" /></Field>
|
|
|
|
|
|
<Field label="服务器"><Input name="host" defaultValue={account?.host || ""} required placeholder="imap.example.com" /></Field>
|
|
|
|
|
|
<Field label="端口"><Input name="port" type="number" min={1} max={65535} defaultValue={account?.port || (tlsMode === "tls" ? 993 : 143)} /></Field>
|
|
|
|
|
|
<Field label="加密方式">
|
|
|
|
|
|
<Select value={tlsMode} onValueChange={(value) => setTlsMode(value as ExternalImapTlsMode)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent><SelectItem value="tls">SSL/TLS</SelectItem><SelectItem value="starttls">STARTTLS</SelectItem><SelectItem value="plain">不加密</SelectItem></SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="存储模式">
|
|
|
|
|
|
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent><SelectItem value="local">同步到本地</SelectItem><SelectItem value="remote">远端直连</SelectItem></SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label={account ? "密码(留空则不修改)" : "密码"}><PasswordInput name="password" required={!account} placeholder={account ? "不修改请留空" : "外部邮箱密码或授权码"} /></Field>
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
|
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={syncReadState} onCheckedChange={(checked) => setSyncReadState(checked === true)} />同步已读状态</label>
|
|
|
|
|
|
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(checked === true)} />启用此账号</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
|
|
|
|
|
<Button disabled={pending || !mailboxId}>{pending ? "保存中..." : "保存"}</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function externalStatusLabel(status: string) {
|
|
|
|
|
|
return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record<string, string>)[status] || status || "未知"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 21:17:04 +08:00
|
|
|
|
function externalOAuthProviderLabel(provider?: ExternalImapOAuthProvider) {
|
|
|
|
|
|
return provider === "gmail" ? "Gmail OAuth" : provider === "outlook" ? "Microsoft 365 / Outlook OAuth" : "OAuth"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 20:20:37 +08:00
|
|
|
|
function ExternalImapSyncPanel({ account, folders, runs, pending, onSyncFolder }: { account: ExternalImapAccount; folders: ExternalImapFolder[]; runs: ExternalImapSyncRun[]; pending: boolean; onSyncFolder: (id: string, folder: string) => void }) {
|
|
|
|
|
|
const [folder, setFolder] = React.useState("")
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (folder && folders.some((item) => item.name === folder)) return
|
|
|
|
|
|
setFolder(folders[0]?.name || "INBOX")
|
|
|
|
|
|
}, [folder, folders])
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-lg bg-muted/40 p-3">
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
|
|
|
|
|
<Field label="单文件夹同步">
|
|
|
|
|
|
<Select value={folder} onValueChange={setFolder}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="选择远端文件夹" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{folders.map((item) => <SelectItem key={item.name} value={item.name}>{folderLabel(item.name)}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Button type="button" variant="outline" disabled={pending || !folder} onClick={() => onSyncFolder(account.id, folder)}><RefreshCcw className="h-4 w-4" />同步文件夹</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground">最近同步记录</div>
|
|
|
|
|
|
{runs.length === 0 && <div className="rounded-md border bg-background p-3 text-sm text-muted-foreground">暂无同步记录</div>}
|
|
|
|
|
|
{runs.slice(0, 6).map((run) => (
|
|
|
|
|
|
<div key={run.id} className="grid gap-2 rounded-md border bg-background p-3 text-sm md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<Badge variant={run.status === "ok" ? "secondary" : run.status === "failed" ? "destructive" : "outline"}>{externalStatusLabel(run.status)}</Badge>
|
|
|
|
|
|
<span className="truncate">{run.folder ? folderLabel(run.folder) : "全部文件夹"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{run.error && <div className="mt-1 truncate text-xs text-destructive">{run.error}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-muted-foreground md:text-right">
|
|
|
|
|
|
<div>导入 {run.imported} · 跳过 {run.skipped} · 失败 {run.failed}</div>
|
|
|
|
|
|
<div>{formatDateTime(run.startedAt)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 17:09:46 +08:00
|
|
|
|
function formatDateTime(value: string) {
|
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return value
|
|
|
|
|
|
return date.toLocaleString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 18:33:25 +08:00
|
|
|
|
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
|
|
|
|
|
|
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
|
|
|
|
|
|
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
|
|
|
|
|
|
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
|
|
|
|
|
|
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:34:58 +08:00
|
|
|
|
function ForwardingTargetPicker({ emails, selected, onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
|
2026-08-02 19:10:34 +08:00
|
|
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
|
|
const [query, setQuery] = React.useState("")
|
2026-08-02 18:33:25 +08:00
|
|
|
|
const selectedSet = React.useMemo(() => new Set(selected), [selected])
|
2026-08-02 19:10:34 +08:00
|
|
|
|
const sortedEmails = React.useMemo(() => {
|
|
|
|
|
|
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
|
|
|
|
|
|
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
|
|
|
|
|
|
}, [emails])
|
|
|
|
|
|
const selectedEmails = React.useMemo(() => sortedEmails.filter((email) => selectedSet.has(email)), [selectedSet, sortedEmails])
|
|
|
|
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
|
|
|
|
const filteredEmails = normalizedQuery ? sortedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : sortedEmails
|
|
|
|
|
|
const label = selectedEmails.length
|
|
|
|
|
|
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? ` 等 ${selectedEmails.length} 个` : ""}`
|
|
|
|
|
|
: "不转发,点击选择邮箱"
|
2026-08-02 18:33:25 +08:00
|
|
|
|
function toggle(email: string, checked: boolean) {
|
|
|
|
|
|
if (disabled) return
|
|
|
|
|
|
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email)
|
|
|
|
|
|
onChange(next)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (emails.length === 0) {
|
|
|
|
|
|
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">暂无已验证邮箱</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
2026-08-02 19:10:34 +08:00
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-[37px] w-full justify-between gap-2 bg-background px-3 text-left text-sm font-normal hover:bg-accent/60 hover:text-foreground",
|
|
|
|
|
|
open && "border-primary/35 ring-1 ring-primary/15",
|
|
|
|
|
|
disabled && "cursor-not-allowed opacity-60 hover:bg-background",
|
|
|
|
|
|
)}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
onClick={() => setOpen((value) => !value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={cn("min-w-0 flex-1 truncate", selectedEmails.length ? "font-medium text-foreground" : "text-muted-foreground")}>{label}</span>
|
|
|
|
|
|
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{open && (
|
2026-08-02 19:34:58 +08:00
|
|
|
|
<div className={cn("absolute left-0 right-0 z-30 rounded-md border bg-popover p-2 shadow-sm", placement === "top" ? "bottom-full mb-2" : "mt-2")}>
|
|
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
|
|
|
|
|
<div className="relative min-w-0">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
|
className="h-8 pl-8 pr-8 text-sm shadow-none"
|
|
|
|
|
|
placeholder="搜索已验证邮箱"
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
{query && (
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" onClick={() => setQuery("")}>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-8 px-2 text-xs font-normal text-muted-foreground hover:bg-muted hover:text-foreground" onClick={() => onChange([])}>
|
|
|
|
|
|
清空
|
2026-08-02 19:10:34 +08:00
|
|
|
|
</Button>
|
2026-08-02 19:34:58 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
|
2026-08-02 19:10:34 +08:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{filteredEmails.map((email) => {
|
|
|
|
|
|
const checked = selectedSet.has(email)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<label
|
|
|
|
|
|
key={email}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex min-h-[36px] cursor-pointer items-center gap-2 rounded-md border border-transparent px-2.5 py-1.5 text-sm transition-colors hover:border-border hover:bg-muted/70",
|
|
|
|
|
|
checked && "border-primary/30 bg-primary/10 font-semibold text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Checkbox checked={checked} disabled={disabled} onCheckedChange={(value) => toggle(email, value === true)} />
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{email}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
{filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground">没有匹配的已验证邮箱</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-02 18:33:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 09:08:20 +08:00
|
|
|
|
function forwardingEmailTone(item: ForwardingVerifiedEmail) {
|
|
|
|
|
|
if (item.verified) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
shortLabel: "已验证",
|
|
|
|
|
|
dotClass: "bg-emerald-500",
|
|
|
|
|
|
chipClass: "bg-emerald-100 text-emerald-800",
|
|
|
|
|
|
detailClass: "text-emerald-700",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.deliveryStatus === "failed") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
shortLabel: "发送失败",
|
|
|
|
|
|
dotClass: "bg-destructive",
|
|
|
|
|
|
chipClass: "bg-destructive/10 text-destructive",
|
|
|
|
|
|
detailClass: "text-destructive",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.deliveryStatus === "delivered") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
shortLabel: "待验证",
|
|
|
|
|
|
dotClass: "bg-amber-500",
|
|
|
|
|
|
chipClass: "bg-amber-100 text-amber-800",
|
|
|
|
|
|
detailClass: "text-amber-700",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
shortLabel: "待验证",
|
2026-08-02 18:33:25 +08:00
|
|
|
|
dotClass: "bg-muted-foreground",
|
|
|
|
|
|
chipClass: "bg-muted text-foreground",
|
|
|
|
|
|
detailClass: "text-foreground",
|
2026-08-02 09:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function forwardingEmailStatusText(item: ForwardingVerifiedEmail) {
|
|
|
|
|
|
const time = item.verificationSentAt ? ` · 最近尝试 ${formatDateTime(item.verificationSentAt)}` : ""
|
|
|
|
|
|
if (item.deliveryStatus === "failed") {
|
|
|
|
|
|
return `验证邮件发送失败${item.deliveryError ? `:${item.deliveryError}` : ""}${time}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.deliveryStatus === "delivered") {
|
|
|
|
|
|
return `验证邮件已发送,请前往目标邮箱完成验证${time}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.deliveryStatus === "sending") {
|
|
|
|
|
|
return `验证邮件发送中${time}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `验证邮件排队发送中${time}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 15:31:20 +08:00
|
|
|
|
function dateInputValue(date: Date) {
|
2026-06-29 16:09:40 +08:00
|
|
|
|
const year = date.getFullYear()
|
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
|
|
|
|
const day = String(date.getDate()).padStart(2, "0")
|
|
|
|
|
|
return `${year}-${month}-${day}`
|
2026-06-29 15:31:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dateInputToISOString(value: string) {
|
|
|
|
|
|
if (!value) return undefined
|
2026-06-29 16:09:40 +08:00
|
|
|
|
const [year, month, day] = value.split("-").map(Number)
|
|
|
|
|
|
return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString()
|
2026-06-29 15:31:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 23:15:35 +08:00
|
|
|
|
function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) {
|
|
|
|
|
|
const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
|
|
|
|
|
|
const server = clientServerHost(hostname, selected?.address)
|
|
|
|
|
|
const rows = [
|
|
|
|
|
|
{ label: "IMAP 服务器", value: `${server}:993`, security: "SSL" },
|
|
|
|
|
|
{ label: "POP3 服务器", value: `${server}:995`, security: "SSL" },
|
|
|
|
|
|
{ label: "SMTP 服务器", value: `${server}:465`, security: "SSL" },
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>第三方客户端</CardTitle>
|
|
|
|
|
|
<div className="mt-1 text-sm text-muted-foreground">IMAP / POP3 / SMTP 配置用于 Thunderbird、Apple Mail、手机邮件客户端等。</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!!selected && <Badge variant="secondary">{selected.address}</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-5">
|
|
|
|
|
|
<Field label="选择邮箱">
|
|
|
|
|
|
<Select value={selected?.id || ""} onValueChange={onSelectMailbox}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="选择邮箱" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
{selected ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="rounded-lg border p-4">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="truncate font-medium">{selected.address}</div>
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
|
|
|
|
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● IMAP</Badge>
|
|
|
|
|
|
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● POP3</Badge>
|
|
|
|
|
|
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● SMTP</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline">已启用</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-lg bg-muted p-5">
|
|
|
|
|
|
<div className="mb-4 font-medium">客户端配置</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{rows.map((row) => (
|
|
|
|
|
|
<ClientConfigRow key={row.label} label={row.label} value={row.value} security={row.security} onCopy={onCopy} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Separator className="my-4" />
|
|
|
|
|
|
<div className="grid gap-3 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">
|
|
|
|
|
|
<div className="text-muted-foreground">用户名</div>
|
|
|
|
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
|
|
|
|
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-muted-foreground">密码</div>
|
|
|
|
|
|
<div>邮箱登录密码</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
2026-08-02 15:07:16 +08:00
|
|
|
|
<EmptyState text="暂无邮箱,创建邮箱后可查看客户端配置" />
|
2026-06-16 23:15:35 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ClientConfigRow({ label, value, security, onCopy }: { label: string; value: string; security: string; onCopy: (text: string) => void }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid items-center gap-2 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">
|
|
|
|
|
|
<div className="text-muted-foreground">{label}</div>
|
|
|
|
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
|
|
|
|
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
|
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 10:47:58 +08:00
|
|
|
|
const apiTokenScopeOptions = [
|
|
|
|
|
|
["messages:send", "发送邮件"], ["messages:read", "读取邮件与投递状态"], ["messages:manage", "重试或取消发送"],
|
|
|
|
|
|
["domains:read", "查看域名"], ["domains:write", "管理域名"], ["mailboxes:read", "查看邮箱"], ["mailboxes:write", "管理邮箱"],
|
2026-08-02 15:07:16 +08:00
|
|
|
|
["dns:read", "查看 DNS"], ["dns:check", "执行 DNS 检测"], ["aliases:read", "查看邮件转发"], ["aliases:write", "管理邮件转发"],
|
2026-07-10 10:47:58 +08:00
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
|
|
function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelete, onCopy }: { items: APIToken[]; loading: boolean; pending: boolean; onCreate: (payload: { name: string; expiresAt?: string; scopes: string[] }) => Promise<{ token: string; item: APIToken }>; onUpdate: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => void; onDelete: (id: string) => void; onCopy: (text: string) => void }) {
|
2026-08-02 23:19:01 +08:00
|
|
|
|
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
|
2026-06-29 15:31:20 +08:00
|
|
|
|
const [createdToken, setCreatedToken] = React.useState("")
|
|
|
|
|
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
2026-07-10 10:47:58 +08:00
|
|
|
|
const [scopes, setScopes] = React.useState<string[]>(["messages:send", "messages:read"])
|
|
|
|
|
|
const [editingToken, setEditingToken] = React.useState<APIToken | null>(null)
|
|
|
|
|
|
const [editingScopes, setEditingScopes] = React.useState<string[]>([])
|
2026-06-29 15:31:20 +08:00
|
|
|
|
const defaultExpiresAt = React.useMemo(() => dateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)), [])
|
|
|
|
|
|
|
|
|
|
|
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
2026-06-29 16:09:40 +08:00
|
|
|
|
const target = event.currentTarget
|
|
|
|
|
|
const form = new FormData(target)
|
2026-06-29 15:31:20 +08:00
|
|
|
|
const expiresAt = dateInputToISOString(String(form.get("expiresAt") || ""))
|
2026-06-29 16:09:40 +08:00
|
|
|
|
try {
|
2026-08-02 23:19:01 +08:00
|
|
|
|
const res = await onCreate({ name: String(form.get("name") || "").trim(), expiresAt, scopes })
|
2026-06-29 16:09:40 +08:00
|
|
|
|
setCreatedToken(res.token)
|
|
|
|
|
|
target.reset()
|
2026-07-10 10:47:58 +08:00
|
|
|
|
setScopes(["messages:send", "messages:read"])
|
2026-08-02 23:19:01 +08:00
|
|
|
|
setCreateDialogOpen(false)
|
2026-06-29 16:09:40 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Mutation-level error handling already shows the toast.
|
|
|
|
|
|
}
|
2026-06-29 15:31:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 23:19:01 +08:00
|
|
|
|
function openCreateDialog() {
|
|
|
|
|
|
setCreatedToken("")
|
|
|
|
|
|
setScopes(["messages:send", "messages:read"])
|
|
|
|
|
|
setCreateDialogOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 15:31:20 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<section className="rounded-lg border bg-card">
|
|
|
|
|
|
<div className="flex min-h-16 items-center justify-between gap-3 px-4 py-3">
|
|
|
|
|
|
<h2 className="text-lg font-semibold leading-7">API 密钥</h2>
|
|
|
|
|
|
<Button type="button" size="sm" className="h-8 rounded-md px-3 text-xs" onClick={openCreateDialog}>创建密钥</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2 border-t px-4 py-4">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
{createdToken && (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-amber-950">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold"><KeyRound className="h-4 w-4" />只显示一次</div>
|
|
|
|
|
|
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
|
|
|
|
|
<code className="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-3 py-2 text-xs">{createdToken}</code>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(createdToken)}><Copy className="h-4 w-4" />复制</Button>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{items.map((item) => {
|
|
|
|
|
|
const expired = item.expiresAt ? new Date(item.expiresAt).getTime() <= Date.now() : false
|
|
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div key={item.id} className="grid min-h-[74px] gap-3 rounded-lg border bg-background px-3 py-3 transition-colors hover:bg-muted/30 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<div className="truncate text-sm font-semibold">{item.name}</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Badge variant={item.disabled || expired ? "secondary" : "default"} className="h-5 px-1.5 text-[10px]">{item.disabled ? "已禁用" : expired ? "已过期" : "可用"}</Badge>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 grid gap-1 text-xs leading-5 text-muted-foreground sm:grid-cols-3">
|
|
|
|
|
|
<span>创建:{formatDateTime(item.createdAt)}</span>
|
|
|
|
|
|
<span>过期:{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"}</span>
|
|
|
|
|
|
<span>最后使用:{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
{(item.scopes || ["*"]).map((scope) => <Badge key={scope} variant="outline" className="h-5 px-1.5 text-[10px] font-normal">{scope}</Badge>)}
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={pending} onClick={() => { setEditingToken(item); setEditingScopes(item.scopes?.includes("*") ? ["messages:send", "messages:read"] : item.scopes || []) }}>编辑权限</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={pending || expired} onClick={() => onUpdate(item.id, { disabled: !item.disabled })}>{item.disabled ? "启用" : "禁用"}</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs text-destructive hover:text-destructive" disabled={pending} onClick={() => setPendingConfirm({ title: "撤销 API 密钥?", description: `密钥“${item.name}”撤销后无法恢复,正在使用它的集成会立即失效。`, confirmText: "撤销密钥", destructive: true, onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}>撤销</Button>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-08-03 16:16:20 +08:00
|
|
|
|
{!loading && items.length === 0 && <div className="grid min-h-[56px] place-items-center text-sm text-muted-foreground">暂无 API 密钥,点击上方按钮创建</div>}
|
|
|
|
|
|
{loading && items.length === 0 && <div className="grid min-h-[56px] place-items-center text-sm text-muted-foreground">正在加载 API 密钥</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
|
|
|
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
|
|
|
|
|
|
<DialogHeader><DialogTitle>创建 API 密钥</DialogTitle></DialogHeader>
|
2026-07-10 10:47:58 +08:00
|
|
|
|
<form className="space-y-4" onSubmit={submit}>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_12rem]">
|
|
|
|
|
|
<Field label="名称"><Input name="name" required maxLength={80} placeholder="billing-system" autoFocus /></Field>
|
2026-07-10 10:47:58 +08:00
|
|
|
|
<Field label="到期日期"><Input name="expiresAt" type="date" defaultValue={defaultExpiresAt} min={dateInputValue(new Date())} required /></Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field label="授权范围">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
2026-07-10 10:47:58 +08:00
|
|
|
|
{apiTokenScopeOptions.map(([value, label]) => (
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<label key={value} className="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm transition-colors hover:bg-muted/50">
|
2026-07-10 10:47:58 +08:00
|
|
|
|
<Checkbox checked={scopes.includes(value)} onCheckedChange={(checked) => setScopes((current) => checked === true ? [...current, value] : current.filter((scope) => scope !== value))} />
|
|
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
</Field>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)}>取消</Button>
|
|
|
|
|
|
<Button disabled={pending || scopes.length === 0}>{pending ? "创建中..." : "创建密钥"}</Button>
|
|
|
|
|
|
</DialogFooter>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
</form>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2026-06-29 15:31:20 +08:00
|
|
|
|
|
2026-07-10 10:47:58 +08:00
|
|
|
|
<Dialog open={!!editingToken} onOpenChange={(open) => { if (!open) setEditingToken(null) }}>
|
|
|
|
|
|
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<DialogHeader><DialogTitle>编辑密钥权限</DialogTitle></DialogHeader>
|
2026-07-10 10:47:58 +08:00
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
|
|
|
|
{apiTokenScopeOptions.map(([value, label]) => (
|
|
|
|
|
|
<label key={value} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
|
|
|
|
|
<Checkbox checked={editingScopes.includes(value)} onCheckedChange={(checked) => setEditingScopes((current) => checked === true ? [...current, value] : current.filter((scope) => scope !== value))} />
|
|
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setEditingToken(null)}>取消</Button>
|
|
|
|
|
|
<Button type="button" disabled={pending || editingScopes.length === 0} onClick={() => { if (editingToken) onUpdate(editingToken.id, { scopes: editingScopes }); setEditingToken(null) }}>保存权限</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-06-29 15:31:20 +08:00
|
|
|
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "撤销"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 01:07:48 +08:00
|
|
|
|
function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) {
|
2026-06-16 15:40:38 +08:00
|
|
|
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[380px_minmax(0,1fr)]">
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader><CardTitle>新增联系人</CardTitle></CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
|
|
|
|
|
|
<Field label="姓名"><Input name="name" placeholder="张三" /></Field>
|
|
|
|
|
|
<Field label="邮箱"><Input name="email" type="email" required /></Field>
|
|
|
|
|
|
<Field label="备注"><Input name="note" /></Field>
|
|
|
|
|
|
<Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存联系人"}</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader><CardTitle>联系人列表</CardTitle></CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-2">
|
|
|
|
|
|
{items.map((item) => (
|
|
|
|
|
|
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="truncate text-sm font-medium">{item.name}</div>
|
|
|
|
|
|
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 gap-1">
|
|
|
|
|
|
<Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
|
|
|
|
|
|
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{!loading && items.length === 0 && <EmptyState text="暂无联系人" />}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 15:54:15 +08:00
|
|
|
|
function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; showStats: boolean; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
|
2026-06-16 15:40:38 +08:00
|
|
|
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
|
|
|
|
|
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
|
|
|
|
|
|
setPendingConfirm({
|
|
|
|
|
|
title,
|
|
|
|
|
|
description: mailbox ? `将对 ${mailbox.address} 执行此清理操作。` : "请先选择邮箱。",
|
|
|
|
|
|
confirmText: destructive ? "确认清空" : "确认处理",
|
|
|
|
|
|
destructive,
|
|
|
|
|
|
onConfirm: () => { onCleanup(target); setPendingConfirm(null) },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
2026-06-22 15:54:15 +08:00
|
|
|
|
{showStats && <StatsSummary stats={stats} />}
|
2026-06-16 15:40:38 +08:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
|
|
|
|
|
<CardContent className="grid gap-3 md:grid-cols-3">
|
|
|
|
|
|
<CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} />
|
|
|
|
|
|
<CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} />
|
|
|
|
|
|
<CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 21:50:44 +08:00
|
|
|
|
type RuleCreatePayload = {
|
|
|
|
|
|
mailboxId: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
matchMode: "all" | "any"
|
|
|
|
|
|
conditions: MailRuleCondition[]
|
|
|
|
|
|
actions: MailRuleAction[]
|
|
|
|
|
|
applyToExisting: boolean
|
|
|
|
|
|
stopProcessing: boolean
|
|
|
|
|
|
enabled: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 17:16:01 +08:00
|
|
|
|
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
|
|
|
|
|
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
|
|
|
|
|
|
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
|
2026-08-03 00:57:45 +08:00
|
|
|
|
const conditionFieldShortLabels: Record<RuleConditionField, string> = { from: "发件人", to: "收件人", cc: "抄送", subject: "邮件主题", body: "body", attachment: "附件", size: "邮件大小", date: "收信日期" }
|
2026-06-24 17:16:01 +08:00
|
|
|
|
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[]
|
2026-06-25 14:11:38 +08:00
|
|
|
|
const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"]
|
2026-08-02 20:46:35 +08:00
|
|
|
|
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" }
|
2026-06-15 21:50:44 +08:00
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) {
|
|
|
|
|
|
const [editingRule, setEditingRule] = React.useState<MailRule | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
function setDialogOpen(next: boolean) {
|
|
|
|
|
|
if (!next) setEditingRule(null)
|
|
|
|
|
|
onOpenChange(next)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="space-y-4">
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="flex justify-stretch sm:justify-end">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
2026-08-03 00:57:45 +08:00
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
|
|
|
|
|
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pending, initialRule, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; pending: boolean; initialRule: MailRule | null; onSave: (payload: RuleCreatePayload) => void }) {
|
2026-06-15 21:50:44 +08:00
|
|
|
|
const [name, setName] = React.useState("我的规则")
|
|
|
|
|
|
const [mailboxId, setMailboxId] = React.useState("all")
|
|
|
|
|
|
const [matchMode, setMatchMode] = React.useState<"all" | "any">("all")
|
2026-08-02 20:32:34 +08:00
|
|
|
|
const [conditions, setConditions] = React.useState<MailRuleCondition[]>([{ field: "to", operator: "contains", value: "" }])
|
|
|
|
|
|
const [actions, setActions] = React.useState<MailRuleAction[]>([{ type: "forward", value: "" }])
|
2026-06-15 21:50:44 +08:00
|
|
|
|
const [enabled, setEnabled] = React.useState(true)
|
|
|
|
|
|
const [applyToExisting, setApplyToExisting] = React.useState(false)
|
|
|
|
|
|
const [stopProcessing, setStopProcessing] = React.useState(false)
|
|
|
|
|
|
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
|
|
|
|
|
|
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
|
|
|
|
|
|
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
|
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!open) return
|
2026-08-03 16:16:20 +08:00
|
|
|
|
setName(initialRule?.name || "我的规则")
|
|
|
|
|
|
setMailboxId(initialRule?.mailboxId || "all")
|
|
|
|
|
|
setMatchMode(initialRule?.matchMode || "all")
|
|
|
|
|
|
setConditions(initialRule?.conditions.length ? initialRule.conditions : [{ field: "to", operator: "contains", value: "" }])
|
|
|
|
|
|
setActions(initialRule?.actions.length ? initialRule.actions : [{ type: "forward", value: "" }])
|
|
|
|
|
|
setEnabled(initialRule?.enabled ?? true)
|
|
|
|
|
|
setApplyToExisting(initialRule?.applyToExisting ?? false)
|
|
|
|
|
|
setStopProcessing(initialRule?.stopProcessing ?? false)
|
|
|
|
|
|
}, [initialRule, open])
|
2026-06-15 21:50:44 +08:00
|
|
|
|
|
|
|
|
|
|
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
2026-06-24 17:16:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}))
|
2026-06-15 21:50:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
|
|
|
|
|
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
|
|
|
|
|
}
|
|
|
|
|
|
function addCondition() { setConditions((items) => [...items, { field: "subject", operator: "contains", value: "" }]) }
|
2026-08-02 20:32:34 +08:00
|
|
|
|
function addAction() { setActions((items) => [...items, { type: "forward", value: "" }]) }
|
2026-06-15 21:50:44 +08:00
|
|
|
|
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) }
|
|
|
|
|
|
|
2026-06-24 17:16:01 +08:00
|
|
|
|
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
2026-08-03 16:16:20 +08:00
|
|
|
|
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).map((item) => item.type === "forward" ? { ...item, value: verifiedRuleForwardTargets(item.value || "", verifiedEmails).join(", ") } : item).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value)
|
2026-06-15 21:50:44 +08:00
|
|
|
|
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
|
|
|
|
|
|
|
|
|
|
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
if (!canCreate) return
|
2026-08-03 16:16:20 +08:00
|
|
|
|
onSave({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled })
|
2026-06-15 21:50:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
2026-08-02 20:46:35 +08:00
|
|
|
|
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]">
|
2026-06-20 02:13:29 +08:00
|
|
|
|
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</DialogHeader>
|
2026-06-20 02:13:29 +08:00
|
|
|
|
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
|
|
|
|
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
|
|
|
|
|
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
|
|
|
|
<span>当新邮件到达时,满足以下</span>
|
|
|
|
|
|
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
|
|
|
|
|
|
<SelectTrigger className="h-9 w-[132px]"><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent><SelectItem value="all">所有条件</SelectItem><SelectItem value="any">任一条件</SelectItem></SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{conditions.map((condition, index) => (
|
2026-08-02 20:46:35 +08:00
|
|
|
|
<div key={index} className="grid gap-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto]">
|
2026-06-24 17:16:01 +08:00
|
|
|
|
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
2026-06-24 17:16:01 +08:00
|
|
|
|
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</Select>
|
2026-06-24 17:16:01 +08:00
|
|
|
|
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
2026-06-24 17:16:01 +08:00
|
|
|
|
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</Select>
|
2026-06-24 17:16:01 +08:00
|
|
|
|
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="text-sm">执行以下动作</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{actions.map((action, index) => (
|
2026-08-02 20:46:35 +08:00
|
|
|
|
<div key={index} className={cn("grid gap-3", action.type === "forward" ? "md:grid-cols-[180px_minmax(0,1fr)_auto]" : "md:grid-cols-[180px_minmax(0,1fr)_auto_auto]")}>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
2026-08-02 20:46:35 +08:00
|
|
|
|
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
|
|
|
|
|
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
|
|
|
|
|
<Info className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-20 02:13:29 +08:00
|
|
|
|
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
|
2026-06-15 21:50:44 +08:00
|
|
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action: MailRuleAction; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
2026-06-15 21:50:44 +08:00
|
|
|
|
if (action.type === "label") {
|
|
|
|
|
|
if (labels.length > 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Select value={action.value || labels[0].name} onValueChange={(value) => onChange({ value, labelId: labels.find((item) => item.name === value)?.id || "" })}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="选择标签" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{labels.map((label) => <SelectItem key={label.id} value={label.name}>{label.name}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action.type === "move") {
|
2026-06-25 14:11:38 +08:00
|
|
|
|
const value = action.value || "Archive"
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return (
|
2026-06-25 14:11:38 +08:00
|
|
|
|
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
|
|
|
|
|
|
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="Inbox">收件箱</SelectItem>
|
|
|
|
|
|
<SelectItem value="Archive">归档</SelectItem>
|
|
|
|
|
|
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
|
|
|
|
|
<SelectItem value="Trash">回收站</SelectItem>
|
|
|
|
|
|
<SelectItem value="__custom">自定义文件夹</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
|
|
|
|
|
|
</div>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-02 20:32:34 +08:00
|
|
|
|
if (action.type === "forward") {
|
2026-08-03 16:16:20 +08:00
|
|
|
|
return <RuleForwardTargets value={action.value || ""} emails={verifiedEmails} onChange={(value) => onChange({ value })} />
|
2026-08-02 20:32:34 +08:00
|
|
|
|
}
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return <Input value="无需填写" readOnly />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) {
|
|
|
|
|
|
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
|
|
|
|
|
|
return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" />
|
2026-08-02 20:46:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
|
|
|
|
|
|
const verifiedByAddress = new Map(verifiedEmails.map((email) => [email.trim().toLowerCase(), email.trim()]))
|
|
|
|
|
|
return Array.from(new Set(value.split(/[\n\r,,;;]+/).map((item) => verifiedByAddress.get(item.trim().toLowerCase())).filter((item): item is string => !!item)))
|
2026-08-02 20:46:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 21:50:44 +08:00
|
|
|
|
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
|
|
|
|
|
const id = React.useId()
|
|
|
|
|
|
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
2026-06-16 15:40:38 +08:00
|
|
|
|
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
2026-08-03 00:57:45 +08:00
|
|
|
|
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
|
|
|
|
|
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
2026-08-03 16:16:20 +08:00
|
|
|
|
const moveDirection = index === 0 ? "down" : "up"
|
|
|
|
|
|
const canMove = count > 1
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
|
|
|
|
|
<div className="min-w-0 space-y-1">
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2 leading-6">
|
|
|
|
|
|
<h3 className="truncate text-base font-semibold text-foreground">{item.name}</h3>
|
|
|
|
|
|
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
2026-08-03 00:57:45 +08:00
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="grid text-sm leading-6 text-muted-foreground">
|
|
|
|
|
|
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
|
|
|
|
|
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || !canMove} onClick={() => onMove(moveDirection)} aria-label={moveDirection === "up" ? "上移" : "下移"} title={moveDirection === "up" ? "上移" : "下移"}>{moveDirection === "up" ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</Button>
|
|
|
|
|
|
<span className="mx-1 h-6 w-px bg-border" />
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button>
|
|
|
|
|
|
<Button variant="ghost" size="icon" className="size-7 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
2026-06-16 15:40:38 +08:00
|
|
|
|
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
|
2026-06-15 21:50:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): MailRuleAction {
|
|
|
|
|
|
if (action.type === "label") {
|
|
|
|
|
|
const value = action.value || labels[0]?.name || ""
|
|
|
|
|
|
return { type: "label", value, labelId: labels.find((label) => label.name === value)?.id || action.labelId || "" }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action.type === "move") return { type: "move", value: action.value || "Archive" }
|
2026-08-02 20:32:34 +08:00
|
|
|
|
if (action.type === "forward") return { type: "forward", value: (action.value || "").trim() }
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return { type: action.type }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 17:16:01 +08:00
|
|
|
|
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 "输入附件名或扩展名"
|
2026-08-02 20:32:34 +08:00
|
|
|
|
if (field === "to" || field === "from" || field === "cc") return "输入邮箱或关键词"
|
|
|
|
|
|
if (field === "subject") return "输入主题关键词"
|
2026-06-24 17:16:01 +08:00
|
|
|
|
return "输入值"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 00:57:45 +08:00
|
|
|
|
function ruleConditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
2026-06-15 21:50:44 +08:00
|
|
|
|
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[]
|
2026-08-03 00:57:45 +08:00
|
|
|
|
return items.map(ruleConditionItemSummary).join(";") || "无条件"
|
2026-06-24 17:16:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 00:57:45 +08:00
|
|
|
|
function ruleConditionItemSummary(item: MailRuleCondition): string {
|
2026-06-24 17:16:01 +08:00
|
|
|
|
if (item.conditions?.length) {
|
|
|
|
|
|
const mode = item.matchMode === "any" ? "任一" : "全部"
|
2026-08-03 00:57:45 +08:00
|
|
|
|
return `${mode}(${item.conditions.map(ruleConditionItemSummary).join(";")})`
|
2026-06-24 17:16:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
const field = item.field || "from"
|
|
|
|
|
|
const operator = item.operator || defaultConditionOperator(field)
|
2026-08-03 00:57:45 +08:00
|
|
|
|
const value = item.value ? `"${item.value}"` : ""
|
|
|
|
|
|
return `${conditionFieldShortLabels[field]}${conditionOperatorLabels[operator]}${value}`
|
2026-06-15 21:50:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 00:57:45 +08:00
|
|
|
|
function ruleActionSummary(action: MailRuleAction) {
|
|
|
|
|
|
if (action.type === "label") return `${ruleActionLabels[action.type]}${action.value ? `:"${action.value}"` : ""}`
|
2026-08-03 16:16:20 +08:00
|
|
|
|
if (action.type === "move") return `${ruleActionLabels[action.type]}"${folderLabel(action.value || "Archive")}"`
|
2026-08-02 20:32:34 +08:00
|
|
|
|
if (action.type === "forward") return `${ruleActionLabels[action.type]}${action.value ? `:${action.value}` : ""}`
|
2026-06-15 21:50:44 +08:00
|
|
|
|
return ruleActionLabels[action.type]
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
|
2026-06-16 15:40:38 +08:00
|
|
|
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
2026-08-02 23:19:01 +08:00
|
|
|
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
|
|
|
|
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
onCreate(new FormData(event.currentTarget))
|
|
|
|
|
|
event.currentTarget.reset()
|
|
|
|
|
|
setDialogOpen(false)
|
|
|
|
|
|
}
|
2026-06-16 15:40:38 +08:00
|
|
|
|
return (
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground">共 {spamCount} 封垃圾邮件 · {items.length} 条发件人拦截规则</div>
|
|
|
|
|
|
<Button type="button" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}><Plus className="h-4 w-4" />新增拦截</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<SettingsCard title="被拦截邮件" subtitle="发件人命中拦截规则后会进入垃圾邮件,规则可随时移除。" contentClassName="space-y-2">
|
|
|
|
|
|
{items.map((item) => (
|
|
|
|
|
|
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/40">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
|
|
|
|
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
|
|
|
|
|
</SettingsCard>
|
|
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
|
|
|
|
<DialogContent className="w-[min(92vw,30rem)] max-w-none">
|
|
|
|
|
|
<DialogHeader><DialogTitle>新增拦截发件人</DialogTitle></DialogHeader>
|
|
|
|
|
|
<form className="space-y-4" onSubmit={submit}>
|
2026-06-16 15:40:38 +08:00
|
|
|
|
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
<Field label="发件人邮箱"><Input name="email" type="email" required placeholder="sender@example.com" /></Field>
|
|
|
|
|
|
<Field label="原因"><Input name="reason" placeholder="可选,例如:广告、骚扰邮件" /></Field>
|
|
|
|
|
|
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
|
|
|
|
|
<Button disabled={pending}>{pending ? "保存中..." : "加入拦截"}</Button>
|
|
|
|
|
|
</DialogFooter>
|
2026-06-16 15:40:38 +08:00
|
|
|
|
</form>
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2026-06-16 15:40:38 +08:00
|
|
|
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "移除"} destructive onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
|
2026-08-02 23:19:01 +08:00
|
|
|
|
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
|
|
|
|
|
const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100)
|
2026-08-03 00:16:03 +08:00
|
|
|
|
const primaryCards = [
|
2026-08-03 16:16:20 +08:00
|
|
|
|
{ label: "总收件", value: stats?.totalIncoming || 0, icon: <Mail className="h-4 w-4" />, tone: "bg-muted text-foreground" },
|
2026-08-03 00:16:03 +08:00
|
|
|
|
{ label: "总发件", value: stats?.totalOutgoing || 0, icon: <SendHorizontal className="h-4 w-4" />, tone: "bg-emerald-50 text-emerald-600" },
|
|
|
|
|
|
{ label: "未读邮件", value: stats?.unreadMessages || 0, icon: <MailCheck className="h-4 w-4" />, tone: "bg-amber-50 text-amber-600" },
|
2026-08-03 16:16:20 +08:00
|
|
|
|
{ label: "存储用量", value: formatBytes(stats?.storageBytes || 0), subvalue: stats?.quotaBytes ? `/ ${formatBytes(stats.quotaBytes)} (${quotaPct.toFixed(0)}%)` : "不限", icon: <HardDrive className="h-4 w-4" />, tone: "bg-violet-50 text-violet-600" },
|
2026-08-03 00:16:03 +08:00
|
|
|
|
]
|
|
|
|
|
|
const secondaryStats = [
|
|
|
|
|
|
{ label: "今日发件", value: stats?.todayOutgoing || 0 },
|
|
|
|
|
|
{ label: "草稿", value: stats?.draftMessages || 0 },
|
|
|
|
|
|
{ label: "发送失败", value: stats?.failedSends || 0 },
|
|
|
|
|
|
{ label: "平均邮件大小", value: formatBytes(stats?.averageMessageBytes || 0) },
|
|
|
|
|
|
]
|
2026-08-02 23:19:01 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
{primaryCards.map((card) => (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div key={card.label} className="grid h-[106px] grid-cols-[2.5rem_minmax(0,1fr)] items-center gap-3 rounded-lg border bg-card px-5">
|
|
|
|
|
|
<div className={cn("flex size-10 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="min-w-0">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="truncate text-sm text-muted-foreground">{card.label}</div>
|
|
|
|
|
|
<div className="truncate text-2xl font-semibold leading-8 text-foreground">{card.value}</div>
|
|
|
|
|
|
{"subvalue" in card && <div className="truncate text-xs leading-4 text-muted-foreground">{card.subvalue}</div>}
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
{secondaryStats.map((item) => (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div key={item.label} className="flex h-[54px] items-center justify-between gap-3 rounded-lg border bg-background px-4 text-sm">
|
|
|
|
|
|
<div className="truncate text-sm text-muted-foreground">{item.label}</div>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="shrink-0 font-semibold text-foreground">{item.value}</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
|
|
|
|
|
<StatsPanel title="收发趋势" className="h-[321px]">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<StatsTrendChart points={stats?.trend || []} />
|
|
|
|
|
|
</StatsPanel>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<StatsPanel title="邮件分布" className="h-[321px]">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<StatsDistribution items={stats?.distribution || []} />
|
2026-08-03 00:37:05 +08:00
|
|
|
|
</StatsPanel>
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
|
|
|
|
<StatsPanel title="存储用量" className="h-[338px]">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<StatsStorage quotaLabel={quotaLabel} quotaPct={quotaPct} hasQuota={!!stats?.quotaBytes} />
|
2026-08-03 00:37:05 +08:00
|
|
|
|
</StatsPanel>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<StatsPanel title="常用联系人" className="h-[338px]">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<StatsContacts contacts={stats?.topContacts || []} />
|
|
|
|
|
|
</StatsPanel>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:16:20 +08:00
|
|
|
|
function StatsPanel({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
2026-08-03 00:37:05 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<section className={cn("overflow-hidden rounded-lg border bg-card", className)}>
|
|
|
|
|
|
<div className="px-5 pb-2 pt-4">
|
|
|
|
|
|
<h2 className="text-base font-semibold leading-6 text-foreground">{title}</h2>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="px-5 pb-5">{children}</div>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 00:16:03 +08:00
|
|
|
|
function StatsTrendChart({ points }: { points: MailStats["trend"] }) {
|
|
|
|
|
|
const data = points.length ? points : [{ date: "", incoming: 0, outgoing: 0 }]
|
|
|
|
|
|
const maxValue = Math.max(...data.flatMap((item) => [item.incoming, item.outgoing]), 1)
|
|
|
|
|
|
const width = 520
|
2026-08-03 00:37:05 +08:00
|
|
|
|
const height = 148
|
|
|
|
|
|
const padding = { top: 12, right: 10, bottom: 22, left: 32 }
|
2026-08-03 00:16:03 +08:00
|
|
|
|
const plotWidth = width - padding.left - padding.right
|
|
|
|
|
|
const plotHeight = height - padding.top - padding.bottom
|
|
|
|
|
|
const xFor = (index: number) => padding.left + (data.length === 1 ? plotWidth / 2 : (index / (data.length - 1)) * plotWidth)
|
|
|
|
|
|
const yFor = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight
|
|
|
|
|
|
const pathFor = (key: "incoming" | "outgoing") => data.map((item, index) => `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(1)} ${yFor(item[key]).toFixed(1)}`).join(" ")
|
|
|
|
|
|
const ticks = trendTicks(data)
|
|
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="h-[250px] rounded-md bg-background">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="flex items-center justify-end gap-4 text-xs text-muted-foreground">
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-foreground" />收件</span>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-emerald-500" />发件</span>
|
|
|
|
|
|
</div>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-[226px] w-full overflow-visible" role="img" aria-label="邮件收发趋势">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
|
|
|
|
|
|
const y = padding.top + plotHeight * step
|
|
|
|
|
|
return <line key={step} x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-border" strokeDasharray={step === 1 ? undefined : "3 5"} />
|
|
|
|
|
|
})}
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<path d={pathFor("incoming")} fill="none" className="stroke-foreground" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<path d={pathFor("outgoing")} fill="none" className="stroke-emerald-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
|
|
{data.map((item, index) => (
|
|
|
|
|
|
<g key={`${item.date}-${index}`}>
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<circle cx={xFor(index)} cy={yFor(item.incoming)} r="2.8" className="fill-foreground" />
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<circle cx={xFor(index)} cy={yFor(item.outgoing)} r="2.8" className="fill-emerald-500" />
|
|
|
|
|
|
</g>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<text x="0" y={padding.top + 4} className="fill-muted-foreground text-[11px]">{maxValue}</text>
|
|
|
|
|
|
<text x="0" y={padding.top + plotHeight + 4} className="fill-muted-foreground text-[11px]">0</text>
|
|
|
|
|
|
{ticks.map((tick) => (
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<text key={`${tick.index}-${tick.label}`} x={xFor(tick.index)} y={height - 6} textAnchor="middle" className="fill-muted-foreground text-[11px]">{tick.label}</text>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trendTicks(data: MailStats["trend"]) {
|
|
|
|
|
|
if (data.length === 0) return []
|
|
|
|
|
|
const count = Math.min(5, data.length)
|
|
|
|
|
|
const seen = new Set<number>()
|
|
|
|
|
|
const ticks: { index: number; label: string }[] = []
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
const index = count === 1 ? 0 : Math.round((i / (count - 1)) * (data.length - 1))
|
|
|
|
|
|
if (seen.has(index)) continue
|
|
|
|
|
|
seen.add(index)
|
|
|
|
|
|
ticks.push({ index, label: formatStatsDate(data[index]?.date || "") })
|
|
|
|
|
|
}
|
|
|
|
|
|
return ticks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatStatsDate(value: string) {
|
|
|
|
|
|
if (!value) return ""
|
|
|
|
|
|
const [, month, day] = value.split("-")
|
|
|
|
|
|
return month && day ? `${month}-${day}` : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StatsDistribution({ items }: { items: MailStats["distribution"] }) {
|
|
|
|
|
|
const rows = items.length ? items : [
|
|
|
|
|
|
{ key: "inbox", label: "收件箱", count: 0 },
|
|
|
|
|
|
{ key: "archive", label: "已归档", count: 0 },
|
|
|
|
|
|
{ key: "spam", label: "垃圾邮件", count: 0 },
|
|
|
|
|
|
{ key: "trash", label: "已删除", count: 0 },
|
|
|
|
|
|
{ key: "attachments", label: "有附件", count: 0 },
|
|
|
|
|
|
{ key: "starred", label: "已加旗标", count: 0 },
|
|
|
|
|
|
]
|
|
|
|
|
|
const maxCount = Math.max(...rows.map((row) => row.count), 1)
|
|
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="space-y-3 py-3">
|
2026-08-03 00:16:03 +08:00
|
|
|
|
{rows.map((row) => (
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div key={row.key} className="grid grid-cols-[4.5rem_minmax(0,1fr)_2rem] items-center gap-2 text-xs">
|
|
|
|
|
|
<div className="truncate font-medium text-muted-foreground">{row.label}</div>
|
|
|
|
|
|
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
|
<div className={cn("h-full rounded-full", distributionBarTone(row.key))} style={{ width: `${Math.max(row.count > 0 ? 6 : 0, Math.round((row.count / maxCount) * 100))}%` }} />
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</div>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="text-right font-semibold text-foreground">{row.count}</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 00:37:05 +08:00
|
|
|
|
function distributionBarTone(key: string) {
|
|
|
|
|
|
if (key === "archive") return "bg-sky-500"
|
|
|
|
|
|
if (key === "spam") return "bg-amber-400"
|
|
|
|
|
|
if (key === "trash") return "bg-red-500"
|
|
|
|
|
|
if (key === "attachments") return "bg-violet-500"
|
|
|
|
|
|
if (key === "starred") return "bg-slate-600"
|
|
|
|
|
|
return "bg-slate-900"
|
2026-08-03 00:16:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) {
|
|
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="h-[260px] pt-3">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="mb-2 flex items-end justify-between gap-3">
|
|
|
|
|
|
<div className="text-sm font-semibold text-foreground">{quotaLabel}</div>
|
|
|
|
|
|
<div className="text-xs font-semibold text-foreground">{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="h-full rounded-full bg-slate-900 transition-all" style={{ width: `${hasQuota ? quotaPct : 12}%` }} />
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<p className="mt-3 text-xs text-muted-foreground">{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) {
|
2026-08-03 16:16:20 +08:00
|
|
|
|
if (contacts.length === 0) return <EmptyState icon={<Users />} text="暂无常用联系人" description="有邮件往来后会显示联系人排行" className="h-[260px] min-h-0 py-4" />
|
2026-08-03 00:16:03 +08:00
|
|
|
|
return (
|
2026-08-03 16:16:20 +08:00
|
|
|
|
<div className="h-[260px] space-y-1 overflow-hidden pt-1">
|
|
|
|
|
|
{contacts.slice(0, 10).map((item, index) => (
|
|
|
|
|
|
<div key={item.email} className="grid grid-cols-[1.25rem_minmax(0,1fr)_3.25rem] items-center gap-2 text-sm leading-6">
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="text-center font-semibold text-muted-foreground">{index + 1}</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
<div className="min-w-0 truncate font-medium text-foreground">{item.email}</div>
|
2026-08-03 00:37:05 +08:00
|
|
|
|
<div className="text-right font-semibold text-muted-foreground">{item.count} 封</div>
|
2026-08-03 00:16:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-08-02 23:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StatsSummary({ stats }: { stats?: MailStats }) {
|
2026-06-24 17:16:01 +08:00
|
|
|
|
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
2026-08-02 23:19:01 +08:00
|
|
|
|
const cards = [
|
|
|
|
|
|
{ label: "总邮件", value: stats?.totalMessages || 0, icon: <Mail className="h-4 w-4" />, tone: "bg-zinc-100 text-zinc-700" },
|
|
|
|
|
|
{ label: "未读", value: stats?.unreadMessages || 0, icon: <MailCheck className="h-4 w-4" />, tone: "bg-emerald-50 text-emerald-600" },
|
|
|
|
|
|
{ label: "星标", value: stats?.starredMessages || 0, icon: <ShieldCheck className="h-4 w-4" />, tone: "bg-amber-50 text-amber-600" },
|
|
|
|
|
|
{ label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}`, icon: <Image className="h-4 w-4" />, tone: "bg-violet-50 text-violet-600" },
|
|
|
|
|
|
{ label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel, icon: <BarChart3 className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
|
|
|
|
|
{cards.map((card) => (
|
|
|
|
|
|
<Card key={card.label} className="shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
|
|
|
|
|
<CardContent className="flex items-center gap-3 p-4">
|
|
|
|
|
|
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="truncate text-xl font-semibold leading-6 text-foreground">{card.value}</div>
|
|
|
|
|
|
<div className="mt-0.5 text-xs text-muted-foreground">{card.label}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 01:07:48 +08:00
|
|
|
|
function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="h-auto justify-start p-4 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 rounded-lg bg-muted p-2">{icon}</div><div className="font-medium">{title}</div></Button> }
|
|
|
|
|
|
function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部邮箱</SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> }
|
|
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
|
2026-08-02 23:19:01 +08:00
|
|
|
|
function EmptyState({ text, description, icon, action, className }: { text: string; description?: string; icon?: React.ReactNode; action?: React.ReactNode; className?: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn("grid min-h-[132px] place-items-center rounded-lg border border-dashed bg-background/60 px-6 py-8 text-center", className)}>
|
|
|
|
|
|
<div className="flex max-w-sm flex-col items-center">
|
|
|
|
|
|
{icon && <div className="mb-3 text-muted-foreground/70 [&>svg]:h-9 [&>svg]:w-9 [&>svg]:stroke-[1.5]">{icon}</div>}
|
|
|
|
|
|
<div className="text-sm font-medium text-muted-foreground">{text}</div>
|
|
|
|
|
|
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
|
|
|
|
|
|
{action && <div className="mt-4">{action}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-14 01:07:48 +08:00
|
|
|
|
function folderLabel(folder: string) { return ({ Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站" } as Record<string, string>)[folder] || folder }
|
2026-06-16 23:15:35 +08:00
|
|
|
|
function clientServerHost(hostname?: string, address?: string) { const value = (hostname || "").trim(); if (value) return value; const domain = (address || "").split("@")[1]; return domain ? `mail.${domain}` : "mail.example.com" }
|
2026-08-02 05:55:53 +08:00
|
|
|
|
function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
|
2026-06-14 01:07:48 +08:00
|
|
|
|
const displayName = cleanAccountName(name, email)
|
2026-08-02 05:55:53 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full items-center justify-between gap-3 px-4">
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
|
|
|
|
<Avatar className="size-[32px] rounded-full">
|
|
|
|
|
|
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback>
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onToggleTheme}>
|
|
|
|
|
|
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onBack}>
|
|
|
|
|
|
<ArrowLeft className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-14 01:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
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() }
|