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