前言:
email是工作生活中经使用的通讯工具, 笔者很是好奇它的工作原理, 故有此系列。这个系列聚焦以下几点:
本文讨论SMTP协议, 即发信协议。POP3协议后续文章讨论。
邮件发送的整体架构
开局一张图:

SMTP netcat初体验
SMTP即Simple Mail Transfer Protocol, 是用来发邮件的协议。要使用它很简单, 首先需要tcp连接上相应的SMTP服务器, 这里我们用nc进行连接, (此处注意别用smtp.qq.com, netcat发送的内容是\n结尾的, 而qq邮箱不能接受这种非标准的兼容选项, 因此我们选择gmail):
1 2
| > nc smtp.gmail.com 587 220 smtp.gmail.com ESMTP n8-20020a170902e54800b001a69dfd918dsm13220791plf.187 - gsmtp
|
服务端的输出格式是: 错误代码[空格]内容\r\n, 220是Service ready的意思, 要查询更多错误代码, 参阅这里。
接下来我们需要键入ehlo得到这个邮件服务器的参数。客户端连接上SMTP服务后先执行ehlo命令, 为了便于查看输入和输出, 我用[]
框住了用户输入:
1 2 3 4 5 6 7 8 9 10 11
| > nc smtp.gmail.com 587 220 smtp.gmail.com ESMTP n8-20020a170902e54800b001a69dfd918dsm13220791plf.187 - gsmtp [ehlo mycom] 250-smtp.gmail.com at your service, [183.230.12.212] 250-SIZE 35882577 250-8BITMIME 250-STARTTLS 250-ENHANCEDSTATUSCODES 250-PIPELINING 250-CHUNKING 250 SMTPUTF8
|
ehlo命令后面可以跟任何字符串, 标准说这个字符串是标识客户端身份的, 但是实际上没啥用, 按惯例可以将它设置为计算机的hostname。我们可以观察到最后一行是250[空格]SMTPUTF8, 前面都是250[横线]xxx, 只有最后一行输出是用空格分割的, 前面的都是用横线分割的, 这是用于判断服务端输出结束的凭据。
服务端读到ehlo命令后会响应自己支持的选项, 250是响应代码, 代表Requested mail action okay, completed。下面解释下一些选项:
- SIZE: 邮件数据的最大字节数
- STARTTLS: 由于我们这里是tcp裸连接, 没有任何加密, 这个选项代表服务端允许tls握手
在连接到smtp服务后, 客户端首先需要ehlo, 然后可选地选择是否进行tls握手加密。qq发邮件可以自行选择是否进行tls/ssl加密, 但是gmail强制要求进行tls握手之后才能发送, 这样就不会被运营商监听到邮件内容。
gmail强行要求发信前进行tls握手, 因此netcat这个小工具没法满足我们的要求, 要进行握手, 使用STARTTLS
命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| nc smtp.gmail.com 587 220 smtp.gmail.com ESMTP n8-20020a170902e54800b001a69dfd918dsm13220791plf.187 - gsmtp [ehlo mycom] 250-smtp.gmail.com at your service, [183.230.12.212] 250-SIZE 35882577 250-8BITMIME 250-STARTTLS 250-ENHANCEDSTATUSCODES 250-PIPELINING 250-CHUNKING 250 SMTPUTF8 [STARTTLS] 220 2.0.0 Ready to start TLS ... 这里我们需要用程序进行tls协商了, 然而nc没法搞
|
关于升级到tls, 这里给个图吧:

使用go编写的程序与qq smtp服务器通讯
之前提到nc发送的字符串是用\n结尾的, qq邮箱不支持, 因此我们自己写个\r\n结尾的nc版本连接qq服务器, 并用它发送邮件试试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| package main
import ( "bufio" "fmt" "net" "os" )
func main() { conn, err := net.Dial("tcp", "smtp.qq.com:587") if err != nil { panic(err) }
go func() { for { buf := make([]byte, 256) n, err := conn.Read(buf) if err != nil { panic(err) }
_, err = os.Stdout.Write(buf[:n]) if err != nil { panic(err) } } }()
scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { _, err := conn.Write([]byte(fmt.Sprintf("%s\r\n", scanner.Text()))) if err != nil { panic(err) } }
select {} }
|
为什么我们又切换到了qq邮箱? 因为gmail发邮件强制要求先ehlo后starttls, 我不想进行tls握手(写代码太麻烦了)。因此我们用qq邮箱来做, 它不强制要求使用tls通讯。
下面我们使用AUTH LOGIN命令进行身份认证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| > echo -n "1494645263@qq.com" | base64 MTQ5NDY0NTI2M0BxcS5jb20= > echo -n "你从qq邮箱设置中拿到的授权码" | base64 cXJjY2xxxxxxxmVxYmFkYg== > go run . 220 newxmesmtplogicsvrszc2-1.qq.com XMail Esmtp QQ Mail Server. [EHLO mycom] 250-newxmesmtplogicsvrsza12-0.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 [MTQ5NDY0NTI2M0BxcS5jb20=] 334 UGFzc3dvcmQ6 [cXJjY2xxxxxxxmVxYmFkYg==] 235 Authentication successful
|
VXNlcm5hbWU6进行base64解码后就是Username:
, UGFzc3dvcmQ6则是Password:
, 要base64解码可以用到echo -n "VXNlcm5hbWU6" | base64 -d
证实。
然后就能进行发信操作了, 我们从上面的235 Authentication successful
处继续:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 235 Authentication successful [MAIL FROM:<1494645263@qq.com>] 250 OK [RCPT TO:<3402002560@qq.com>] 250 OK [DATA] 354 End data with <CR><LF>.<CR><LF>. [Subject: Hello World] [From: 旭旭科技 <1494645263@qq.com>] // 这里允许署名一下, 接收者能看到我们的署名, 但也允许不署名 From: <xxx@qq.com> 就是不署名的情况 [To: <3402002560@qq.com>] // To也能署名, 这里不署 [] // 注意这是一个空行 [Hello World] [.] [] // 注意这是一个空行 250 OK: queued as.
|
然后我们就能收到Hello World的讯息了, 是不是很简单😃。
如果我们要复用这个连接, 要用它发送多个讯息, 这时使用RSET命令清空之前的缓存, 这会清空之前的MAIL FROM
, RCPT TO
命令。RSET
后就能重新进行发送邮件操作了。
最后, 要退出, 使用QUIT
命令, 服务端响应221 Service closing transmission channel后会close socket:
关于抄送和密送
一封邮件可以发给多个人, 我们可以通过多次RCPT
命令实现。以前我还以为一封邮件只能有一个收信人, 看来我对email的认知还是太少了😅。
此外, 一封邮件也能抄送给多个人, 比如我们邮件发送给了Bob并抄送给了Alice, Bob和Alice都能收到这封邮件, 并且Bob和Alice都知道此邮件的接收者是Bob, 也都知道这封邮件被抄送给了Alice。那么这个功能有啥用呢, 我认为它是用来区分收信者与旁边者的一个凭据, 强调接收方是Bob而已。
一封邮件也能密送给多个人, 区别就在密送的对象不会公开给其他人, 比如我们发送邮件给Bob并密送给Alice, Bob和Alice都能收到这封邮件, 但是Bob却不知道这封邮件密送给了Alice, 而Alice知道这封邮件来自我, 发送给Bob。密送很适合打小报告。
如果要发给Bob(110@qq.com), 并抄送给Alice(220@qq.com)那么这样做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| [MAIL FROM:<1494645263@qq.com>] 250 OK [RCPT TO:<110@qq.com>] 250 OK [RCPT TO:<220@qq.com>] 220 OK [DATA] 354 End data with <CR><LF>.<CR><LF>. [Subject: Hello World] [From: 旭旭科技 <1494645263@qq.com>] [To: <110@qq.com>] [Bcc: <220@qq.com>] [] // 这是空行 [Hello World] [.] [] // 这是空行 250 OK: queued as.
|
然后Bob收到的邮件就能看到发信人是1494645263@qq.com, 但他看不到密送给了Alice。Alice收到的邮件能看到发信人是我, 收信人是Bob, 密送给了Alice。
如果一封邮件被密送给了A, B, C三个人, A不会知道这封邮件被密送给了B和C。密送是很隐秘的, 它就是用来打小报告的。
使用smtp发送邮件的时候, 邮件也许不会保存在“已发送”里面, 如果想要知道自己的程序发了什么, 在qq邮箱的设置->账户中勾选上“SMTP发信后保存到服务器”。
data的的格式: mime
之前我们data的正文只是“Hello World”几个字符, 实际上使用MIME(Multipurpose Internet Mail Extensions), 可以包含多种多样的媒体格式。

这里请注意, MIME-Version是个兼容性选项, 但是直到今天它还是1.0, 因此我们可以认为它永远都是1.0。
mime消息包含mime header和mime body, mime body又分为了body header和body payload。boundary是一串随机的字符串, 用于放在body之间做分割。
邮件的mime是多级的内嵌结构:

举个例子, 如果我们要发送一串文字, 并且包含一个图片附件, 那么data就大概是这样的(不能进行缩进, 但是为了可读性, 我手动进行了缩进):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| From: from@qq.com To: dest@qq.com Subject: 测试邮件 Content-Type: multipart/mixed; boundary"X_NextPart_A"
This is a multi-part message in MIME format
--X_NextPart_A Content-Type: multipart/alternative; boundary="X_NextPart_B"; --X_NextPart_B Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64
一串文本的base64编码, 能够通过cid:随机的id.jpg引用附件的真实url
--X_NextPart_B Content-Type: text/html; charset="utf-8" Content-Transfer-Encoding: base64
一串文本的html, 能够通过cid:随机的id.jpg引用附件的真实url
--X_NextPart_B
--X_NextPart_A Content-Type: image/jpeg; Content-Disposition: attachment; filename="avatar.jpg" Content-ID: <随机的id.jpg> Content-Transfer-Encoding: base64
图片的base64编码
--X_NextPart_A
|
具体的内容可以指定编码, base64是常见的编码, 使用这种编码的原因是它能把特殊符号转化成常见的符号, 不会与分割字符串冲突。此处, 我们规定了一个附件, 它下载下来的文件名是avatar.jpg。Content-Disposition: attachment代表它是附件。Content-ID可以指定它的引用别名, 可以在其它地方使用cid:随机的id.jpg将其替换成图片的真实url。
multipart/alternative
的语义是: 其中内嵌的多条内容都能等价地替换。邮件客户端只能在里面选择其中之一进行展示。
网络中常见邮件没有附件, 图片也是从第三方链接引过来的(并使用html的img标签显示), 这时mime header一般都不是mixed, 它一般就是alternative的。
一般邮件的alternative包含两种内容, 一种是html, 一种是plain。有的设备并不能读取html的内容, 因此可以选择展示plain的内容。大多数邮件都会生成html和plain两种可替代内容, 提高邮件的普适性。
下面就是网络中常见邮件的格式, 我们在网络上收到的图片一般都是这样的, 不包含附件, 也没有内嵌图片, 如果要显示图片, 一般都是引用的外链:
它有这样的分层结构:
1 2 3
| alternative text/plain text/html
|
举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| From: from@qq.com To: dest@qq.com Subject: 测试邮件 Content-Type: multipart/alternative; boundary="X_NextPart_A"
This is a multi-part message in MIME format
--X_NextPart_A Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64
一串文本的base64编码, 不能显示图片
--X_NextPart_A Content-Type: text/html; charset="utf-8" Content-Transfer-Encoding: base64
一串文本的html, 可以用<img>标签, src=外部链接引入外部的图片
--X_NextPart_A
|
常见的邮件一般只包含文字和图片, 它一般不会把图片的二进制数据发送给邮件服务器, 而是直接引第三方的图片, 图片可以放在图床或对象存储里边, 上传图片的二进制成本太高。
下面是邮件的一般需求, 其分层结构如下:
需求1(最复杂的情形):
- 具有文本和html版本
- 具有内嵌的图片
- 具有附件
1 2 3 4 5 6 7
| multipart/mixed multipart/alternative text/plain multipart/related text/html inline image attachment
|
例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| From: from@qq.com To: dest@qq.com Subject: 测试邮件 Content-Type: multipart/mixed; boundary="X_NextPart_A"
This is a multi-part message in MIME format
--X_NextPart_A Content-Type: multipart/alternative; boundary="X_NextPart_B"
--X_NextPart_B Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64
文字的base64编码, 能够通过cid:随机的id2.jpg引用图片的真实url
--X_NextPart_B Content-Type: multipart/related; boundary="X_NextPart_C" --X_NextPart_C Content-Type: text/html; charset="utf-8" Content-Transfer-Encoding: base64
html内容的base64编码, 能够通过cid:随机的id1.jpg或cid:随机的id2.jpg引用图片的真实url
--X_NextPart_C Content-Type: image/jpeg; Content-Disposition: inline; Content-ID: <随机的id1.jpg> Content-Transfer-Encoding: base64
图片1的base64
--X_NextPart_C
html的base64编码
--X_NextPart_B
--X_NextPart_A Content-Type: image/jpeg; Content-Disposition: inline; Content-ID: <随机的id2.jpg> Content-Transfer-Encoding: base64
图片2的base64
--X_NextPart_A
|
需求2(网络邮件最常见的形式):
- 具有文本和html版本
- 没有内嵌图片和附件
- 图片是引用的外链
1 2 3
| multipart/alternative text/plain text/html
|
需求3:
- 具有文本和html版本
- 只有内嵌图片, 没有附件
1 2 3 4 5 6
| multipart/alternative text/plain multipart/related text/html inline image inline image
|
需求4:
- 具有文本和html版本
- 有附件, 没有内嵌图片
1 2 3 4 5
| multipart/mixed multipart/alternative text/plain text/html attachment
|
相关参考来自这里。
SMTP协议总结
协议本身不复杂, 复杂的是mime消息数据的规则。上面提到的提示就是smtp的所有主干部分了, 下面列举一些其它的命令:
- HELO: 同EHLO, 但是EHLO是HELO的超集, 它返回了邮件服务器的更多拓展参数, 一般不用HELO。
- VRFY: 检查服务器是否存在此邮箱账号, 这个选项一般不被邮箱服务器支持, 不论是gmail还是qq都没有支持。这是为了安全。
- NOOP: 这个命令可以用于心跳检测, 对端总是回应250错误码, 可以用来保活。
此外, 如果我们用smtp协议发邮件, 如果在data命令后发内容中途出现了socket错误, 此时我们无法断定邮件是否发出。如果程序选择重连smtp服务器并重新发送, 可能导致同样的邮件被发出两次。这是不可避免的。smtp协议是简单的, 没法预防这种重放问题。如果要解决, 我的想法是实现一个更牛的协议, 做一个二阶段提交。但是这不是刚需, 一个邮件被发两次也不是什么大错, 因此smtp选择忽视这个问题。
发送之后?
邮件递交给邮箱服务器后, 由邮件服务器递交给另外一个邮件服务器的过程叫relaying。此时邮件服务器收到邮件后会尽最大努力发出。但是如果发不出, 比如网络问题或者根本没有这个邮箱, 邮件就会被“退回”。
参考文献