收邮件这件事,协议层(SMTP 收信)其实没多难,真正磨人的是拿到原始报文之后:标题是一串 =?GBK?B?xPq1...?=,正文是一堆 =C4=FA=B5=C4,同一封信里 HTML 和纯文本各一份,外面还可能再套一层附件。
这篇用 Go 标准库把一封"问题邮件"从原始报文解到可读文本,顺带把几个容易踩的坑讲清楚。代码是为这篇文章现写的,完整可跑,只额外依赖 golang.org/x/text 做 GBK 转码。
先造一封国内系统很常见的验证码邮件:发件人和标题用 GBK/GB2312 编码,正文是 multipart/alternative,纯文本部分走 quoted-printable,HTML 部分走 base64。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const raw = "From: =?GB2312?B?zajWqrf+zvE=?= <noreply@example.com>\r\n" + "To: someone@example.com\r\n" + "Subject: =?GBK?B?xPq1xNHp1qTC686q?= =?UTF-8?B?77yaODQ3MjAx?=\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/alternative; boundary=\"b1\"\r\n" + "\r\n" + "--b1\r\n" + "Content-Type: text/plain; charset=GBK\r\n" + "Content-Transfer-Encoding: quoted-printable\r\n" + "\r\n" + "=C4=FA=B5=C4=D1=E9=D6=A4=C2=EB=CA=C7 847201=A3=AC10 =B7=D6=D6=D3=C4=DA=D3=D0=D0=A7=A1=A3\r\n" + "--b1\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + "Content-Transfer-Encoding: base64\r\n" + "\r\n" + "PHA+5oKo55qE6aqM6K+B56CB5pivIDxiPjg0NzIwMTwvYj48L3A+\r\n" + "--b1--\r\n" |
注意 Subject:前半截是 GBK,后半截是 UTF-8,两个 encoded-word 之间隔了一个空格。这不是我故意刁难,转发、代发、模板拼接之后的真实邮件里经常出现。
Go 的 mime.WordDecoder 能处理 RFC 2047 的 =?charset?B/Q?...?= 格式,但它只内置了 UTF-8、ISO-8859-1 和 US-ASCII,遇到 GBK 直接报错。要自己给它一个 CharsetReader:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
func charsetReader(charset string, input io.Reader) (io.Reader, error) { switch strings.ToLower(charset) { case "utf-8", "us-ascii", "": return input, nil case "gbk", "gb2312", "gb18030": // GB2312 和 GBK 都是 GB18030 的子集,统一按 GB18030 解最稳 return transform.NewReader(input, simplifiedchinese.GB18030.NewDecoder()), nil } return nil, fmt.Errorf("unsupported charset: %s", charset) } var dec = &mime.WordDecoder{CharsetReader: charsetReader} func decodeHeader(s string) string { out, err := dec.DecodeHeader(s) if err != nil { return s // 解不了就原样返回,别让一个头拖垮整封信 } return out } |
两个细节:
遍历 multipart 的时候,大部分教程写的是 mr.NextPart()。它有个不太显眼的行为:如果子部分是 quoted-printable,它会自动解码,并且把 Content-Transfer-Encoding 头从 Header 里删掉。
这本来是好意,但如果你的代码后面统一根据 CTE 头做解码,就会出现两种结果:QP 部分被"自动"解了,你拿不到 CTE 头以为它是原文;base64 部分它不管,你又得自己解。行为不一致,出问题很难查。
我的建议是用 NextRawPart()(Go 1.14 起有),所有传输编码自己处理,逻辑统一:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
type Part struct { ContentType string Body string } func walk(r io.Reader, contentType, cte string, out *[]Part) error { mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { // Content-Type 写错的邮件并不少见,兜底当纯文本 mediaType, params = "text/plain", map[string]string{} } if strings.HasPrefix(mediaType, "multipart/") { mr := multipart.NewReader(r, params["boundary"]) for { p, err := mr.NextRawPart() if err == io.EOF { return nil } if err != nil { return err } if err := walk(p, p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), out); err != nil { return err } } } // 叶子节点:先解传输编码,再解字符集,顺序不能反 var body io.Reader = r switch strings.ToLower(strings.TrimSpace(cte)) { case "quoted-printable": body = quotedprintable.NewReader(body) case "base64": body = base64Reader(body) } cr, err := charsetReader(params["charset"], body) if err != nil { return err } b, err := io.ReadAll(cr) if err != nil { return err } *out = append(*out, Part{ContentType: mediaType, Body: string(b)}) return nil } |
递归是必须的:multipart/mixed 里套 multipart/alternative 再套 multipart/related(HTML 内嵌图片)是很常见的三层结构。
解码顺序是"先传输编码、后字符集"。传输编码(QP/base64)是为了让二进制能走 7bit 通道加的一层壳,壳里面才是某个字符集的字节流。反过来做,GBK 的字节会被当成 UTF-8 去解 QP,全乱。
标准库的 base64.NewDecoder 能跳过 \r\n,但有些发信系统会在 base64 行尾留空格或 Tab,解码直接报 illegal base64 data。做个最小清洗:
|
1 2 3 4 5 6 7 8 9 10 |
func base64Reader(r io.Reader) io.Reader { b, _ := io.ReadAll(r) clean := strings.Map(func(c rune) rune { if c == '\r' || c == '\n' || c == ' ' || c == '\t' { return -1 } return c }, string(b)) return base64.NewDecoder(base64.StdEncoding, strings.NewReader(clean)) } |
这里为了简单直接 ReadAll 进内存了。附件很大的场景应该写一个流式过滤的 io.Reader,不然几十 MB 的附件会整个进内存两次。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func main() { msg, err := mail.ReadMessage(strings.NewReader(raw)) if err != nil { panic(err) } fmt.Println("原始 Subject:", msg.Header.Get("Subject")) fmt.Println("解码 Subject:", decodeHeader(msg.Header.Get("Subject"))) fmt.Println("解码 From :", decodeHeader(msg.Header.Get("From"))) var parts []Part if err := walk(msg.Body, msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), &parts); err != nil { panic(err) } for _, p := range parts { fmt.Printf("[%s] %s\n", p.ContentType, strings.TrimSpace(p.Body)) } } |
import 部分:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
import ( "encoding/base64" "fmt" "io" "mime" "mime/multipart" "mime/quotedprintable" "net/mail" "strings" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" ) |
go mod init mimedemo && go get golang.org/x/text && go run .,输出:
原始 Subject: =?GBK?B?xPq1xNHp1qTC686q?= =?UTF-8?B?77yaODQ3MjAx?=
解码 Subject: 您的验证码为:847201
解码 From : 通知服务 <noreply@example.com>
[text/plain] 您的验证码是 847201,10 分钟内有效。
[text/html] <p>您的验证码是 <b>847201</b></p>
这份代码能对付大多数验证码、通知类邮件,但离一个完整的邮件解析器还差几块,列出来免得误用:
我在 forxi.cn 上做临时邮箱时,临时邮箱收到的基本就是这类验证码和通知邮件,字符集五花八门。如果你也在做收信相关的东西,建议一开始就把 GB18030 兜底和 NextRawPart 这两件事做对,后面能少查很多乱码工单。