feat(mail): 增加签名管理与客户端配置

- 新增邮件签名的数据表、接口和前端管理页,支持全局/邮箱默认签名的创建、编辑、删除与查询。
- 写信时自动带入当前邮箱的默认签名,并优化手动输入内容时的覆盖行为。
- 补充第三方邮件客户端配置页,展示 IMAP/POP3/SMTP 连接信息及公共主机名。
- 扩展部署配置,开放 POP3S/SMTPS 端口并启用 Dovecot POP3 服务。
This commit is contained in:
LanQin
2026-06-16 23:15:35 +08:00
parent 9a0e039767
commit 045132146b
18 changed files with 628 additions and 22 deletions
+217 -3
View File
@@ -2,9 +2,9 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailStats } from "@/lib/api"
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"
import { PasswordInput } from "@/components/ui/password-input"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -28,11 +29,13 @@ import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGrou
import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
type Tab = "profile" | "mailboxes" | "clients" | "signatures" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
clients: { label: "第三方客户端", icon: <Laptop className="h-4 w-4" /> },
signatures: { label: "签名管理", icon: <KeyRound className="h-4 w-4" /> },
contacts: { label: "联系人管理", icon: <Contact className="h-4 w-4" /> },
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
rules: { label: "收件规则", icon: <SlidersHorizontal className="h-4 w-4" /> },
@@ -64,7 +67,9 @@ export function ProfilePage() {
const user = me.data?.user
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
@@ -107,6 +112,22 @@ export function ProfilePage() {
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
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: "签名已删除" }) } })
const createRule = useMutation({
mutationFn: (payload: {
mailboxId: string
@@ -214,6 +235,8 @@ export function ProfilePage() {
function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
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} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
@@ -437,6 +460,196 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
)
}
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 ThunderbirdApple 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>
</>
) : (
<EmptyState text="暂无邮箱账号,创建邮箱后可查看客户端配置" />
)}
</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>
)
}
function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpdate, onSetDefault, onDelete }: { items: MailSignature[]; mailboxes: Mailbox[]; loading: boolean; pending: boolean; onCreate: (form: FormData) => void; onUpdate: (id: string, form: FormData) => void; onSetDefault: (id: string) => void; onDelete: (id: string) => void }) {
const [mailboxId, setMailboxId] = React.useState("all")
const [isDefault, setIsDefault] = React.useState(false)
const [editing, setEditing] = React.useState<MailSignature | null>(null)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const editingMailboxId = editing?.mailboxId || "all"
const editingIsDefault = editing?.isDefault || false
function resetCreateForm(form: HTMLFormElement) {
form.reset()
setMailboxId("all")
setIsDefault(false)
}
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"></div>
</div>
<div className="text-sm text-muted-foreground"> {items.length} </div>
</div>
</CardHeader>
<CardContent>
<form className="space-y-4 rounded-lg border p-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", mailboxId === "all" ? "" : mailboxId); form.set("isDefault", isDefault ? "on" : ""); onCreate(form); resetCreateForm(e.currentTarget) }}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="签名名称"><Input name="name" required placeholder="例如:默认签名" /></Field>
<Field label="绑定邮箱">
<MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} />
</Field>
</div>
<Field label="签名内容">
<Textarea name="content" required className="min-h-40" placeholder="支持多行文本,写信时会自动转为 HTML" />
</Field>
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox checked={isDefault} onCheckedChange={(value) => setIsDefault(value === true)} />
<span></span>
</label>
<Button disabled={pending}>{pending ? "保存中..." : "创建签名"}</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-3">
{items.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">
<div className="font-medium">{item.name}</div>
{item.isDefault && <Badge></Badge>}
<Badge variant="outline">{mailbox}</Badge>
</div>
<div className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{item.content}</div>
</div>
<div className="flex shrink-0 gap-1">
{!item.isDefault && <Button variant="outline" size="sm" disabled={pending} onClick={() => onSetDefault(item.id)}></Button>}
<Button variant="ghost" size="icon" className="size-8" disabled={pending} onClick={() => setEditing(item)}><PencilLine className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8 text-destructive" disabled={pending} onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
</div>
</div>
)
})}
{!loading && items.length === 0 && <EmptyState text="暂无签名" />}
</CardContent>
</Card>
<Dialog open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{editing && (
<form key={editing.id} className="space-y-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", editingMailboxId === "all" ? "" : editingMailboxId); form.set("isDefault", editingIsDefault ? "on" : ""); onUpdate(editing.id, form); setEditing(null) }}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="签名名称"><Input name="name" defaultValue={editing.name} required /></Field>
<Field label="绑定邮箱">
<MailboxSelect value={editingMailboxId} mailboxes={mailboxes} onChange={(value) => setEditing((current) => current ? { ...current, mailboxId: value === "all" ? "" : value } : current)} />
</Field>
</div>
<Field label="签名内容">
<Textarea name="content" required className="min-h-44" defaultValue={editing.content} />
</Field>
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox checked={editingIsDefault} onCheckedChange={(value) => setEditing((current) => current ? { ...current, isDefault: value === true } : current)} />
<span></span>
</label>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}></Button>
<Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div>
)
}
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 }) {
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
return (
@@ -773,6 +986,7 @@ function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxe
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
function EmptyState({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
function folderLabel(folder: string) { return ({ Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站" } as Record<string, string>)[folder] || folder }
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" }
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onBack }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
const displayName = cleanAccountName(name, email)
if (collapsed) return <div className="flex justify-center"><Avatar className="size-9 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback></Avatar></div>