记一次诡异的Java Mail邮件乱码问题

最近遇到一个诡异的中文邮件读取, 显示乱码的问题, 解决过程比较曲折, 我觉得很有必要记录下来.

故事是这样的, 最近做了一个读取邮件在系统显示的功能, 标准的Java Mail的处理方式, 很快发现有一些中文的邮件, 显示为乱码, 一堆问号:

�ͻ���?���Dz������Լ���?
����Э�̸��Ƿ���ܡ��ȸ��ɡ�

开始很纳闷, 因为本地并不能重现, 本地测试同一封邮件, 读取回来就是正常的, 同时邮件标题中的中文字符是没有问题的, 还发现其他的一封中文邮件, 也是没有问题的.

所以判断一定是这封邮件有什么独特的地方, 通过给MailStore的property设置"mail.debug"为true, 打开调试模式后, 调试信息是这样的:

A5 FETCH 1661 (ENVELOPE INTERNALDATE RFC822.SIZE)

  • 1661 FETCH (ENVELOPE ("Wed, 30 May 2018 10:37:37 +0800" "=?GBK?B?T1IyMDE4...0Irzcu79WxhYmVs?=" ... INTERNALDATE "30-May-2018 10:37:35 +0800" RFC822.SIZE 4028)
    A6 FETCH 1661 (BODYSTRUCTURE)
  • 1661 FETCH (BODYSTRUCTURE ((("TEXT" "PLAIN" ("charset" "GB2312") NIL NIL "BASE64" 110 3 NIL NIL NIL)("TEXT" "HTML" ("charset" "GB2312") NIL NIL "QUOTED-PRINTABLE" 795 11 NIL NIL NIL) "ALTERNATIVE" ("BOUNDARY" "----=002_NextPart882583734822=----") NIL NIL)("IMAGE" "PNG" ("name" "=?GB2312?B?OTM4X9...vC5wbmc=?=") "_Foxmail.1@51f50e29-48f0-ad3d-2e81-703262901068" NIL "BASE64" 1288 NIL NIL NIL) "RELATED" ("BOUNDARY" "----=001_NextPart260442321737=----") NIL NIL))
    A6 OK FETCH Completed
    A7 FETCH 1661 (BODY[1.1])
  • 1661 FETCH (BODY[1.1] {110} v827p8LytO2jrLWryseyu7P...hsYWJlbLDJoaMNCg0KDQoNCg==

可以发现邮件标题用了MIME Encoded-Words, 编码是GBK, 邮件本体是multipart, 有3块, 分别是text/plain, text/html, image/png, 其中文本部分有标明编码是GB2312, 正文部分用了Base64编码

根据官方解释, getContent方法应当负责读取相应的编码信息来解析文本:

https://javaee.github.io/javamail/FAQ#unsupen
Typically, such bodyparts internally hold their textual data in some non Unicode charset. JavaMail (through the corresponding DataContentHandler) attempts to convert that data into a Unicode string

前面说过发现另一封中文邮件读取正常, 于是也调试了一下, 发现那封邮件头写的编码是UTF-8, 没有问题.

然后查了数据库连接, 数据库引擎, 数据库表还是操作系统上声明的编码, 都统一是UTF-8, 没有问题.

因为邮件标题能正常读取, 并存储显示都正常, 所以其实是迅速排除了数据库/操作系统底层设置的问题, 看起来问题还是出在程序没有正确按邮件头的编码来解码.

于是写了一个程序测试, 发现确实当同样的GB2312编码的字符串强行按UTF-8解码, 就会出现前面的一堆问号的乱码.

确定了出问题的方式, 下面就是想办法复现, 因为本地是好的, 所以只能想办法在线上的环境动手脚, 因为有一个备用的环境, 经测试也能重现问题, 于是单独写一段测试代码部署过去触发, 然后查看日志, 这样调试很没有效率, 但是也只能这样了.

结果刚开始就发现一个令人震惊的事, 测试代码显示中文被正确解析了, 然后当我过一段时间再运行的时候, 又变成了乱码, 完全搞不懂为什么, 于是反复加了很多的调试输出, 来回部署了十几遍, 真的很折腾.

当陷入僵局的时候我想到为什么我本地没有问题呢, 很有可能是因为服务器环境是jdk6, 而本地是jdk8, 不过当我在本地安装了jdk6之后, 还是不能重现问题

反正接下来就是想尽办法剥开getContent方法背后所有执行的代码, 查看经过了那些类那些方法, 虽然没有找到问题, 但是知道了getContent是怎么获取正文的了:

  1. getContent首先得到一个DataHandler
  2. DataHandler然后得到DataContentHandler
  3. DataContentHandler是通过当前文本类型, 在CommandMap工厂里获得的
  4. 这里应该要获取text_plain这个handler来处理
  5. handler会读取charset的编码设定, 解析文本

上面说了, 正常情况下这个DataContentHandler应该是text_plain, 但是随即发现出问题的时候, 这里用的居然是StringDataContentHandler, 同时通过查源码得知, StringDataContentHandler在jdk6和jdk8的时候确实是不一样的, 8的时候加入了编码的判断, 而6的时候没有, 因为没有指定, 所以用了defaultCharset, 也就是UTF-8, 砰!出问题!, 而我本地是8的环境,这也是为什么我总是复现不成功的原因了.

// StringDataContentHandler在jdk8下的源码
enc = this.getCharset(ds.getContentType()); // 这里去读了ContentType下的编码信息
is = new InputStreamReader(ds.getInputStream(), enc); // 使用正确的编码解码

// 这是text_plain的源码, 可以看到同样读取了编码信息, 和上面是一样的, 所以这两个是对的
enc = getCharset(ds.getContentType());
is = new InputStreamReader(ds.getInputStream(), enc);

// StringDataContentHandler在jdk6下的源码, 是有bug的
is = new InputStreamReader(ds.getInputStream()); 
// 1. 里面的getInputStream是解析Base64编码的正文的
// 2. 外面的Reader才是负者文本解码的
// InputStreamReader使用一个参数创建时, 用的是系统默认编码: Charset.defaultCharset()
// 根据配置不同可能是US-ASCII或者UTF-8等等

然后发现一般在我刚部署完成的时候, 测试是通过的, 用的处理类也是text_plain, 但是过一段时间就会变成StringDataContentHandler, 于是猜测有什么东西会在运行的时候改变这个设置.

随后仔细研究了CommandMap这个类, 它是抽象类, 用到的实现类是MailcapCommandMap, 它的加载方式来源三个地方:

  1. User Home目录下的.mailcap文件
  2. JavaHome lib下的mailcap文件
  3. javax.mail的包里面的mailcap文件

前两个地方通常是空的, 第三个地方内容是:

text/plain;; x-java-content-handler=com.sun.mail.handlers.text_plain
text/html;; x-java-content-handler=com.sun.mail.handlers.text_html
...

所以在这里text_plain的配置是正常的.

那StringDataContentHandler是怎么配置进去的呢, 通过google这个类发现, 还真有可以注入的地方, 那就是rt.jar/com.sun.xml.internal.ws.binding下的BindingImpl这个类:

public static void initializeJavaActivationHandlers() {
    try {
        CommandMap map = CommandMap.getDefaultCommandMap();
        if (map instanceof MailcapCommandMap) {
            MailcapCommandMap mailMap = (MailcapCommandMap)map;
            if (!cmdMapInitialized(mailMap)) {
                mailMap.addMailcap("text/xml;;x-java-content-handler=com.sun.xml.internal.ws.encoding.XmlDataContentHandler");
                mailMap.addMailcap("application/xml;;x-java-content-handler=com.sun.xml.internal.ws.encoding.XmlDataContentHandler");
                mailMap.addMailcap("image/*;;x-java-content-handler=com.sun.xml.internal.ws.encoding.ImageDataContentHandler");
                // 这里用指定的类覆盖了默认设置
                mailMap.addMailcap("text/plain;;x-java-content-handler=com.sun.xml.internal.ws.encoding.StringDataContentHandler");
            }
        }
    } catch (Throwable var2) {
        ;
    }
}

不过这个类是jdk8的, 对应jdk6是com.sun.xml.internal.ws.encoding下的MimeCodec, 效果是一样的:

static {
    try {
        CommandMap var0 = CommandMap.getDefaultCommandMap();
        if (var0 instanceof MailcapCommandMap) {
            MailcapCommandMap var1 = (MailcapCommandMap)var0;
            String var2 = ";;x-java-content-handler=";
            var1.addMailcap("text/xml" + var2 + XmlDataContentHandler.class.getName());
            var1.addMailcap("application/xml" + var2 + XmlDataContentHandler.class.getName());
            var1.addMailcap("image/*" + var2 + ImageDataContentHandler.class.getName());
            var1.addMailcap("text/plain" + var2 + StringDataContentHandler.class.getName());
        }
    } catch (Throwable var3) {
        ;
    }
}

看到这个的时候, 真的有一种让人"呵呵"的感觉, 要知道addMailCap甚至特意留了一个空位给这个用:

public MailcapCommandMap() {
    List dbv = new ArrayList(5);
    MailcapFile mf = null;
    dbv.add((Object)null); // 这里特意存了一个null到第一个的位置
    LogSupport.log("MailcapCommandMap: load HOME");

正常情况下这个CommandMap是这样的:

[
  null, // 这个是预留的空位
  {"mimeTypes": ["message/rfc822", "multipart/*", "text/plain", "text/xml", "text/html"]}, // 这个是从javax.mail-1.5.2.jar/META-INF/mailcap读来的
  {"mimeTypes": ["image/jpeg", "image/gif", "text/*"]} // 这个是从classes.jar/META-INF/mailcap.default读来的
]

当addMailCap后, CommandMap变成这样的:

[
  // 被BindingImpl注入
  {"mimeTypes": ["application/xml","text/plain","text/xml","image/*"],   
  {"mimeTypes": ["message/rfc822", "multipart/*", "text/plain", "text/xml", "text/html"]}, 
  {"mimeTypes": ["image/jpeg", "image/gif", "text/*"]} 
]

MailcapCommandMap实例化的时候就特意加了一个null到数组开头, 就是为了addMailcap的时候占用这个位置, 从而达到覆盖配置的目的, 非常"精巧", 除了注入的这个类有bug!

所以真相大白, 刚部署的时候一切正常, 读取邮件也正常, 但是当应用执行了一些webservice相关的代码后, 因为其中的初始化设定, 用有bug的StringDataContentHandler覆盖了正常的text_plain, 于是出现乱码.

解决方法1: 升级jdk8, 显然这个影响有点大, 不考虑

解决方法2: 想办法让text_plain能够排在前面, 拥有最高优先级, 但是前面说了通过程序注入的配置已经是最高优先级了, 甚至高过了java home下的配置文件, 那只能先下手为强, 把text_plain抢先配置进去

还好BindingImpl的初始化是lazy的, 只有触发相关代码才会执行, 所以解决方案就是在应用启动时就抢先配置, 占好位置, 这样后面再加的配置优先级都低过它.

通过注册Spring的web listener可以做到应用启动时执行:

public class StartupListener extends ContextLoaderListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        try {
            CommandMap map = CommandMap.getDefaultCommandMap();
            if (map instanceof MailcapCommandMap) {
                MailcapCommandMap commandMap = (MailcapCommandMap) map;
                commandMap.addMailcap("text/plain;;x-java-content-handler=com.sun.mail.handlers.text_plain");
            }
        } catch (Exception ignore) {
        }
    }
}

这么一改, 问题解决!

其实我是非常喜欢这样刨根问底, 最终解决问题的过程的, 因为不仅可以解决问题, 还能学到不少之前不知道的东西, 比如:

乱码还原问题

不是所有乱码都是可以还原的, 比如这次的GB2312被错误解码为UTF-8, 因为UTF-8的特殊编码方式, 不被识别的字符全都变成问号了, 是不可能还原的.

但是反过来, UTF-8的字符串被错误的用GB2312解码的话是有可能还原的哦.

邮件编码方案

为了邮件在互联网传输过程中达到最大的通用性, 标准规定只能使用可打印的ascii字符, 那中文或者其他语言的字符怎么办呢, 方法就是这类字符先按该语言的特定编码存储, 比如GB2312编码, 然后将编码信息以二进制的形式二次编码为基础的ascii码, 这里的方案通常有Base64, 或者Quoted-printable, 然后只要在合适的地方标明使用的两次编码的方案, 还原的时候倒过来解码就可以了.

比如邮件标题是中文的情况下, 获取来是这样的:

=?GBK?B?T1IyMDE4...0Irzcu79WxhYmVs?=

这个格式是MIME的标准, 表示是用GBK+Base64的编码方案

比如邮件正文有中文的话, 头信息是这样的:

"TEXT" "PLAIN" ("charset" "GB2312") NIL NIL "BASE64" 110 3 NIL NIL NIL

这里表示是用GB2312+Base64的编码方案

或者还可能这样:

"TEXT" "HTML" ("charset" "GB2312") NIL NIL "QUOTED-PRINTABLE" 795 11 NIL NIL NIL

这里表示是用GB2312+QUOTED-PRINTABLE的编码方案

Quoted-Printable编码看起来长这样:

=CD=F8=C9=CF=B9=BA=CE=EF

总的来说Base64的信息含量比较高, 因为Base64用了3个可打印的字节替换4个原始的二进制字节, 所以理论上讲, 编码后的字符串比原来长了1/3.

但是Quoted-Printable是把一个8位的字符用两个十六进制数值来表示,然后在前面加"=", 3个字节换一个, 长了2倍, 虽然胜在简单, 但是比较消耗流量, 所以现在常用的都是Base64编码了

总结

虽然表面上看起来只是一个普通的乱码问题, 但是背后却隐藏了这么多弯弯绕绕, 然而最后解决又只需要三四行代码, 所以想起来以前说工程师的一个老笑话, "拧一颗螺丝值1块钱, 但是知道拧哪一颗值5000块!". 这也正是开发的乐趣所在了.

你可能感兴趣的:(记一次诡异的Java Mail邮件乱码问题)