From f0c175e7f6385900de845770c4c9f03761346a61 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Fri, 19 Jun 2026 21:27:05 +0800 Subject: [PATCH] fix: decode RFC 2047 encoded words in mail sender display name --- apps/api/internal/app/maildir_sync.go | 48 ++++++++++++++++++++++++++- apps/web/src/pages/admin.tsx | 33 +++++++++++++++--- apps/web/src/pages/mail.tsx | 34 ++++++++++++++++--- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 8127f70..148907b 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -485,12 +485,58 @@ func partFilename(header textproto.MIMEHeader) string { func firstAddressParts(value string) (string, string) { items, err := netmail.ParseAddressList(value) if err != nil || len(items) == 0 { - return strings.TrimSpace(value), "" + // ParseAddressList failed — attempt RFC 2047 decode on the raw header, + // then retry parsing. This handles non-standard From headers where + // encoded words (e.g. =?UTF-8?B?…?=) cause the initial parse to fail. + decoded := decodeMIMEHeader(value) + items, err = netmail.ParseAddressList(decoded) + if err != nil || len(items) == 0 { + // Still unparseable: return the decoded value as the email and + // try to extract a display name from the decoded string. + email, name := splitNameAndEmail(decoded) + return normalizeEmail(email), strings.TrimSpace(name) + } } item := items[0] return normalizeEmail(item.Address), strings.TrimSpace(item.Name) } +// decodeMIMEHeader decodes all RFC 2047 encoded words (=?charset?encoding?data?=) +// in the given header value. Falls back to the original value on any error. +func decodeMIMEHeader(value string) string { + if !strings.Contains(value, "=?") { + return value + } + decoder := new(mime.WordDecoder) + decoded, err := decoder.DecodeHeader(value) + if err != nil { + return value + } + return decoded +} + +// splitNameAndEmail attempts to extract a display name and email address from +// a string like "Display Name " or plain "user@example.com". +func splitNameAndEmail(value string) (string, string) { + value = strings.TrimSpace(value) + if value == "" { + return "", "" + } + // Try "Name " pattern + if idx := strings.LastIndex(value, "<"); idx > 0 { + email := strings.TrimRight(value[idx+1:], ">") + name := strings.TrimSpace(strings.Trim(value[:idx], `" `)) + if strings.Contains(email, "@") { + return email, name + } + } + // Plain email or unknown format + if strings.Contains(value, "@") { + return value, "" + } + return value, "" +} + func addressList(value string) []string { items, err := netmail.ParseAddressList(value) if err != nil { diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 0b5cd9e..3ce66a2 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -810,10 +810,34 @@ function escapeHtml(value: string) { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] || char) } +/** + * Decode RFC 2047 encoded words in mail headers (e.g. =?UTF-8?B?5byA5ZSu?=). + * Handles Base64 (B) and Quoted-Printable (Q) encoding. + */ +function decodeMimeHeader(value: string): string { + if (!value || !value.includes("=?")) return value + return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => { + try { + const lowerEncoding = String(encoding).toLowerCase() + let decoded: string + if (lowerEncoding === "b") { + decoded = atob(encoded) + } else { + decoded = encoded.replace(/_/g, " ").replace(/=([0-9a-fA-F]{2})/g, (_m: string, hex: string) => String.fromCharCode(parseInt(hex, 16))) + } + const bytes = new Uint8Array(Array.from(decoded, (ch) => ch.charCodeAt(0))) + const decoder = new TextDecoder(String(charset).toLowerCase() || "utf-8") + return decoder.decode(bytes) + } catch { + return _match + } + }) +} + function adminSenderDisplayName(message: MailMessage) { - const fromName = message.fromName?.trim() + const fromName = decodeMimeHeader(message.fromName?.trim() || "") if (fromName) return fromName - const text = message.from.trim() + const text = decodeMimeHeader(message.from.trim()) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) const name = namedAddress?.[1]?.trim() if (name) return name @@ -822,8 +846,9 @@ function adminSenderDisplayName(message: MailMessage) { } function adminSenderTitle(message: MailMessage) { - const name = message.fromName?.trim() - return name ? `${name} <${message.from}>` : message.from + const name = decodeMimeHeader(message.fromName?.trim() || "") + const from = decodeMimeHeader(message.from) + return name ? `${name} <${from}>` : from } function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 399a0e6..6210bf5 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -1293,6 +1293,31 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailbox, onSelect }: { ) } +/** + * Decode RFC 2047 encoded words in mail headers (e.g. =?UTF-8?B?5byA5ZSu?=). + * Handles Base64 (B) and Quoted-Printable (Q) encoding. + * Returns the original string unchanged if no encoded words are found or on error. + */ +function decodeMimeHeader(value: string): string { + if (!value || !value.includes("=?")) return value + return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => { + try { + const lowerEncoding = String(encoding).toLowerCase() + let decoded: string + if (lowerEncoding === "b") { + decoded = atob(encoded) + } else { + decoded = encoded.replace(/_/g, " ").replace(/=([0-9a-fA-F]{2})/g, (_m: string, hex: string) => String.fromCharCode(parseInt(hex, 16))) + } + const bytes = new Uint8Array(Array.from(decoded, (ch) => ch.charCodeAt(0))) + const decoder = new TextDecoder(String(charset).toLowerCase() || "utf-8") + return decoder.decode(bytes) + } catch { + return _match + } + }) +} + function cleanAccountName(name: string, email?: string) { const value = name.trim() if (!value || (email && value.toLowerCase() === email.toLowerCase())) return email?.split("@")[0] || "用户" @@ -1306,13 +1331,13 @@ function accountInitial(name: string, email?: string) { } function senderDisplayName(message: MailMessage) { - const fromName = message.fromName?.trim() + const fromName = decodeMimeHeader(message.fromName?.trim() || "") if (fromName) return fromName return displayNameFromAddress(message.from) } function displayNameFromAddress(value: string) { - const text = value.trim() + const text = decodeMimeHeader(value.trim()) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) const name = namedAddress?.[1]?.trim() if (name) return name @@ -1322,8 +1347,9 @@ function displayNameFromAddress(value: string) { } function senderTitle(message: MailMessage) { - const name = message.fromName?.trim() - return name ? `${name} <${message.from}>` : message.from + const name = decodeMimeHeader(message.fromName?.trim() || "") + const from = decodeMimeHeader(message.from) + return name ? `${name} <${from}>` : from } function MessageRow({