diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 5026fac..ff2601b 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -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 diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 76b0d6f..cd2661c 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -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, ¬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 { 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, diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index da13a9b..9078f23 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -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 } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 81a0b92..b2171fa 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -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 { diff --git a/apps/api/internal/app/mime.go b/apps/api/internal/app/mime.go index a479579..c9859de 100644 --- a/apps/api/internal/app/mime.go +++ b/apps/api/internal/app/mime.go @@ -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 { diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index ce48561..ca87279 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -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 } diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index f7344a8..6cae86b 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -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"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index f6204fb..ced3e2d 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -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 } diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 0b1b0bc..0b5cd9e 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -430,7 +430,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
{message.mailboxAddress || message.recipientAddress || "-"}
{message.ownerEmail &&
{message.ownerEmail}
} - {message.from} + {adminSenderDisplayName(message)} {message.recipientAddress || message.to.join(", ")} {folderName(message.folder)} {formatDate(message.receivedAt)} @@ -771,7 +771,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
- + @@ -810,6 +810,22 @@ function escapeHtml(value: string) { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] || char) } +function adminSenderDisplayName(message: MailMessage) { + const fromName = message.fromName?.trim() + if (fromName) return fromName + const text = message.from.trim() + const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) + const name = namedAddress?.[1]?.trim() + if (name) return name + const address = text.match(/<([^>]+)>/)?.[1]?.trim() || text + return address.split("@")[0]?.trim() || text || "未知发件人" +} + +function adminSenderTitle(message: MailMessage) { + const name = message.fromName?.trim() + return name ? `${name} <${message.from}>` : message.from +} + function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { return
{icon}
{value}
{label}
} diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index d80b59a..f38e428 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -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() {
-
{selected.from} 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}
+
{senderDisplayName(selected)} 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}
- {accountInitial(selected.from)} + {accountInitial(senderDisplayName(selected), selected.from)}
-
{selected.from}
+
{senderDisplayName(selected)}
收件人 {selected.to.join(", ")}
@@ -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 (
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} /> -
{message.from}
+
{senderName}
{message.subject} {message.snippet} @@ -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
-
{message.from}
+
{senderName}