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
+3 -3
View File
@@ -657,13 +657,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
}
}
if q != "" {
where = append(where, "(m.subject LIKE ? OR m.from_addr LIKE ? OR m.to_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ? OR mb.address LIKE ? OR u.email LIKE ?)")
where = append(where, "(m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ? OR mb.address LIKE ? OR u.email LIKE ?)")
like := "%" + q + "%"
args = append(args, like, like, like, like, like, like, like, like)
args = append(args, like, like, like, like, like, like, like, like, like)
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
+47 -4
View File
@@ -193,6 +193,7 @@ func (a *App) migrate(ctx context.Context) error {
message_id TEXT NOT NULL,
subject TEXT NOT NULL,
from_addr TEXT NOT NULL,
from_name TEXT NOT NULL DEFAULT '',
to_addrs TEXT NOT NULL,
cc_addrs TEXT NOT NULL DEFAULT '[]',
bcc_addrs TEXT NOT NULL DEFAULT '[]',
@@ -210,7 +211,7 @@ func (a *App) migrate(ctx context.Context) error {
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
`CREATE TABLE IF NOT EXISTS attachments (
@@ -299,6 +300,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateMessagesForUnregistered(ctx); err != nil {
return err
}
if err := a.migrateMessagesFromName(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err
}
@@ -568,6 +572,7 @@ func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
message_id TEXT NOT NULL,
subject TEXT NOT NULL,
from_addr TEXT NOT NULL,
from_name TEXT NOT NULL DEFAULT '',
to_addrs TEXT NOT NULL,
cc_addrs TEXT NOT NULL DEFAULT '[]',
bcc_addrs TEXT NOT NULL DEFAULT '[]',
@@ -586,8 +591,8 @@ func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
)`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO messages_new(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
SELECT id,mailbox_id,folder_id,'',message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at FROM messages`); err != nil {
if _, err := tx.ExecContext(ctx, `INSERT INTO messages_new(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
SELECT id,mailbox_id,folder_id,'',message_uid,message_id,subject,from_addr,'',to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at FROM messages`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil {
@@ -607,10 +612,47 @@ func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
return nil
}
func (a *App) migrateMessagesFromName(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
return err
}
defer rows.Close()
hasFromName := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
if name == "from_name" {
hasFromName = true
}
}
if err := rows.Err(); err != nil {
return err
}
if !hasFromName {
if _, err := a.db.ExecContext(ctx, `ALTER TABLE messages ADD COLUMN from_name TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
if _, err := a.db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_messages_search`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`); err != nil {
return err
}
return nil
}
func messageIndexes() []string {
return []string{
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
}
@@ -809,6 +851,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
Subject: subject,
From: "system@lanqin.local",
FromName: "LanQin Email",
To: []string{a.cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
+12 -11
View File
@@ -31,6 +31,7 @@ type storedMessage struct {
MessageID string
Subject string
From string
FromName string
To []string
CC []string
BCC []string
@@ -144,12 +145,12 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
limit := 30
if q != "" {
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
like := "%" + q + "%"
args = append(args, like, like, like, like)
args = append(args, like, like, like, like, like)
}
args = append(args, limit+1, offset)
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?`
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
@@ -322,7 +323,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
now := a.now().UTC()
messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1])
mimeBytes, err := BuildMIME(MIMEMessage{
From: mb.Address, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, MessageID: messageID, Date: now, Attachments: req.Attachments,
From: mb.Address, FromName: mb.DisplayName, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, MessageID: messageID, Date: now, Attachments: req.Attachments,
})
if err != nil {
badRequest(w, err)
@@ -340,7 +341,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load sent folder")
return
}
base := storedMessage{MailboxID: mb.ID, FolderID: sentFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: req.Subject, From: mb.Address, To: req.To, CC: req.CC, BCC: req.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(req.Text, req.HTML), BodyText: req.Text, BodyHTML: req.HTML, IsRead: true}
base := storedMessage{MailboxID: mb.ID, FolderID: sentFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: req.Subject, From: mb.Address, FromName: mb.DisplayName, To: req.To, CC: req.CC, BCC: req.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(req.Text, req.HTML), BodyText: req.Text, BodyHTML: req.HTML, IsRead: true}
sentID, err := a.insertMessage(r.Context(), base, req.Attachments)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to store sent message")
@@ -617,7 +618,7 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool
}
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody)
if err != nil {
@@ -656,8 +657,8 @@ func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments
folderID = msg.FolderID
}
recipientAddr := normalizeEmail(msg.RecipientAddr)
_, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
_, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
if err != nil {
return "", err
}
@@ -867,7 +868,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil {
return msg, err
}
@@ -881,7 +882,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil {
return msg, err
}
@@ -896,7 +897,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage,
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int
var bodyText, bodyHTML string
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil {
return msg, err
}
+8 -6
View File
@@ -354,7 +354,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if strings.TrimSpace(subject) == "" {
subject = "(no subject)"
}
from := firstAddress(m.Header.Get("From"))
from, fromName := firstAddressParts(m.Header.Get("From"))
to := addressList(m.Header.Get("To"))
cc := addressList(m.Header.Get("Cc"))
if len(to) == 0 {
@@ -382,6 +382,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
@@ -480,12 +481,13 @@ func partFilename(header textproto.MIMEHeader) string {
return ""
}
func firstAddress(value string) string {
items := addressList(value)
if len(items) == 0 {
return strings.TrimSpace(value)
func firstAddressParts(value string) (string, string) {
items, err := netmail.ParseAddressList(value)
if err != nil || len(items) == 0 {
return strings.TrimSpace(value), ""
}
return items[0]
item := items[0]
return normalizeEmail(item.Address), strings.TrimSpace(item.Name)
}
func addressList(value string) []string {
+12 -1
View File
@@ -10,6 +10,7 @@ import (
"mime"
"mime/multipart"
"net"
netmail "net/mail"
"net/smtp"
"net/textproto"
"strings"
@@ -20,6 +21,7 @@ const smtpSessionTimeout = 45 * time.Second
type MIMEMessage struct {
From string
FromName string
To []string
CC []string
BCC []string
@@ -38,7 +40,7 @@ func BuildMIME(m MIMEMessage) ([]byte, error) {
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
}
}
writeHeader("From", m.From)
writeHeader("From", formatAddressHeader(m.FromName, m.From))
writeHeader("To", strings.Join(m.To, ", "))
writeHeader("Cc", strings.Join(m.CC, ", "))
writeHeader("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
@@ -104,6 +106,15 @@ func BuildMIME(m MIMEMessage) ([]byte, error) {
return buf.Bytes(), nil
}
func formatAddressHeader(name, address string) string {
address = strings.TrimSpace(address)
name = strings.TrimSpace(name)
if address == "" || name == "" || strings.EqualFold(name, address) {
return address
}
return (&netmail.Address{Name: name, Address: address}).String()
}
func textprotoMIMEHeader(values map[string]string) textproto.MIMEHeader {
h := textproto.MIMEHeader{}
for k, v := range values {
+2 -2
View File
@@ -878,7 +878,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
}
rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
_ = a.db.QueryRowContext(ctx, `SELECT from_addr,to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
for _, rule := range rules {
if !ruleMatches(rule, msg) {
continue
@@ -1128,7 +1128,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,m.from_addr,m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
if err != nil {
return 0, err
}
+1
View File
@@ -79,6 +79,7 @@ type MailMessage struct {
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
+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) {