本文主要讲解使用 mail 库解析邮件所碰到的坑.

邮件格式本身的解析由 mail 库.由于邮件格式标准过多且过于复杂,鉴于个人能力有限,所以就不讲解邮件相关的标准的.需要自己先阅读相关资料.比如 RFC 822, multipart 等方面的资料.

坑主要分两大类:

  1. 编码 (修炼的必经之路)
  2. 邮件非常见格式解析 (主要是苹果设备发出来的邮件)
    • 正文只有图片(只包含附件 part, 无 text part 或 html part)
    • 正文有多个文本段(multi text part)
    • multipart 再包含 multipart

mail 基础技巧

  1. 查看 mail 官方文档
  2. Mail.new(str) 的 str 变量,需要为 RFC 822 标准格式
  3. gmail 邮件详情页的 “显示原始邮件” ,下载下来的 original_msg.txt 文件,是 RFC 822 标准,调试时可以直接下载此文件来调试.
  4. iamp 抓取时, imap.uid_fetch(uid, ['RFC822'])[0] 这样可以拿到 RFC 822 格式的内容. 参考来源

编码

编码这个坑与编程语言无关,它是我们修炼必经的路.

  • 世界上有多种字符,比如英文,简体中文,繁体中文.
  • 一种字符有可能有多种编码,比如简体中文有 GB2312, GBK, GB18030 . 参考来源
  • 一种编码有可能有多种实现,比如 Unicode 编码有 UTF-8, UTF-16, UTF-32 多种实现. 参考来源

代码示例:

# "中" 字不同编码的十六进制值. http://blog.bigbinary.com/2011/07/20/ruby-pack-unpack.html
puts "中".encode('UTF-8').unpack('H*') # e4b8ad
puts "中".encode('UTF-32').unpack('H*') # 0000feff00004e2d 
puts "中".encode('GBK').unpack('H*') # d6d0

# 字节数组
puts "中".encode('UTF-8').bytes.inspect # [228, 184, 173]
puts "中".encode('UTF-32').bytes.inspect # [0, 0, 254, 255, 0, 0, 78, 45]
puts "中".encode('GBK').bytes.inspect # [214, 208]

# Base64
require "base64"
puts Base64.encode64('中'.encode('GBK')) # 1tA=
puts Base64.encode64('中'.encode('UTF-8')) # 5Lit

从上示例可以看出,同一个字符,用不同编码时,其二进制数据值有可能不一样.

那么编码的主要问题是什么? 请看代码示例:

require "base64"
str = Base64.decode64('1tA=') # 解码 "中" 的 GBK 编码的 base64 值.
puts str.encoding # ASCII-8BIT, 相当于是一个字节数组(byte array, 1byte = 8bit)
puts str.bytes.inspect # [214, 208] , 等于上示例的 "中".encode('GBK').bytes.inspect .也就是说变量的在内存里的二进制值还是 GBK 编码.
puts str # 打印出乱码. 因为终端一般设置的编码为 UTF-8 ,如果想要此语句不显示成乱码,把终端编码改成 GBK 即可.记得改回 UTF-8.

puts str.force_encoding('GBK').bytes.inspect # [214, 208], 字节还是 GBK 未变
puts str.force_encoding('GBK').encode('UTF-8') # 中 force_encoding 只是改变变量的编码元信息. encode 把变量的字节从 GBK 变成 UTF-8 . 这样打印就不乱码了.

puts str.force_encoding('BIG5').bytes.inspect # [214, 208], 字节还是 GBK 未变
puts str.force_encoding('BIG5').encode('UTF-8') # 笢 同样的字节数据,在繁体 BIG5 编码里有效且是另外一个字符.

str.encode('UTF-8') # 报错.因为不知道字节的编码信息,有可能默认编码转换映射是从 ASCII to UTF-8(待考证)

把一个字符转成对应编码的字节不难,把一个已知编码信息的字节转成对应字符也不难.

难在把一个不知道编码信息的二进制数据,转成对应的字符. [214, 208] 是 GBK 编码里的 “中” 字,同时也是 BIG5 编码里面的 “笢” 字.

上示例就是演示了邮件里碰到的编码问题,当正文经过 base64 编码后.收到邮件后, base64 解码出来的二进制数据,到底是 GBK, 还是 BIG5 ,还是其它编码?

所幸的是,大部分情况我们都不需要靠猜, 一般邮件正文 part 都有这样一段 header 信息. 其 Content-Type 的 charset 就告诉了我们这段 base64 解码后的二进制是什么编码.

MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: base64

代码示例:

require 'mail' # 需要先安装 mail gem
# 构建 RFC822 标准的邮件字符串
mail = Mail.new do
  from    '[email protected]'
  to      '[email protected]'
  subject 'This is a test email'
  content_type 'text/plain; charset=GBK'
  body    "中".encode('GBK')
end

original_msg = mail.to_s
=begin
Date: Sat, 19 Aug 2017 13:39:10 +0800
From: [email protected]
To: [email protected]
Message-ID: <[email protected]>
Subject: This is a test email
Mime-Version: 1.0
Content-Type: text/plain;
 charset=GBK
Content-Transfer-Encoding: base64

1tA=
=end
puts original_msg

# 开始解析
puts "*" * 42
mail = Mail.new(original_msg)
body = mail.body.decoded
puts body # 乱码
puts body.encoding # ASCII-8BIT
puts body.force_encoding(mail.charset).encode('UTF-8') # 中
# 需要检查 charset 是否存在,通过 Encoding.find 方法

理想的世界, content_type 是带了 charset .但现实与理想总是存在差距.有些没有带 charset ,有些甚至连 content_type 整行都没有.

这个时候编码就要靠猜了, charlock_holmes 就是干这事的.

但这个只有在正文很多的时候才会有可能猜的准.

puts CharlockHolmes::EncodingDetector.detect('中文测试很长的文字'.encode('GBK').force_encoding('ASCII-8BIT'))
# {:type=>:text, :encoding=>"UTF-16BE", :ruby_encoding=>"UTF-16BE", :confidence=>10}

puts CharlockHolmes::EncodingDetector.detect('遍身罗绮者 不是养蚕人'.encode('GBK').force_encoding('ASCII-8BIT'))
# {:type=>:text, :encoding=>"ISO-8859-6", :ruby_encoding=>"ISO-8859-6", :confidence=>16, :language=>"ar"}

puts CharlockHolmes::EncodingDetector.detect('中文测试,工要在地一上是中国;'.encode('GBK').force_encoding('ASCII-8BIT'))
# {:type=>:text, :encoding=>"GB18030", :ruby_encoding=>"GB18030", :confidence=>100, :language=>"zh"}

所以,优先以 content_type 的 charset 去解码, charlock_holmes 只是最后方案.

事实上 encode 方法有个坑,就是有可能 encode 碰到无效字节,会导致报错. 推荐加上 invalid 和 undef 参数. replace 默认是替换成问号.还可以直接删除无效字节,推荐使用替换.

"abc".encode('UTF-8', invalid: :replace, undef: :replace)

邮件非常见格式解析

1. 正文只有图片

require 'mail' # 需要先安装 mail gem
require 'open-uri'
# 构建 RFC822 标准的邮件字符串
mail = Mail.new do
  from    '[email protected]'
  to      '[email protected]'
  subject 'This is a test email'
  content_type 'image/png; filename=One_black_Pixel.png'
  body    open('https://upload.wikimedia.org/wikipedia/en/4/45/One_black_Pixel.png').read
end

original_msg = mail.to_s
=begin
Date: Sat, 19 Aug 2017 14:25:48 +0800
From: [email protected]
To: [email protected]
Message-ID: <[email protected]>
Subject: This is a test email
Mime-Version: 1.0
Content-Type: image/png;
 filename=One_black_Pixel.png
Content-Transfer-Encoding: base64

iVBORwoaCgAAAApJSERSAAAAAQAAAAEIAgAAAJB3U94AAAABc1JHQgCuzhzp
AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAAxJREFU
GFdjYGBgAAAABAABXM3/aQAAAABJRU5ErkJggg==
=end
puts original_msg

# 开始解析
puts "*" * 42
mail = Mail.new(original_msg)
puts mail.body # 正文是乱码
puts mail.attachment? # true

对于这种只有图片的邮件,我们先用 attachment? 方法判断是不是附件,是附件的话,按附件的逻辑处理,比如保存到本地.

2. 正文有多个文本段

此非常见格式的邮件一般是苹果设备自带的邮件客户端发出来的.

苹果邮件客户端这么做是为了实现在纯文本格式邮件插入图片的上下环绕效果.

主流的做法纯文本不插入图片,图片只作为普通附件存在.要插入图片,使用 html 格式,通过 html img 标签来实现,img src 填图片附件的 cid .

这样的格式,在 gmail 显示不出环绕效果,只作为普通附件显示. 在苹果的客户端可以显示.

MailCatcher 这个接收测试工具,和我开始一样,以为一个邮件只有一个 text part,所以导致这种邮件只会显示部分文本.

require 'mail' # 需要先安装 mail gem
require 'open-uri'
# 构建 RFC822 标准的邮件字符串
mail = Mail.new do
  from    '[email protected]'
  to      '[email protected]'
  subject 'This is a test email'
  part :content_type => "multipart/alternative", :content_disposition => "inline" do |p|
    p.part body: "abc"
    p.part content_type: 'image/png; filename=One_black_Pixel.png', body: open('https://upload.wikimedia.org/wikipedia/en/4/45/One_black_Pixel.png').read
    p.part body: "def"
  end
end

original_msg = mail.to_s
=begin
Date: Sat, 19 Aug 2017 14:58:30 +0800
From: [email protected]
To: [email protected]
Message-ID: <[email protected]>
Subject: This is a test email
Mime-Version: 1.0
Content-Type: multipart/mixed;
 boundary="--==_mimepart_5997e19675b41_70774d198d1bc8305cd";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_5997e19675b41_70774d198d1bc8305cd
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5997e1959f3c7_70774d198d1bc830458";
 charset=UTF-8
Content-Transfer-Encoding: 7bit
Content-Disposition: inline
Content-ID: <[email protected]>


----==_mimepart_5997e1959f3c7_70774d198d1bc830458
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

abc
----==_mimepart_5997e1959f3c7_70774d198d1bc830458
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

def
----==_mimepart_5997e1959f3c7_70774d198d1bc830458
Content-Type: image/png;
 filename=One_black_Pixel.png
Content-Transfer-Encoding: base64

iVBORwoaCgAAAApJSERSAAAAAQAAAAEIAgAAAJB3U94AAAABc1JHQgCuzhzp
AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAAxJREFU
GFdjYGBgAAAABAABXM3/aQAAAABJRU5ErkJggg==

----==_mimepart_5997e1959f3c7_70774d198d1bc830458--

----==_mimepart_5997e19675b41_70774d198d1bc8305cd--
=end
puts original_msg

# 开始解析
puts "*" * 42
mail = Mail.new(original_msg)
puts mail.multipart?
puts mail.text_part.body # 默认只取了第一个 text part

puts "*" * 42
# 通过 all_parts 拿到所有 part, 包含本身.
mail.all_parts.each do |part|
  # 需要排除 multipart , attachment, 生产代码还需要区分 text 还是 html. text 和 text 加在一起, html 和 html 加在一起.
  # 这里还有一个大坑,就是多 text part 字符拼接时,一定要先把编码转成 utf-8 .因为苹果设备如果刚好那部分只有英文,那么编码为 ASCII, 如果有中文,编码为 GBK .
  # 有兴趣的朋友可以用苹果邮件客户端自己测试一下
  puts part.body if !part.multipart? && !part.attachment?
end

multipart 再包含 multipart

这种情景主要出现在苹果邮件客户端同时发送 text 和 html 格式的. html 是一个 sub multipart.

处理方法同上, all_parts 会自动遍历 sub multipart . 我们只要排除 multipart? 和 attachment? 即可.