广告位联系
返回顶部

Go语言解析邮件出现乱码的解决方法与避坑指南

Golang 来源:互联网 作者:佚名 发布时间:2026-10-04 21:30:49 人浏览
摘要

收邮件这件事,协议层(SMTP 收信)其实没多难,真正磨人的是拿到原始报文之后:标题是一串 =?GBK?B?xPq1...?=,正文是一堆 =C4=FA=B5=C4,同一封信里 HTML 和纯文本各一份,外面还可能再套一层附

收邮件这件事,协议层(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 之间隔了一个空格。这不是我故意刁难,转发、代发、模板拼接之后的真实邮件里经常出现。

坑一:标题解码,mime.WordDecoder 默认不认 GBK

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

}

两个细节:

  • 声明是 GB2312 的邮件,实际内容经常超出 GB2312(生僻字、全角符号),按 GB2312 严格解会出乱码或替换字符。统一按 GB18030 解,它向下兼容前两者。
  • 用 DecodeHeader 而不是 Decode。Decode 只接受一个完整的 encoded-word,DecodeHeader 会处理混排的普通文本和多个 encoded-word,而且按 RFC 2047 的规定,相邻两个 encoded-word 之间的空白会被丢掉。所以上面那个 GBK + UTF-8 拼起来的标题能正确还原成一句话,不会中间多个空格。

坑二:multipart.Reader.NextPart 会偷偷帮你解 QP

遍历 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 正文里的杂质

标准库的 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>

还没覆盖的部分

这份代码能对付大多数验证码、通知类邮件,但离一个完整的邮件解析器还差几块,列出来免得误用:

  • 附件文件名:Content-Disposition 里的 filename*= 是 RFC 2231 编码(filename*=GBK''%C4%FA...),还有超长文件名被拆成 filename*0*=、filename*1*= 的续行形式。mime.ParseMediaType 能处理 RFC 2231 续行,但字符集只认 UTF-8 和 US-ASCII,GBK 文件名要自己再转。
  • 没声明 charset 的正文:老系统发的信经常不写 charset,上面的代码会当成 UTF-8,遇到 GBK 字节就是乱码。靠谱一点的做法是先 utf8.Valid 判断,不合法再按 GB18030 试。
  • multipart/alternative 选哪个:这里把两份都收了。实际展示时应该优先 HTML,并且 HTML 必须做消毒(去脚本、去外链追踪像素)再渲染,这是另一个话题。
  • Big5、ISO-2022-JP 等其他字符集:按同样的方式往 charsetReader 里加分支就行,golang.org/x/text/encoding/htmlindex 可以按名字直接查。

我在 forxi.cn 上做临时邮箱时,临时邮箱收到的基本就是这类验证码和通知邮件,字符集五花八门。如果你也在做收信相关的东西,建议一开始就把 GB18030 兜底和 NextRawPart 这两件事做对,后面能少查很多乱码工单。


版权声明 : 本文内容来源于互联网或用户自行发布贡献,该文观点仅代表原作者本人。本站仅提供信息存储空间服务和不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权, 违法违规的内容, 请发送邮件至2530232025#qq.cn(#换@)举报,一经查实,本站将立刻删除。
原文链接 :
相关文章
  • Go语言解析邮件出现乱码的解决方法与避坑指南
    收邮件这件事,协议层(SMTP 收信)其实没多难,真正磨人的是拿到原始报文之后:标题是一串 =?GBK?B?xPq1...?=,正文是一堆 =C4=FA=B5=C4,同一
  • golang中unsafe包的实现介绍
    unsafe 包 Go 语言中的 unsafe 包是一个非常强大且危险的工具,它允许开发者进行低级的内存操作,直接访问内存地址。通常,Go 是一种安全的
  • Linux下Epoll与Go netpoll上下文切换
    Go netpoll 本身不引发上下文切换 你看到的goroutine 阻塞在conn.Read(),不是线程(M)被挂起,而是 goroutine 被gopark挂起、让出 M 给其他 G 使用。
  • rust、go、java、python、nodejs各语言内存对比介绍
    在高负载业务场景中,比如Web服务的高频请求处理、Kafka消息的持续消费、流式计算的实时数据处理,我们常常面临这样的挑战:大量短命对
  • Go中的闭包函数Closure示例
    闭包(Closure)是编程中一个非常重要、但初学者容易晕的概念。它在函数式编程(Functional Programming)中无处不在,Go 语言对它的支持非常强
  • go语言中regexp正则表达式的操作
    Go 语言的regexp包提供了对正则表达式的支持。 正则表达式(regex)是一种字符串搜索模式,用来检查一个字符串是否符合某种特定的模式,
  • Go实现完全静态编译和交叉编译的代码
    Go 语言天生支持跨平台编译,并且其标准库几乎不依赖系统动态库,所以在大多数场景下,它编译出来的二进制文件几乎可以直接丢到任何
  • Go语言编译环境设置教程

    Go语言编译环境设置教程
    Go语言优势 天生支持高并发 可以自由的去控制其并发量,也就是携程,通过go routine关键字就行了。 自动垃圾回收机制 内存的清理 不需要环
  • Go fmt包中Scan获取标准输入方式
    Go fmt包下有三个函数 可以在程序运行过程中获取用户输入。 fmt.Scan:获取输入 fmt.Scanf:获取输入,但是可以指定格式,go会根据格式解析参
  • go中空接口的具体使用
    接口-空接口 1. 什么是空接口? 空接口是特殊形式的接口类型,普通的接口都有方法,而空接口没有定义任何方法口,也因此,我们可以说
  • 本站所有内容来源于互联网或用户自行发布,本站仅提供信息存储空间服务,不拥有版权,不承担法律责任。如有侵犯您的权益,请您联系站长处理!
  • Copyright © 2017-2022 F11.CN All Rights Reserved. F11站长开发者网 版权所有 | 苏ICP备2022031554号-1 | 51LA统计