feat(mail): 支持发件人名称展示与存储
- 为邮件消息新增 `fromName` 字段,并同步扩展数据库迁移、API 类型与前后端查询/入库逻辑。 - 解析入站邮件时提取并保存发件人名称,发信时将邮箱显示名写入 MIME `From` 头。 - 邮件列表、详情、通知与引用内容改为优先展示发件人名称,提升可读性。
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user