前段时间事情太多了,忙着写毕业论文,考试,然后又被抽到了盲审,不过好在有惊无险,最后也在学院提交三月中旬申请答辩成功,如果盲审顺利的话,4月份就可以毕业了。不过这段时间总算可以看代码、看书了,感觉自己操作系统方面有些不扎实,索性买了本孙钟秀的《操作系统教程》看,之后顺便阅读和分析了jwSMTP源码,这里写篇文章记录下。本文不想对代码细节作太多分析,因为代码比较好读,并且文章末尾我会放出我注释过的源码链接,所以此文多介绍下原理吧。
jwSMTP
jwSMTP是一个由C++编写的发送邮件的库,支持Linux、Windows等平台。可使用HTML或纯文本方式发送邮件。也可添加附件,支持多个收件人。并且支持LOGIN和PLAIN两种服务器验证方式。
两种调用方式
第一种方式
1
2
3
4
5
6
7
mailer mail(“myfriend@friend .com”,
“someone@somewhere .net”,
“There is always room for FooBar”,
“Foo\nBar”,
“ns.somewhere.net”);
mail.send( );
第二种方式
1
2
3
4
5
6
7
8
mailer mail(“myfriend@friend .com”,
“someone@somewhere .net”,
“There is always room for FooBar”,
vec,
“mail.somewhere.net”,
mailer::SMTP_PORT,
false );
主要区别是一个查询MX record,一个不查询MX record,直接发送给SMTP Server。
base64编码
Base64是网络上最常见的用于传输8Bit字节代码的编码方式之一,设计此种编码是为了使二进制数据可以通过非纯8bit的传输层传输,Base64编码可用于在HTTP环境下传递较长的标识信息,另一方面,采用Base64编码具有不可读性,即所编码的数据不会被人用肉眼所直接看到。 电子邮件的主题,MIME都会用到base64编码。我们现在说下其原理:
Base64编码方法:
base64的编码都是按字符串长度,以每3个8bit的字符为一组,然后针对每组,首先获取每个字符的ASCII编码,然后将ASCII编码转换成8bit的二进制,得到一组3*8=24bit的字节,然后再将这24bit划分为4个6bit的字节,并在每个6bit的字节前面都填两个高位0,得到4个8bit的字节,然后将这4个8bit的字节转换成10进制,对照Base64编码表,得到对应编码后的字符。
不是3的整数倍的,需要补齐而出现的0,转化成十进制的时候就不能按常规用base64编码表来对应,可以理解成为一种特殊的“异常”,编码应该对应“=”。 代码base64.cpp/base64.h是对Base64编码的实现,更多原理请参考下参考链接关于base64编码的原理与实现一文。打开一封Email,查看其原始信息,一般为如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Date: Thu, 25 Dec 2014 06 :33 :07 +0800
To: "B" @126 .com>
Subject:
X -mailer: Foxmail 5.0 beta2 [cn]
Mime-Version: 1.0
Content-Type: text/plain
charset="gb2312"
Content-Transfer-Encoding: base64
xOO6w6OsU25haVgNCg0KoaGhodXiysfSu7j2QmFzZTY0tcSy4srU08q8/qOhDQoNCkJlc3QgV2lz
aGVzIQ0KIAkJCQkNCqGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaEgICAgICAgICAgICAgICBl
U1g/IQ0KoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoSAgICAgICAgICAgICAgIHNuYWl4QHll
YWgubmV0DQqhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhICAgICAgICAgMjAwMy0x
Mi0yNQ0K
程序流程
程序一般为先设置发件人信息,之后设置收件人信息,对应的函数为setsender()和addrecipient()函数,此处没什么可说的。之后是setmessage/setmessageHTML函数,两者的主要区别是不是需要base64编码,方法前面已说,此处主要说下checkRFCcompat()函数,此函数主要功能是:将消息结尾改为CRLF(\r\n)形式,之后注意此处:
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
if (message.size() == 1 ) {
if (*(message.begin ()) == '.' )
message.push_back('.' );
}
else if (message.size() == 2 ) {
if (*(message.begin ()) == '.' ) {
it = message.begin ();
it = message.insert(it, '.' );
}
}
else {
if (*(message.begin ()) == '.' ) {
it = message.begin ();
it = message.insert(it, '.' );
}
for (it = message.begin ()+2 ; it != message.end (); ++it) {
// follow the rfc. Add '.' if the first character on a line is '.'
if (*it == '\n' ) {
if ( ((it + 1 ) != message.end ()) && (*(it +1 ) == '.' ) ) {
it = message.insert(it + 1 , '.' );
++it; // step past
}
}
}
}
此处是根据RFC2821 (SMTP协议)的4.5.2 Transparency编写的,内容为下:
Before sending a line of mail text, the SMTP client checks the first character of the line. If it is a period, one additional period is inserted at the beginning of the line.
When a line of mail text is received by the SMTP server, it checks the line. If the line is composed of a single period, it is treated as the end of mail indicator. If the first character is a period and there are other characters on the line, the first character is deleted.
然后就是每一行消息不能超过1000个字符,见RFC2821的text line小节。
之后的一些setsubject、setserver、addrecipent等等函数,都不做解释了,都是用来添加/删除主机、设置服务器、增加/删除收件人列表相关的,很好明白。我们重点说下邮件发送函数send()里的operator()()函数,如果lookupMXRecord为真,就调用gethostaddresses()函数,用来查询MX记录,这涉及到DNS协议相关知识,请查DNS小节。如果为fasle,则直接连接SMTP server,operator()()和makesmtpmessage()函数主要是完成了如下流程(并不完全一致,仅参考):
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
C: telnet smtp.163.com 25 (连接到163的SMTP服务器,协议规定SMTP服务器的端口号为25)
S: Trying 202.108.5.83...
Connected to smtp.163.split.netease.com.
Escape character is '^]'.
220 163.com Anti-spam GT for Coremail System (163com[071018]) (220 表示连接成功)
C: HELO smtp.163.com (协议规定的握手过程,格式为HELO + 服务器名称)
S: 250 OK (250 表示握手成功)
C: AUTH LOGIN (AUTH LOGIN 是用户登录命令)
S: 334 dXNlcm5hbWU6 (334表示服务器接受)
C: tommy_mail (输入明文用户名)
S: 535 Error: authentication failed (服务器拒绝,因为SMTP要求用户名和密码都通过64位编码后再发送)
C: AUTH LOGIN (重新要求SMTP登录)
S: 334 dXNlcm5hbWU6
C: dG9tb* * * * * FpbA== (用编码后的内容发送)
S: 334 UGFzc3dvcmQ6 (334表示接受)
C: * * * * * * * * aXZldXA= (编码后的密码)
S: 235 Authentication successful (235 登录成功)
C: MAIL FROM:
(MAIL FROM:
格式,用来记录发送者)S: 250 Mail OK (250 系统常用确认信息)
C: RCPT TO: (接收者邮箱,可多次使用以实现发送给多个人)
S: 250 Mail OK
C: DATA (DATA明令表示以下为邮件正文)
S: 354 End data with .
C: TO:11
FROM:22
SUBJECT:TEST MAIL SMTP (邮件主题)
hello world (空一行写邮件正文)
. (正文以.结束)
S: 250 Mail OK queued as smtp3,DdGowLBLAjqD6_JIg1hfBA==.63235S2 1223879684 (服务器接受)
C: noop (空操作,延迟退出时间)
S: 250 OK
C: quit (退出SMTP服务器连接)
S: 221 Bye
DNS协议
调用gethostaddresses()函数,用来查询MX记录,这涉及到DNS协议相关知识,本函数可以使用nslookup命令模拟,我本地模拟如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
imlinuxer@imlinuxer:~$ nslookup
> set type= mx
> mail.qq.com
Server: 127.0 .1.1
Address: 127.0 .1.1
Non-authoritative answer:
*** Can't find mail.qq.com: No answer
Authoritative answers can be found from:
mail.qq.com
origin = qq.com
mail addr = webmaster.qq.com
serial = 1186990741
refresh = 300
retry = 600
expire = 86400
minimum = 86400
DNS查询的过程一般是:客户向DNS服务器的53端口发送UDP报文,DNS服务器收到后进行处理,并把结果记录仍以UDP报文的形式返回过来。除了报文头是固定的12字节外,其他每一部分的长度均为不定字节数。我们只关心的是报文头、问题、回答这三个部分 DNS的协议为rfc1035 ,但是枯燥难懂,可以查看参考链接的DNS消息格式,比较容易理解。
1
unsigned char dns[512 ] = {1 ,1 , 1 ,0 , 0 ,1 , 0 ,0 , 0 ,0 , 0 ,0 };
比如此处即为DNS Header消息头部信息。 之后几段代码是请求部分格式,代码里我已详细注释,之后发送请求,解析应答即可。
SMTP验证方式
比较简单,原理见此:SMTP验证方式种类(LOGIN、PLAIN、CRAM-MD5) jwSMTP代码只实现了两种验证方式:LOGIN和PLAIN。
说的有点多了,感觉很多原理都解释了,逻辑稍微有一点混乱,主要是自己不是那么擅长组织语言,如果读者有兴趣,可以多了解下原理,知道SMTP和DNS原理,基本上代码就不需要多看了。
最后扔出我的中文源码剖析代码,在Github上:
https://github.com/armsword/Source/tree/master/jwSMTP
参考链接
关于base64编码的原理与实现 POP3 SMTP协议分析学习笔记 DNS消息格式