From f0c175e7f6385900de845770c4c9f03761346a61 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Fri, 19 Jun 2026 21:27:05 +0800 Subject: [PATCH 1/5] 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({ From d312076d4ba1f314bca2f58aebb179bf92ce3fc7 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Fri, 19 Jun 2026 22:15:00 +0800 Subject: [PATCH 2/5] chore: handle more encodes & fix idx range --- apps/api/go.mod | 3 +- apps/api/go.sum | 4 ++ apps/api/internal/app/maildir_sync.go | 73 ++++++++++++++++----------- apps/web/src/lib/utils.ts | 56 ++++++++++++++++++++ apps/web/src/pages/admin.tsx | 26 +--------- apps/web/src/pages/mail.tsx | 27 +--------- 6 files changed, 108 insertions(+), 81 deletions(-) diff --git a/apps/api/go.mod b/apps/api/go.mod index 844d823..15bc769 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,6 +1,6 @@ module lanqin-email-api -go 1.22 +go 1.25.0 require ( github.com/go-chi/chi/v5 v5.1.0 @@ -20,6 +20,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.38.0 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index 428dc1b..2be94ba 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -26,13 +26,17 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 148907b..e6f173d 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -17,6 +17,9 @@ import ( "path/filepath" "strings" "time" + + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/ianaindex" ) type maildirMailbox struct { @@ -350,8 +353,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, if err != nil { return storedMessage{}, nil, err } - decoder := new(mime.WordDecoder) - subject, _ := decoder.DecodeHeader(m.Header.Get("Subject")) + subject := decodeMIMEHeader(m.Header.Get("Subject")) if strings.TrimSpace(subject) == "" { subject = "(no subject)" } @@ -460,42 +462,31 @@ func transferReader(encoding string, r io.Reader) io.Reader { } func partFilename(header textproto.MIMEHeader) string { - decoder := new(mime.WordDecoder) if _, params, err := mime.ParseMediaType(header.Get("Content-Disposition")); err == nil { if name := strings.TrimSpace(params["filename"]); name != "" { - decoded, _ := decoder.DecodeHeader(name) - if decoded != "" { - name = decoded - } - return filepath.Base(name) + return filepath.Base(decodeMIMEHeader(name)) } } if _, params, err := mime.ParseMediaType(header.Get("Content-Type")); err == nil { if name := strings.TrimSpace(params["name"]); name != "" { - decoded, _ := decoder.DecodeHeader(name) - if decoded != "" { - name = decoded - } - return filepath.Base(name) + return filepath.Base(decodeMIMEHeader(name)) } } return "" } func firstAddressParts(value string) (string, string) { - items, err := netmail.ParseAddressList(value) + // Proactively decode RFC 2047 encoded words before parsing, so that + // non-UTF-8 charsets (e.g. GBK, Shift_JIS) are handled by our + // CharsetReader instead of Go's default WordDecoder which only + // supports UTF-8 and ISO-8859-1. + decoded := decodeMIMEHeader(value) + items, err := netmail.ParseAddressList(decoded) if err != nil || len(items) == 0 { - // 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) - } + // Still unparseable: return the decoded value 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) @@ -503,11 +494,14 @@ func firstAddressParts(value string) (string, string) { // decodeMIMEHeader decodes all RFC 2047 encoded words (=?charset?encoding?data?=) // in the given header value. Falls back to the original value on any error. +// Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via x/text. func decodeMIMEHeader(value string) string { if !strings.Contains(value, "=?") { return value } - decoder := new(mime.WordDecoder) + decoder := &mime.WordDecoder{ + CharsetReader: charsetReader, + } decoded, err := decoder.DecodeHeader(value) if err != nil { return value @@ -515,6 +509,22 @@ func decodeMIMEHeader(value string) string { return decoded } +// charsetReader converts a non-UTF-8 charset stream into UTF-8 using x/text encodings. +func charsetReader(charset string, input io.Reader) (io.Reader, error) { + charset = strings.ToLower(strings.TrimSpace(charset)) + if charset == "utf-8" || charset == "us-ascii" { + return input, nil + } + enc, err := ianaindex.IANA.Encoding(charset) + if err != nil { + return nil, fmt.Errorf("unsupported charset %q: %w", charset, err) + } + if enc == encoding.Nop || enc == encoding.Replacement { + return nil, fmt.Errorf("unsupported charset %q", charset) + } + return enc.NewDecoder().Reader(input), nil +} + // 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) { @@ -523,7 +533,7 @@ func splitNameAndEmail(value string) (string, string) { return "", "" } // Try "Name " pattern - if idx := strings.LastIndex(value, "<"); idx > 0 { + if idx := strings.LastIndex(value, "<"); idx >= 0 { email := strings.TrimRight(value[idx+1:], ">") name := strings.TrimSpace(strings.Trim(value[:idx], `" `)) if strings.Contains(email, "@") { @@ -539,8 +549,13 @@ func splitNameAndEmail(value string) (string, string) { func addressList(value string) []string { items, err := netmail.ParseAddressList(value) - if err != nil { - return nil + if err != nil || len(items) == 0 { + // ParseAddressList failed — attempt RFC 2047 decode, then retry. + decoded := decodeMIMEHeader(value) + items, err = netmail.ParseAddressList(decoded) + if err != nil || len(items) == 0 { + return nil + } } out := make([]string, 0, len(items)) for _, item := range items { diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 2bdbcfe..33f6506 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -25,3 +25,59 @@ export function formatBytes(bytes: number) { while (size >= 1024 && idx < units.length - 1) { size /= 1024; idx++ } return `${size.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}` } + +/** + * Normalize charset aliases to canonical names supported by the browser's TextDecoder. + * Only contains aliases that differ from their canonical name — standard names like + * "gb18030", "big5", "euc-kr" are passed through directly to TextDecoder. + */ +function normalizeCharset(charset: string): string { + const c = charset.toLowerCase().trim() + const aliases: Record = { + "gb2312": "gbk", + "x-gbk": "gbk", + "euc-cn": "gbk", + "hz-gb-2312": "gbk", + "shift-jis": "shift_jis", + "sjis": "shift_jis", + "windows-31j": "shift_jis", + "ks_c_5601-1987": "euc-kr", + "ksc5601": "euc-kr", + "windows-949": "euc-kr", + "iso-8859-1": "windows-1252", + } + const mapped = aliases[c] + if (mapped) return mapped + // cpXXX / cpXXX windows code pages: cp936→windows-936, cp943→windows-943, etc. + if (/^cp\d+$/.test(c)) return "windows-" + c.slice(2) + return c +} + +/** + * Decode RFC 2047 encoded words in mail headers (e.g. =?UTF-8?B?5byA5ZSu?=). + * Handles Base64 (B) and Quoted-Printable (Q) encoding. + * Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via charset alias normalization. + * Returns the original string unchanged if no encoded words are found or on error. + */ +export 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") { + const sanitized = encoded.replace(/\s+/g, "") + const padded = sanitized + "=".repeat((4 - (sanitized.length % 4)) % 4) + decoded = atob(padded) + } 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 normalized = normalizeCharset(charset) || "utf-8" + const decoder = new TextDecoder(normalized) + return decoder.decode(bytes) + } catch { + return _match + } + }) +} diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 3ce66a2..cb23b0b 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from "react-router-dom" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { ArrowRight, CheckCircle2, Circle, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api" -import { cn, formatBytes, formatDate } from "@/lib/utils" +import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -810,30 +810,6 @@ 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 = decodeMimeHeader(message.fromName?.trim() || "") if (fromName) return fromName diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 6210bf5..c8e7ad6 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -14,7 +14,7 @@ import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api" -import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils" +import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" import { Button } from "@/components/ui/button" @@ -1293,31 +1293,6 @@ 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] || "用户" From 5604b59501dd048b044cbf9652e584defc04762d Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Fri, 19 Jun 2026 22:49:20 +0800 Subject: [PATCH 3/5] fix: improve RFC 2047 decode & fix trim space --- apps/api/internal/app/maildir_sync.go | 44 ++++++++++++++++++--------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index e6f173d..d42edfb 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -15,6 +15,7 @@ import ( "net/textproto" "os" "path/filepath" + "regexp" "strings" "time" @@ -476,39 +477,54 @@ func partFilename(header textproto.MIMEHeader) string { } func firstAddressParts(value string) (string, string) { - // Proactively decode RFC 2047 encoded words before parsing, so that - // non-UTF-8 charsets (e.g. GBK, Shift_JIS) are handled by our - // CharsetReader instead of Go's default WordDecoder which only - // supports UTF-8 and ISO-8859-1. - decoded := decodeMIMEHeader(value) - items, err := netmail.ParseAddressList(decoded) + items, err := netmail.ParseAddressList(value) if err != nil || len(items) == 0 { - // Still unparseable: return the decoded value and try to extract - // a display name from the decoded string. - email, name := splitNameAndEmail(decoded) - return normalizeEmail(email), strings.TrimSpace(name) + // 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 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) + // Decode item.Name individually so that non-UTF-8 charsets (e.g. GBK, + // Shift_JIS) are handled by our CharsetReader, while the address list + // structure is parsed from the raw header (avoiding commas/semicolons + // inside decoded display names breaking the parser). + return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(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. // Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via x/text. +// Per RFC 2047 §6.2, linear whitespace between adjacent encoded words is +// stripped before decoding. func decodeMIMEHeader(value string) string { if !strings.Contains(value, "=?") { return value } + // RFC 2047 §6.2: ignore whitespace between adjacent encoded words. + collapsed := adjacentEncodedWordSpaceRe.ReplaceAllString(value, "$1$2") decoder := &mime.WordDecoder{ CharsetReader: charsetReader, } - decoded, err := decoder.DecodeHeader(value) + decoded, err := decoder.DecodeHeader(collapsed) if err != nil { return value } return decoded } +// adjacentEncodedWordSpaceRe matches whitespace between two adjacent RFC 2047 +// encoded words. Per RFC 2047 §6.2, this whitespace must be ignored when +// displaying the header. +var adjacentEncodedWordSpaceRe = regexp.MustCompile(`(\?=)\s+(=\?)`) + // charsetReader converts a non-UTF-8 charset stream into UTF-8 using x/text encodings. func charsetReader(charset string, input io.Reader) (io.Reader, error) { charset = strings.ToLower(strings.TrimSpace(charset)) @@ -519,7 +535,7 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) { if err != nil { return nil, fmt.Errorf("unsupported charset %q: %w", charset, err) } - if enc == encoding.Nop || enc == encoding.Replacement { + if enc == nil || enc == encoding.Nop || enc == encoding.Replacement { return nil, fmt.Errorf("unsupported charset %q", charset) } return enc.NewDecoder().Reader(input), nil @@ -534,7 +550,7 @@ func splitNameAndEmail(value string) (string, string) { } // Try "Name " pattern if idx := strings.LastIndex(value, "<"); idx >= 0 { - email := strings.TrimRight(value[idx+1:], ">") + email := strings.TrimSpace(strings.Trim(value[idx+1:], "> ")) name := strings.TrimSpace(strings.Trim(value[:idx], `" `)) if strings.Contains(email, "@") { return email, name From c3a66683eedfb69b35ce345b0c5b72250abab83d Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Fri, 19 Jun 2026 23:12:20 +0800 Subject: [PATCH 4/5] chore: linear whitespace between adjacent encoded words is stripped --- apps/web/src/lib/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 33f6506..c1002be 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -57,11 +57,14 @@ function normalizeCharset(charset: string): string { * Decode RFC 2047 encoded words in mail headers (e.g. =?UTF-8?B?5byA5ZSu?=). * Handles Base64 (B) and Quoted-Printable (Q) encoding. * Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via charset alias normalization. + * Per RFC 2047 §6.2, linear whitespace between adjacent encoded words is stripped. * Returns the original string unchanged if no encoded words are found or on error. */ export function decodeMimeHeader(value: string): string { if (!value || !value.includes("=?")) return value - return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => { + // RFC 2047 §6.2: ignore whitespace between adjacent encoded words. + const collapsed = value.replace(/(\?=)\s+(=\?)/g, "$1$2") + return collapsed.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => { try { const lowerEncoding = String(encoding).toLowerCase() let decoded: string From b147be37532d3224f0655ca516bed3354774c0ff Mon Sep 17 00:00:00 2001 From: AnserJim <81072191+killerprojecte@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:18:22 +0800 Subject: [PATCH 5/5] fix: revert golang version Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/api/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/go.mod b/apps/api/go.mod index 15bc769..d0c6033 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,6 +1,6 @@ module lanqin-email-api -go 1.25.0 +go 1.22 require ( github.com/go-chi/chi/v5 v5.1.0