SMTP协议简析

627 阅读21分钟

近来阅读了一些SMTP的文档,做了一个SMTP的sdk,写一篇文章总结这个过程积累的笔记。 文章主要分以下四部分展开介绍

  • SMTP的历史和常用RFC
  • SMTP的架构和连接方式
  • SMTP的命令和错误码
  • MIME消息格式

第一部分:SMTP的历史与常用RFC

SMTP的历史

SMTP 是Simple Mail Transfer Protocol的缩写,是一个基于TCP/IP用于发送邮件的协议。SMTP的历史很悠久,我们简单回顾一下SMTP的起源和发展。

SMTP的前身

1960年代,人们开始研究在大型计算机之间的通信协议,这个时期已经存在同一台电脑上不同用户之间的通信方式。随着更多电脑的出现,1970年代,人们开始研究在阿帕网(ARPANET)上不同操作系统之间的通信协议

1971年,出现了一个Mail Box Protocol,这个协议没有被实现,在 RFC 196中有这个协议的定义。

同样在70年代初期,Ray Tomlinson对SNDMSG进行了优化,SNDMSG支持在同一台大型计算机上,不同用户之间可以相互发送邮件,Ray Tomlinson优化之后,支持在ARPANET上的不同电脑之间发送邮件。这也是计算机历史上普遍公认的第一份电子邮件。

70年代还有一些邮件协议作为提议出现,但是还未被实现,比如 RFC 524: A Proposed Mail Protocol

历史上人们基于FTP开发过的一个电子邮件协议,最终版本可以参考1977年的 RFC 733

上面这些协议虽然未被大范围使用,但是都为SMTP的诞生一定程度打下了理论基础。

SMTP的出现

1980年,出现了新的协议,用于替代基于FTP的邮件协议,它就是 MTP协议(Mail Transfer Protocol)。没错,SMTP意思就是Simple的MTP。

1981年,出现了SMTP,第一份定义SMTP的RFC文档是 RFC 788 Simple Mail Transfer Protocol

现代SMTP

1995,在RFC 1869: SMTP Service Extensions中定义了ESMTP,它是其实是对SMTP的补充,引入了EHLO还有一系列扩展属性和登录认证方式。现代的SMTP服务器都是遵从ESMTP来实现的。但是为了方便,我们一般还是称它为SMTP。

学习这些古老的网络协议,除了在网上阅读前人写的博客,难以避免需要阅读其RFC文档,而阅读RFC文档阅读起来并不简单,单单是SMTP文档看起来就有很多个,每一个都叫Simple Mail Transfer Protocol。我们先理清RFC文档中几个关键概念。

如何阅读RFC文档

以下截图是 RFC 2821 的header部分,我们总结一下这其中几个关键字段的意思。

image.png

  • Request for Comments: 表示该文档的RFC编号是2821
  • Obsoletes: RFC 2821 是RFC 821\974\1869这些文档的升级版本
  • Updates: RFC 2821是RFC 1123的补充
  • Obsoleted by: RFC 5321是当前RFC 2821的升级版本
  • Updated by: RFC 5336是当前RFC 2821的补充

这里我们主要分清楚Obsolete和Update的关系,前者是升级,后者是补充。所以我们应该选择RFC 5321来阅读最新的SMTP协议,而RFC 2821和RFC 821是SMTP的历史文档。

推荐一个提高检索RFC文档的网站, rfc.fyi/ : 支持按主题搜索RFC,默认只列出最新版本,搜索框下面支持勾选Obsolete选项,选中状态搜索结果会列出历史版本。

SMTP常用RFC

SMTP

MINE

Encoded-words

第二部分:SMTP的架构与连接

SMTP的架构

开局一张图,让我们看看邮件服务器怎么使用SMTP/IMAP/POP3进行发信和收信

image.png

我们可以看到,整个发信到收信,有三个阶段

  • 客户端通过SMTP协议发送给自己的邮件服务器
  • 邮件服务器通过SMTP协议把邮件投递到收件人的邮件服务器
  • 收件人借助POP3/IMAP从自己的邮件服务器收取最新的右键

SMTP发送邮件有两个不同的阶段

  • submitting:客户端发送给邮件服务器。
  • relaying:一个邮件服务器投递邮件给另一个邮件服务器。有些英文文档也把relaying称为transfer

Submitting VS Relaying

这两个场景传输邮件都是借助SMTP命令,但是有很多细节上的区别。

submitting

  • 登录认证:需要进行登录认证,再发信
  • 端口:理论上使用587,587是一个需要身份认证的端口,很多文章提到,如果拿25作为submitting阶段的端口,很可能会被一些ISP拦截(自测了QQ邮箱暂时没遇到这种情况)

image.png

  • 域名:从邮件服务商的官网获取,比如QQ邮箱xxx

relaying

  • 登录认证:无需进行登录认证,而且由于不同邮件服务商的账号体系不一致,也不可能进行身份认证
  • 端口:理论上使用25

image.png

  • 域名:可以通过nslookup/dig/host等命令获取该服务器的MX地址

SMTP的端口与服务器

SMTP的服务器

如果要连接SMTP服务器,依然是得分submitting阶段和relaying阶段,

submitting阶段的host

submitting阶段的SMTP服务器可以从邮件服务商的官网获取,

QQ邮箱服务器SMTP配置

Gmail邮件服务器SMTP配置

163邮件服务器SMTP配置

relaying阶段的host

relaying阶段的host,我们可以通过nslookup/dig/host等工具,查询邮件服务器的MX记录。

什么是MX记录

MX记录(MX Record)是DNS中的一种资源记录类型,对于someone@example.com这个邮件地址,我们拿example.com用做MX记录的查询,如下图,使用dig工具可以查询qq邮箱的MX记录。从图中可以看到,每个MX记录左边有一个表示优先级的数值,越小表示优先级越大。图中mx3.qq.com的优先级最高,我们可以选择它用于relaying阶段的host,向它投递邮件。relaying阶段如何连接服务器我们就不展开了,本文着重介绍submitting阶段

image.png

SMTP的端口

SMTP相关的端口有这几个,25/587/465

  • 25:该端口在 RFC 821引入,但是该端口默认是不支持SSL并且不用身份认证,而且很多ISP会拦截该端口的请求,所以推荐用于relaying阶段。
  • 587:该端口在 RFC 2476中定义,需要身份认证,推荐用于submitting阶段。
  • 465:这个端口设计的初衷是用于SSL连接,但是最终未被IETF采纳。所以很多文章介绍这个端口时,都建议不使用它。但是其实它还是有使用场景的,很多邮件服务器在submitting阶段,可以通过25/587建立TCP明文连接,然后再通过starttls和服务器进行协商,把TCP明文连接升级为SSL/TLS连接。而如果希望在和服务器第一次建立连接就是一个SSL/TLS连接,则需要使用465端口。 总结一下,submitting阶段有三种和SMTP建立连接的方式,他们分别使用以下端口
    • 使用25/587建立TCP明文连接,进行发信
    • 使用25/587建立TCP明文连接,执行STARTTLS,升级为SSL/TLS连接,进行发信
    • 使用465端口建立SSL/TLS连接,进行发信

SMTP的三种连接方式

接下来介绍如何使用命令行建立这三种连接

TCP连接

在命令行下,可以通过telnet或者nc(netcat)建立该连接 image.png 我们可以发现,可以使用25和587端口建立连接,467则失败,印证了前面提到的,467是用于直接建立SSL/TLS连接的端口。我使用25/587/467尝试了smtp.gmail.com,和smtp.qq.com一样的表现。再尝试smtp.163.com,则只有25可以建立连接,587/467都不行

TCP连接+STARTTLS

虽然字面上STARTTLS只包含了TLS,但是它支持SSL和TLS。我们先通过一张图了解什么是STARTTLS image.png 从图中我们可以了解到,SMTP中使用STARTTLS,需要经历以下几个阶段

  • 建立TCP连接
  • 发起EHLO
  • 从EHLO的返回中确认服务器支持STARTTLS,然后发出STARTTLS命令,等待返回
  • 与服务器协商,把当前连接升级为SSL/TLS连接

我们可以在命令行名模拟这个过程(没错,不要试图用telnet/nc模拟上面的过程,实现不了)

openssl s_client -starttls smtp -connect smtp.gmail.com:25/587

上面这一条命令,主流的邮件服务器都只支持25和587端口。

SSL/TLS连接

直接建立SSL/TLS连接比较好理解,我们可以使用以下命令实现

openssl s_client -connect smtp.gmail.com:465

总结一下,我们如果开发一个SMTP发信SDK,优先使用第三种连接方式,其次是第二种。在命令行下调试SMTP命令时,推荐使用TCP连接方式即可,因为后面两种在命令行下,像qq邮箱,虽然可以建立连接,但是ehlo时会返回5xy的错误码(使用java/nodejs开发的SDK中则表现正常,命令行下为什么ehlo失败我也还不知道原因),163邮箱则一切正常。

第三部分:SMTP的命令和错误码

SMTP协议是通过一系列命令来实现身份认证以及发送邮件。服务器收到每一条命令,都会返回一个2yz~5yz(200多到500多)区间的返回码,不同返回码有不同的含义。SMTP除了提供用于发送邮件的命令,还有一系列工具类型的命令,比如VRFY/EXPN。

SMTP发送一封邮件,可以分为五个阶段,下面是这五个阶段各自使用的SMTP命令。

  • 建立会话:HELO/EHLO
  • 身份认证:AUTH LOGIN/PLAIN/NTLM/etc
  • 发送邮件信封:MAIL FROM,RCPT TO(有多个收件人时,需要执行多次RCPT TO)
  • 发送邮件内容:DATA
  • 关闭会话:QUI

SMTP不同的返回码具备不同的含义

  • 2yz:命令被成功处理
  • 3yz:命令被成功处理,但是服务器需要更多信息才能完成任务,比如auth和data命令就会遇到3yz的返回
  • 4yz:表示服务器发生暂时性的失败,客户端可以进行重试直至成功(2yz)或者失败(5yz)
  • 5yz:发生不可恢复错误

关于SMTP的返回码,也可以阅读RFC中的详细定义:SMTP错误码定义

上面的2yz区间的返回码,有一个比较特殊,是221,它表示连接已断开,发送QUIT指令成功后,会返回221,而其他请求也可能遇到返回221表示连接已断开。

接下来介绍SMTP常用命令。

HELO

helo quinn
250-newxmesmtplogicsvrszc9.qq.com-100.110.16.25-30251559
250-SIZE 73400320
250 OK

HELO都是hello的缩写,在RFC 788被定义,用于建立一次全新SMTP会话。

其中host用于标记客户端的身份,如果不同邮件服务器之间的通信,则填写域名,如果是邮件客户端连接邮件服务器,则可以填写当前客户端设备的hostname。

EHLO

ehlo quinn
250-newxmesmtplogicsvrszc8.qq.com
250-PIPELINING
250-SIZE 73400320
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
250-AUTH=LOGIN
250-MAILCOMPRESS
250 8BITMIME

EHLO的全称是Extended Hello,在RFC 1869中的ESMTP引入,和HELO一样,EHLO也是用于发起一次SMTP会话,不同的是,SMTP服务器收到EHLO后,会返回它支持的一系列扩展特性。

我们可以发现EHLO有多行返回值,我们阅读 RFC 5321 section 4.2.1 最后三段内容,可以看到关于多行返回值的格式定义。多行返回值,每一行都是以数字加-字符开头,而最后一行是以数字加空格开头。这里的数字是SMTP的返回码,多行返回的情况,每一行开头的数字都是一样的。

再来看看EHLO返回的每一行的的含义

250-PIPELINING - 表示支持PIPELINING特性,这是一种可以提高发信速度的机制,可以参考这篇文章 wiki.geant.org/display/pub…

250-SIZE 73400320 表示邮件大小的上限,73400320byte等于70M。

250-STARTTLS 支持starttls特性,可以讲一个明文的socket连接,升级为一个ssl socket,后面我们会介绍。

250-AUTH LOGIN PLAIN XOAUTH XOAUTH2 支持LOGIN PLAIN XOAUTH XOAUTH2这一系列登录方式

250-AUTH=LOGIN:这一行跟上一行的前缀很像,多了一个等于号,它也表示登录方式,不过是一种向后兼容的登录,没有找到任何官方文档描述它,可以参考此处stackoverflow.com/a/48957693/…

AUTH

我们前面提到,只有submitting阶段需要登录认证,SMTP服务器一般都提供多种登录认证方式,以下介绍几种常见方式。

下面介绍的密码可能不是邮箱真实的密码,而是一个登录授权码,需要看不同邮件服务商的规则,比如QQ邮箱可以从这里获取登录授权码:QQ邮箱如何获取授权码

登录时,需要对账号密码进行一次base64,可以在终端获取

echo -n "1970558530@qq.com" | base64
MTk3MDU1ODUzMEBxcS5jb20=
echo -n "你的登录授权码" | base64
xxxxxxxxxxxxxxxxxxxxxxxxxxx==

AUTH LOGIN

// login: 邮箱地址base64,登陆码base64
auth login
334 VXNlcm5hbWU6
MTk3MDU1ODUzMEBxcS5jb20=
334 UGFzc3dvcmQ6
base64ofyourpassword
235 Authentication successful

auth login方式,我们需要输入auth login之后,再输入邮箱地址的base64,再输入秘钥的base64,我们可以看到,前两步的返回码比较特别,是3xy,这个区间表示当前是中间态,需要进一步的指令。

AUTH PLAIN

// plain: 
auth plain `\0account\0password`.toBase64()
235 Authentication successful

RFC 4616中,这种登录方式需要这样拼认证信息 authzidauthcidpasswd 第一个字段可以为空。不过它后面的NUL字符还得保留,然后我们只需要再拼上账号密码,二者中间用NUL字符隔开,拼接结果再进行一次Base64 - ## AUTH NTLM

MAIL FROM

mail from:<1970558530@qq.com>
250 OK 

该命令表示发信人身份。 在RFC 1869中的ESMTP对mail from和rcpt to两条命令进行了扩展,不过目前还没遇到需要使用这些扩展的场景,如果有读者使用过,欢迎留言。下面截图是我在网上的博客看到的使用案例。

image.png

image.png

上面两个案例分别来自这两个链接

在Exchange 伺服器上使用Telnet 测试SMTP 通讯

SMTP Service Extension for Authentication

RCPT TO

该命令不同于mail from,rcpt to可以执行多次,包括to/cc/bcc几种觉得都得使用rcpt to命令。

rcpt to:<742223410@qq.com>
250 OK
rcpt to:<z742223410@163.com>
250 OK

在SMTP命令中,区分不了to/cc/bcc不同的身份,最终需要邮件正文的mime header里的不同字段进行区分。

该指令还可以用于检查服务器是否存在某个邮箱,如果不存在,服务器会返回5yz的错误码,如下图,是我校验某个gmail邮箱的例子

image.png

DATA

data
354 End data with <CR><LF>.<CR><LF>.

执行data指令,表示准备开始发送邮件正文,然后使用.表示发送结束。 data成功返回后,我们就可以输入mime的内容了,

  • xxxx

QUIT

该指令比较简单,用于通知服务器断开连接。

quit
221 Bye.

RSET

表示情况cache,前面输入的信封(mail from、rcpt to),邮件内容(data)都情况,当前会话回到执行登录之后,可以重新写一封新邮件。

rset
250 OK

VRFY

VRFY用于校验服务器是否存在该邮箱账号。出于安全问题,大部分邮件服务器都没提供该指令的功能。如果真的有这个检查邮箱是否存在的情况,可以尝试使用上面提到的rcpt命令。 比如gmail返回252,表示无法VRFY用户

VRFY chenhuazhaoao
252 2.1.5 Send some mail, I'll try my best e20-20020a544f14000000b0032bbe945839si432879oiy.57 - gsmtp

QQ邮箱则返回了502,502表示未实现该功能

VRFY 742223410
502 Invalid input from 183.60.88.5 to newxmesmtplogicsvrszc10.qq.com
VRFY 742223410@qq.com
502 Invalid input from 183.60.88.5 to newxmesmtplogicsvrszc10.qq.com

EXPN

EXPN用于校验服务器是否存在某个邮箱账号或者邮件组。和VRFY一样,目前我也未找到一个服务器用于测试这个指令。理论上它的校验效果如下图。

image.png 图片来自EXPN command—Verify whether a mailbox exists on the local host

NOOP

noop这条命令不起任何作用,可以理解为是一个心跳包。

noop
250 OK from 119.130.139.100 to newxmesmtplogicsvrszc11.qq.com.

使用telnet发送一封邮件

接下来结合以上SMTP命令,使用telnet建立连接,使用SMTP命令发送一封邮件。关于如何建立连接,可以参考 [[SMTP的架构与连接]]

// telnet 链接smtp服务器
➜  ~ telnet smtp.qq.com 25
Trying 183.47.101.192...
Connected to smtp.qq.com.
Escape character is '^]'.
220 newxmesmtplogicsvrszb6.qq.com XMail Esmtp QQ Mail Server.

![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d70ea1490d974d78b371f6dba2b6ce3f~tplv-k3u1fbpfcp-watermark.image?)
// ehlo
ehlo quinn
250-newxmesmtplogicsvrszb6.qq.com
250-PIPELINING
250-SIZE 73400320
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
250-AUTH=LOGIN
250-MAILCOMPRESS
250 8BITMIME

// 登录
auth login
334 VXNlcm5hbWU6
MTk3MDU1ODUzMEBxcS5jb20= // base64的账号
334 UGFzc3dvcmQ6
dddddddddddddddddddddddd= // base64的密码
235 Authentication successful

// 发件人
mail from:<1970558530@qq.com>
250 OK

// 收件人/抄送/密送
rcpt to:<742223410@qq.com>
250 OK
rcpt to:<z742223410@163.com>
250 OK

// 输入data指令,表示开始要发送邮件内容
data
354 End data with <CR><LF>.<CR><LF>.
Subject: test
From: 1970558530@qq.com

This is a test message.
.
250 OK: queued as.

// 断开连接
quit
221 Bye.
Connection closed by foreign host.

第四部分:Mime消息格式

现代SMTP是以Mime格式来发送一封邮件的,接下来简单介绍一下Mime消息格式。

Mine

Mine的全称是Multipurpose Internet Mail Extensions,是一种消息格式,它的设计初衷是作为电子邮件的消息格式,但是其中的Content-Type也被引用到HTTP协议中。 Mime消息的格式主要在这五篇RFC中定义

Mime的结构可以分为header和body两块,中间由一个空行隔开。body可以分为多个part,如下图,是一个Mime消息结构的概览。 、 image.png

Mime header

这里说的header,大部分存在于Mime消息的header区域。有一小部分既存在header区域也存在于body区域,比如Content-Type。而有一部分header只存在于Body,比如Content-Transfer-Encoding。接下来介绍常见的header字段。

MIME-Version

该字段看起来是为了支持不同版本的兼容问题,但是截止目前为止,还是1.0版本。所以目前看到的Mime消息,都是这样

Mime-Version: 1.0

Message-ID

Message-ID是Mime消息的唯一标记,如下格式

Message-ID: <42698ee0-ce5f-3a69-ee96-e394e0992e57>

理论上该字段不会被修改,但是在网上看到说有一些邮件厂商会修改该字段。以及官方文档对这个字段的描述中,提到一个Mime消息可能存在多个版本,他们的Message-ID会不一样,目前还没遇到过这种场景,使得Mime消息存在多个版本,如果读者遇到也欢迎留言介绍一下。以下是 RFC 5322 - 3.6.4 的原文

A message identifier pertains to exactly one version of a particular message; subsequent revisions to the message each receive new message identifiers.

In-Reply-To

该字段用于回复邮件的场景,表示被回复邮件的Message-ID。

References

该字段也是用于回复邮件的场景,不过它是用于邮件会话。比如A邮件被B邮件回复,B邮件被C邮件回复。

B邮件的in-Reply-To和References都写A邮件的Message-ID。

C邮件in-Reply-To应该写B的Message-ID,C邮件的References应该写A邮件的Message-ID。

Content-Type

Content-Type一行的格式如下

Content-Type: [type]/[subtype]; parameter

而其完整定义在MIME的五篇RFC文档中的第一篇, RFC 2045-5。如下图

image.png

RFC的很多文档确实不是人读的,我们简单解释一下上面的定义。type可以有两种类型discrete-type(离散类型)和composite-type(复合类型)。

discrete-type可以有以下几种,text/image/audio/video/application/extension-token。而extension-token其实是一个扩展类型,可以来自愈IANA新定义的类型,也可以是开发者自己顶一顶额私有类型,私有类型只需要使用"x-"或者”X-“开头即可。

composite-type主要有message和mutipart两种,同样也可以有extension-token。

type/subtype

  • 以下列举常见的type/subtype组合
    • text/plain(纯文字)
    • text/html(HTML文件)
    • application/xhtml+xml(XHTML文件)
    • image/gif(GIF图片)
    • image/jpeg(JPEG图片)【PHP中为:image/pjpeg】
    • image/png(PNG图片)【PHP中为:image/x-png】
    • audio/mpeg(MP3音频)
    • audio/aac(AAC音频)
    • video/mpeg(MPEG视频)
    • video/mp4(MPEG-4视频)
    • application/octet-stream(任意的二进制资料)
    • application/pdf(PDF文件)
    • application/msword(Microsoft Word文件)
    • application/vnd.openxmlformats-officedocument.wordprocessingml.document(Microsoft Word 2007文件)
    • application/vnd.wap.xhtml+xml (wap1.0+)
    • application/xhtml+xml (wap2.0+)
    • message/rfc822(RFC 822形式)
    • multipart/alternative(HTML邮件的HTML形式和纯文本形式,相同内容使用不同形式表示)
    • application/x-www-form-urlencoded(使用HTTP的POST方法送出的表单)
    • multipart/form-data(同上,但主要用于表单送出时伴随文件上传的场合)

parameter

最后一个参数,parameter,理论上可以是任意key-value接口最常见是这两个参数 - boundary - 用于type=mutipart的场景,用于表示各个part之间的分隔符 - charset - 如果type=text的场景,缺省值是ASCII,其他可能值有"ISO-8859-1"、"UTF-8"、"GB2312"等等。

Content-Transfer-Encoding

电子邮件不支持传输非ASCII码,这个字段是为了声明一种编码方式,把数据编码成可打印的ASCII吗字符进行传输。常见的方式有以下几种 - 7bit - 8bit - binary - quoted-printable - base64

Content-Disposition

Disposition这个词的意思是”处置“的意思,这个字段用于描述某个附件应该被怎么处置,一般有两种可选项,attachment和inline,在邮件里分别表示附件和正文中的内嵌图片。

如下,是一个常见的使用,其中描述了处置方式是Inline,文件名是abc.png。如果文件名包含非ASCII码,需要使用Encoded-Word的方式进行编码。

Content-Disposition: inline; filename="abc.png"

Content-Id

该字段是内嵌图片/附件的唯一标记,在SMTP发信中,正文中的内嵌图片的img标签的src属性,就写content-id的值即可。

<img src="cid:db81ee5c-1bc5-8c9c-a7fe-23b10efebe9e">

Mime body

邮件的正文是一个树形结构,有三个常见层级 - mutipart/mixed:该层级包含附件,以及内部的related结构 - mutipart/related:该层级包含内嵌图片,以及内部的alternative结构 - mutipart/alternative:该层级包含纯文本的正文和HTML格式的正文 三者的关系如下图

image.png

这里有一点要注意,在实现SMTP SDK时,我尝试过无论是否有内嵌图片和附件,我都建立三个层级的树形结构,往大部分邮件服务器发送邮件都是成功的,但是往outlook发送则会遇到失败。最终改为按需建立层级,比如,如果有内嵌图片,没有附件,则只建立related+alternative两层。如果只有正文,则只建立alternative一层。

Encoded-Word

Mime的header(可以是整个message的header也可以是body的header)只能使用ASCII码,如果是非ASCII码,需要使用Encoded-Word进行编码。

RFC 2047中对Encoded-Word进行了定义

encoded-word = "=?" charset "?" encoding "?" encoded-text "?="

篇幅问题,encode-word在这里就不继续详细展开介绍了。

结语

SMTP的RFC文章中写了一句这样的话。我觉得说的挺好的,可能这就是大道至简,虽然SMTP是一个简单协议,却也算生命力顽强,至今被使用了长达半个世纪之久。就以这句话做结尾吧。

SMTP's strength comes primarily from its simplicity. Experience with many protocols has shown that protocols with few options tend towards ubiquity, whereas protocols with many options tend towards obscurity.