关于email的一切(1)-SMTP发信

前言:

email是工作生活中经使用的通讯工具, 笔者很是好奇它的工作原理, 故有此系列。这个系列聚焦以下几点:

  • SMTP协议
  • POP3/IMAP4协议

本文讨论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)
}

// conn reader
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)
}
}
}()

// stdin reader
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:

1
2
[QUIT]
221 Bye.

关于抄送和密送

一封邮件可以发给多个人, 我们可以通过多次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), 可以包含多种多样的媒体格式。

data的结构

这里请注意, 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(最复杂的情形):

  1. 具有文本和html版本
  2. 具有内嵌的图片
  3. 具有附件
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(网络邮件最常见的形式):

  1. 具有文本和html版本
  2. 没有内嵌图片和附件
  3. 图片是引用的外链
1
2
3
multipart/alternative
text/plain
text/html

需求3:

  1. 具有文本和html版本
  2. 只有内嵌图片, 没有附件
1
2
3
4
5
6
multipart/alternative
text/plain
multipart/related
text/html
inline image
inline image

需求4:

  1. 具有文本和html版本
  2. 有附件, 没有内嵌图片
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。此时邮件服务器收到邮件后会尽最大努力发出。但是如果发不出, 比如网络问题或者根本没有这个邮箱, 邮件就会被“退回”。

参考文献