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 != "" { 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 + "%" 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) 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 FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_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, message_id TEXT NOT NULL,
subject TEXT NOT NULL, subject TEXT NOT NULL,
from_addr TEXT NOT NULL, from_addr TEXT NOT NULL,
from_name TEXT NOT NULL DEFAULT '',
to_addrs TEXT NOT NULL, to_addrs TEXT NOT NULL,
cc_addrs TEXT NOT NULL DEFAULT '[]', cc_addrs TEXT NOT NULL DEFAULT '[]',
bcc_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 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_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_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 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 ( `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 { if err := a.migrateMessagesForUnregistered(ctx); err != nil {
return err return err
} }
if err := a.migrateMessagesFromName(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil { if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err return err
} }
@@ -568,6 +572,7 @@ func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
message_id TEXT NOT NULL, message_id TEXT NOT NULL,
subject TEXT NOT NULL, subject TEXT NOT NULL,
from_addr TEXT NOT NULL, from_addr TEXT NOT NULL,
from_name TEXT NOT NULL DEFAULT '',
to_addrs TEXT NOT NULL, to_addrs TEXT NOT NULL,
cc_addrs TEXT NOT NULL DEFAULT '[]', cc_addrs TEXT NOT NULL DEFAULT '[]',
bcc_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 { )`); err != nil {
return err 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) 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 { 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 return err
} }
if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil { if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil {
@@ -607,10 +612,47 @@ func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
return nil 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 { func messageIndexes() []string {
return []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_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_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 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")), MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
Subject: subject, Subject: subject,
From: "system@lanqin.local", From: "system@lanqin.local",
FromName: "LanQin Email",
To: []string{a.cfg.AdminEmail}, To: []string{a.cfg.AdminEmail},
SentAt: now, SentAt: now,
ReceivedAt: now, ReceivedAt: now,
+12 -11
View File
@@ -31,6 +31,7 @@ type storedMessage struct {
MessageID string MessageID string
Subject string Subject string
From string From string
FromName string
To []string To []string
CC []string CC []string
BCC []string BCC []string
@@ -144,12 +145,12 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
limit := 30 limit := 30
if q != "" { 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 + "%" like := "%" + q + "%"
args = append(args, like, like, like, like) args = append(args, like, like, like, like, like)
} }
args = append(args, limit+1, offset) 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 ?` 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...) rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil { if err != nil {
@@ -322,7 +323,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
now := a.now().UTC() now := a.now().UTC()
messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1]) messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1])
mimeBytes, err := BuildMIME(MIMEMessage{ 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 { if err != nil {
badRequest(w, err) 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") respondError(w, http.StatusInternalServerError, "failed to load sent folder")
return 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) sentID, err := a.insertMessage(r.Context(), base, req.Attachments)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to store sent message") 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) { 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) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody) msg, err := scanMessageFull(row, includeBody)
if err != nil { if err != nil {
@@ -656,8 +657,8 @@ func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments
folderID = msg.FolderID folderID = msg.FolderID
} }
recipientAddr := normalizeEmail(msg.RecipientAddr) 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) _, 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, 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) 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 { if err != nil {
return "", err return "", err
} }
@@ -867,7 +868,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int 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 { if err != nil {
return msg, err return msg, err
} }
@@ -881,7 +882,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int 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 { if err != nil {
return msg, err return msg, err
} }
@@ -896,7 +897,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage,
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
var bodyText, bodyHTML string 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 { if err != nil {
return msg, err return msg, err
} }
+8 -6
View File
@@ -354,7 +354,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if strings.TrimSpace(subject) == "" { if strings.TrimSpace(subject) == "" {
subject = "(no subject)" subject = "(no subject)"
} }
from := firstAddress(m.Header.Get("From")) from, fromName := firstAddressParts(m.Header.Get("From"))
to := addressList(m.Header.Get("To")) to := addressList(m.Header.Get("To"))
cc := addressList(m.Header.Get("Cc")) cc := addressList(m.Header.Get("Cc"))
if len(to) == 0 { 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")), MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject, Subject: subject,
From: from, From: from,
FromName: fromName,
To: to, To: to,
CC: cc, CC: cc,
SentAt: sentAt, SentAt: sentAt,
@@ -480,12 +481,13 @@ func partFilename(header textproto.MIMEHeader) string {
return "" return ""
} }
func firstAddress(value string) string { func firstAddressParts(value string) (string, string) {
items := addressList(value) items, err := netmail.ParseAddressList(value)
if len(items) == 0 { if err != nil || len(items) == 0 {
return strings.TrimSpace(value) return strings.TrimSpace(value), ""
} }
return items[0] item := items[0]
return normalizeEmail(item.Address), strings.TrimSpace(item.Name)
} }
func addressList(value string) []string { func addressList(value string) []string {
+12 -1
View File
@@ -10,6 +10,7 @@ import (
"mime" "mime"
"mime/multipart" "mime/multipart"
"net" "net"
netmail "net/mail"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
"strings" "strings"
@@ -20,6 +21,7 @@ const smtpSessionTimeout = 45 * time.Second
type MIMEMessage struct { type MIMEMessage struct {
From string From string
FromName string
To []string To []string
CC []string CC []string
BCC []string BCC []string
@@ -38,7 +40,7 @@ func BuildMIME(m MIMEMessage) ([]byte, error) {
fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 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("To", strings.Join(m.To, ", "))
writeHeader("Cc", strings.Join(m.CC, ", ")) writeHeader("Cc", strings.Join(m.CC, ", "))
writeHeader("Subject", mime.QEncoding.Encode("utf-8", m.Subject)) writeHeader("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
@@ -104,6 +106,15 @@ func BuildMIME(m MIMEMessage) ([]byte, error) {
return buf.Bytes(), nil 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 { func textprotoMIMEHeader(values map[string]string) textproto.MIMEHeader {
h := textproto.MIMEHeader{} h := textproto.MIMEHeader{}
for k, v := range values { 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() rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject} 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 { for _, rule := range rules {
if !ruleMatches(rule, msg) { if !ruleMatches(rule, msg) {
continue continue
@@ -1128,7 +1128,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?` where += ` AND m.mailbox_id=?`
args = append(args, mailboxID) 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 { if err != nil {
return 0, err return 0, err
} }
+1
View File
@@ -79,6 +79,7 @@ type MailMessage struct {
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
Subject string `json:"subject"` Subject string `json:"subject"`
From string `json:"from"` From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"` To []string `json:"to"`
CC []string `json:"cc"` CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"` 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 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 MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = { 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[] labels?: MailLabel[]
} }
export type DNSRecord = { type: string; name: string; value: string; ttl: number } 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> <div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>} {message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
</TableCell> </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 className="max-w-[220px] truncate">{message.recipientAddress || message.to.join(", ")}</TableCell>
<TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell> <TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell>
<TableCell className="text-muted-foreground">{formatDate(message.receivedAt)}</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"> <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.mailboxAddress || message.recipientAddress || ""} />
<MessageMeta label="所属用户" value={message.ownerEmail || ""} /> <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={message.recipientAddress || message.to.join(", ")} />
<MessageMeta label="文件夹" value={folderName(message.folder)} /> <MessageMeta label="文件夹" value={folderName(message.folder)} />
<MessageMeta label="时间" value={formatDate(message.receivedAt)} /> <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) 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 }) { 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> 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 if (newMessages.length === 0) return
const first = newMessages[0] const first = newMessages[0]
const firstSender = senderDisplayName(first)
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` 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 }) toast({ title, description })
playIncomingMailSound(mailAudioContextRef) playIncomingMailSound(mailAudioContextRef)
if ("Notification" in window && Notification.permission === "granted") { if ("Notification" in window && Notification.permission === "granted") {
@@ -671,7 +672,7 @@ export function MailPage() {
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button> <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>
</div> </div>
</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
messageLabels={selected.labels || []} messageLabels={selected.labels || []}
availableLabels={labelItems} availableLabels={labelItems}
@@ -963,9 +964,9 @@ function CompactMessageDetail({
</div> </div>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3"> <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="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 className="truncate text-muted-foreground"> {selected.to.join(", ")}</div>
</div> </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 }) { 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 visibleLabels = (message.labels || []).slice(0, 2)
const senderName = senderDisplayName(message)
return ( 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")}> <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()} /> <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")} /> <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"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{message.subject}</span> <span className="truncate font-medium">{message.subject}</span>
<span className="min-w-0 truncate text-muted-foreground">{message.snippet}</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() 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({ function MessageRow({
message, message,
active, active,
@@ -1135,6 +1158,7 @@ function MessageRow({
}) { }) {
const visibleLabels = (message.labels || []).slice(0, 2) const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) 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")}> 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"> <div className="flex gap-3">
<Checkbox <Checkbox
@@ -1146,7 +1170,7 @@ function MessageRow({
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between gap-2"> <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"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
type="button" type="button"
@@ -1452,7 +1476,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
function quoteMessage(message: MailMessage) { function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n") 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 || "" } function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
async function fileToAttachment(file: File) { async function fileToAttachment(file: File) {