feat(mail): 支持发件人名称展示与存储

- 为邮件消息新增 `fromName` 字段,并同步扩展数据库迁移、API 类型与前后端查询/入库逻辑。
- 解析入站邮件时提取并保存发件人名称,发信时将邮箱显示名写入 MIME `From` 头。
- 邮件列表、详情、通知与引用内容改为优先展示发件人名称,提升可读性。
This commit is contained in:
LanQin
2026-06-16 23:29:23 +08:00
parent 045132146b
commit 5821d9aa30
10 changed files with 135 additions and 37 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export type MailFolder = { id: string; name: string; role: string; unreadCount:
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
+18 -2
View File
@@ -430,7 +430,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
</TableCell>
<TableCell className="max-w-[220px] truncate">{message.from}</TableCell>
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to.join(", ")}</TableCell>
<TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell>
<TableCell className="text-muted-foreground">{formatDate(message.receivedAt)}</TableCell>
@@ -771,7 +771,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
<div className="grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2">
<MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} />
<MessageMeta label="所属用户" value={message.ownerEmail || ""} />
<MessageMeta label="发件人" value={message.from} />
<MessageMeta label="发件人" value={adminSenderTitle(message)} />
<MessageMeta label="收件人" value={message.recipientAddress || message.to.join(", ")} />
<MessageMeta label="文件夹" value={folderName(message.folder)} />
<MessageMeta label="时间" value={formatDate(message.receivedAt)} />
@@ -810,6 +810,22 @@ function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] || char)
}
function adminSenderDisplayName(message: MailMessage) {
const fromName = message.fromName?.trim()
if (fromName) return fromName
const text = message.from.trim()
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
const name = namedAddress?.[1]?.trim()
if (name) return name
const address = text.match(/<([^>]+)>/)?.[1]?.trim() || text
return address.split("@")[0]?.trim() || text || "未知发件人"
}
function adminSenderTitle(message: MailMessage) {
const name = message.fromName?.trim()
return name ? `${name} <${message.from}>` : message.from
}
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return <Card><CardContent className="flex items-center gap-4 p-5"><div className="grid h-10 w-10 place-items-center rounded-lg bg-muted text-foreground">{icon}</div><div><div className="text-2xl font-semibold tracking-tight">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
}
+31 -7
View File
@@ -272,8 +272,9 @@ export function MailPage() {
if (newMessages.length === 0) return
const first = newMessages[0]
const firstSender = senderDisplayName(first)
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}`
const description = newMessages.length > 1 ? `${first.from} 等发来新邮件` : `${first.from}${first.snippet ? ` · ${first.snippet}` : ""}`
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}`
toast({ title, description })
playIncomingMailSound(mailAudioContextRef)
if ("Notification" in window && Notification.permission === "granted") {
@@ -671,7 +672,7 @@ export function MailPage() {
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>
</div>
</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labelItems}
@@ -963,9 +964,9 @@ function CompactMessageDetail({
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(selected.from)}</AvatarFallback></Avatar>
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(senderDisplayName(selected), selected.from)}</AvatarFallback></Avatar>
<div className="min-w-0 text-sm">
<div className="truncate font-medium text-foreground">{selected.from}</div>
<div className="truncate font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</div>
<div className="truncate text-muted-foreground"> {selected.to.join(", ")}</div>
</div>
</div>
@@ -992,11 +993,12 @@ function CompactMessageDetail({
function CompactMessageRow({ message, active, checked, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) {
const visibleLabels = (message.labels || []).slice(0, 2)
const senderName = senderDisplayName(message)
return (
<div onClick={onClick} className={cn("grid cursor-pointer grid-cols-[32px_28px_minmax(140px,240px)_minmax(0,1fr)_88px_36px] items-center gap-2 border-b px-4 py-2 text-sm transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} />
<Mail className={cn("h-4 w-4", message.isRead ? "text-muted-foreground/70" : "fill-yellow-200 text-yellow-500")} />
<div className="truncate">{message.from}</div>
<div className="truncate" title={senderTitle(message)}>{senderName}</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{message.subject}</span>
<span className="min-w-0 truncate text-muted-foreground">{message.snippet}</span>
@@ -1118,6 +1120,27 @@ function accountInitial(name: string, email?: string) {
return (first || "蓝").toUpperCase()
}
function senderDisplayName(message: MailMessage) {
const fromName = message.fromName?.trim()
if (fromName) return fromName
return displayNameFromAddress(message.from)
}
function displayNameFromAddress(value: string) {
const text = value.trim()
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
const name = namedAddress?.[1]?.trim()
if (name) return name
const address = text.match(/<([^>]+)>/)?.[1]?.trim() || text
const localPart = address.split("@")[0]?.trim()
return localPart || text || "未知发件人"
}
function senderTitle(message: MailMessage) {
const name = message.fromName?.trim()
return name ? `${name} <${message.from}>` : message.from
}
function MessageRow({
message,
active,
@@ -1135,6 +1158,7 @@ function MessageRow({
}) {
const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
const senderName = senderDisplayName(message)
return <div onClick={onClick} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="flex gap-3">
<Checkbox
@@ -1146,7 +1170,7 @@ function MessageRow({
/>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm">{message.from}</div>
<div className="min-w-0 truncate text-sm" title={senderTitle(message)}>{senderName}</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
@@ -1452,7 +1476,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
}
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
async function fileToAttachment(file: File) {