美文网首页
记一次诡异的Java Mail邮件乱码问题

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

作者: 利德 | 来源:发表于2018-06-06 15:01 被阅读345次

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

    故事是这样的, 最近做了一个读取邮件在系统显示的功能, 标准的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邮件乱码问题

          本文链接:https://www.haomeiwen.com/subject/kfihsftx.html