为什么电子邮件需要 Base64
互联网邮件最初是为行长有限的 7 位 ASCII 文本设计的。RFC 5322 至今仍把每行限制在 998 个字符,而且除非接收服务器声明支持 8BITMIME 扩展,SMTP 只保证 7 位数据能安全送达。即使是 8 位传输,邮件中也不能包含 NUL 字节或单独的回车符和换行符,而任何二进制文件迟早都会包含这些。发送原始字节需要 BINARYMIME 扩展,发件方无法指望服务器之间的整条传输路径都支持它。
MIME(RFC 2045)用 Content-Transfer-Encoding 头解决了这个问题。它告诉接收方,某个部分的正文是如何转换成安全文本的。它有五个取值:
| 取值 | 含义 |
|---|---|
7bit | 纯 ASCII 文本行,不编码 |
8bit | 短行的 8 位文本,需要 8BITMIME |
binary | 原始字节,需要 BINARYMIME |
quoted-printable | 大体可读的文本,非 ASCII 字节转义为 =XX |
base64 | 每行最多 76 个字符的 Base64 |
对于附件,邮件软件几乎总会选择 base64。算法本身见 Base64 的工作原理。
Base64 与 quoted-printable 的对比
quoted-printable 保留 ASCII 字符原样不动,把其他每个字节写成 = 加两位 hex 数字。UTF-8 的 é(字节 C3 A9)会变成 =C3=A9。超过 76 个字符的行会用软换行断开,即在行尾加一个 =。
这让它非常适合偶尔带重音字母的英文文本,而对其他内容则很浪费,因为每个非 ASCII 字节要占 3 个字符。Base64 则不管内容是什么,开销都是固定的 4/3。我们用 Python 的 quopri.encodestring 和 base64.encodebytes(均使用 LF 行尾)对三种语言的句子各重复 20 次进行了编码:
| 样本(UTF-8) | 字节数 | Quoted-printable | Base64 |
|---|---|---|---|
| 英文全字母句 | 900 | 926 | 1,216 |
| 带重音字母的法语句子 | 1,060 | 1,830 | 1,435 |
| 俄文全字母句 | 1,600 | 4,602 | 2,165 |
邮件客户端一般会替你做这个选择:以 ASCII 为主的文本正文用 quoted-printable 或 7bit,附件和非拉丁文字的文本用 Base64。如果需要手动读写 quoted-printable,quoted-printable 工具 支持双向转换。
原始邮件中的附件
带附件的邮件是一个 multipart/mixed 正文。每个部分都有自己的头部、一个空行,然后是内容。下面是用 Python 的 email 包生成的一封完整的最小邮件(为便于阅读,缩短了边界字符串):
From: Ana <ana@example.com>
To: bo@example.org
Subject: =?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?=
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
--b1
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Hi Bo,
The caf=C3=A9 menu is attached.
--b1
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="menu.pdf"
JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyA+PgplbmRvYmoKdHJhaWxl
cgo8PCAvUm9vdCAxIDAgUiA+PgolJUVPRgo=
--b1--
需要关注的部分:
Content-Type给出文件类型,Content-Disposition给出文件名。Content-Transfer-Encoding: base64表明正文是 Base64。- Base64 从该部分的第一个空行之后开始,到下一个
--boundary行之前结束。 - 除最后一行外,每行恰好 76 个字符。每个完整的行包含文件的 57 个字节。
- 开头的
JVBERi0是%PDF-的 Base64,所以这是一个 PDF。其他文件签名见 常见的 Base64 前缀。
HTML 邮件中内嵌的图片,在 multipart/related 部分中使用同样的机制。每张图片都有一个 Content-ID 头,HTML 以 <img src="cid:..."> 的形式引用它。传输时这些行以 CRLF 结尾,所以每 76 个 Base64 字符要占 78 个字节;完整的计算见 Base64 体积开销。想自己生成分行的输出,可以用 MIME Base64 编码器。
编码的邮件头:=?UTF-8?B?…?=
Subject、From 中的显示名以及某些文件名等邮件头必须是 ASCII。RFC 2047 把其他文本编码为编码字(encoded-word):
=?charset?encoding?encoded-text?=
encoding 为 B 表示 Base64,为 Q 表示 quoted-printable 的一种变体,其中 _ 代表空格。这两个字母不区分大小写,所以上面 Python 写的是 =?utf-8?b?。下面是一些真实的例子,均用 Python 的 email.header.decode_header 验证过:
| 编码后 | 解码后 |
|---|---|
=?UTF-8?B?0J/RgNC40LLQtdGC?= | Привет |
=?utf-8?q?Caf=C3=A9_menu?= | Café menu |
=?ISO-8859-1?Q?Caf=E9_menu?= | Café menu |
=?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?= | Café menu – Größe |
一个编码字最多 75 个字符,所以较长的主题会被拆成多个编码字,每个占一行折叠的头部行。RFC 2047 规定,相邻编码字之间的空白会被忽略,而且每个编码字必须包含完整的字符,因此一个多字节的 UTF-8 字符永远不会被拆到两个编码字中。一个 44 个字符的俄文主题,Python 输出如下:
Subject: =?utf-8?b?0KHRh9GR0YIg0LfQsCDQvtC60YLRj9Cx0YDRjCDQuCDQsNC60YIg0YE=?=
=?utf-8?b?0LLQtdGA0LrQuCDQtNC70Y8g0LHRg9GF0LPQsNC70YLQtdGA0LjQuA==?=
每个编码字都是一个带有自己填充的完整 Base64 字符串。分别解码后,第一个得到 Счёт за октябрь и акт с,第二个得到 верки для бухгалтерии;解码器会把它们拼回原来的主题。
附件文件名还有第二个标准 RFC 2231,形如 filename*=UTF-8''%D0%BE%D1%82%D1%87%D1%91%D1%82.pdf。许多邮件程序也会把 RFC 2047 编码字放在 filename="..." 里,所以解析器应该两种都能处理。
如何从 .eml 文件中提取附件
在浏览器中:用 EML 解码器 打开 .eml,即可阅读邮件正文并保存附件。它在页面中本地运行,当邮件包含发票或个人数据时,这一点很重要。
用 Python:标准库能解析 MIME,并同时解码 Base64 正文和 RFC 2047 邮件头:
import sys
from email import policy
from email.parser import BytesParser
with open(sys.argv[1], "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
print("Subject:", msg["Subject"]) # encoded-words decoded
for part in msg.iter_attachments():
name = part.get_filename()
data = part.get_content() # Base64 already decoded to bytes
with open(name, "wb") as out:
out.write(data)
print("saved", name, len(data), "bytes")
对上面的示例邮件运行时,它输出了 Subject: Café menu – Größe 和 saved menu.pdf 83 bytes。在实际代码中,写文件前要先清理 name,因为它来自发件人。
手动处理:把该部分的 Base64 行复制到一个文件中,再进行解码。原始 .eml 文件使用 CRLF 行尾,而 GNU base64 -d 遇到第一个回车符就会以 invalid input 停止。请先去掉回车符,或者用 -i 跳过字母表之外的字符:
tr -d '\r' < block.txt | base64 -d > menu.pdf
在我们的测试中,openssl base64 -d -in block.txt -out menu.pdf 可以原样接受 CRLF 版本。如果你手里只有 Base64 文本,可以把它粘贴到 Base64 转文件 中,它会根据开头的字节识别类型;PDF 也可以直接粘贴到 Base64 转 PDF。
查看原始邮件的邮件头、正文和附件,全部在你的浏览器本地解码。
常见问题
如何从邮件源码中打开 Base64 编码的附件?
复制该部分空行之后、下一个边界之前的 Base64 行,再把它们解码为文件。EML 解码器 可以直接从 .eml 文件中帮你提取附件。
邮件主题中的 =?UTF-8?B? 是什么意思?
它是 RFC 2047 编码字(encoded-word)的开头:字符集为 UTF-8,B 表示接下来 ? 和 ?= 之间的文本是 Base64。如果是 Q 而不是 B,则表示 quoted-printable 的一种变体。
为什么邮件中的 Base64 每行是 76 个字符?
RFC 2045 把 MIME 中 Base64 的每行长度限制为 76 个字符。76 个字符正好容纳 57 个字节,而且这个限制让每行远低于电子邮件允许的 998 个字符上限。
邮件正文用 quoted-printable 好还是 Base64 好?
以 ASCII 为主的文本用 quoted-printable,因为它保持可读,体积几乎不增加。二进制数据和非拉丁文字的文本用 Base64,对这类文本 quoted-printable 可能让体积增加到将近三倍。