IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    jwSMTP源码剖析

    armsword发表于 2015-02-02 12:36:13
    love 0

    前段时间事情太多了,忙着写毕业论文,考试,然后又被抽到了盲审,不过好在有惊无险,最后也在学院提交三月中旬申请答辩成功,如果盲审顺利的话,4月份就可以毕业了。不过这段时间总算可以看代码、看书了,感觉自己操作系统方面有些不扎实,索性买了本孙钟秀的《操作系统教程》看,之后顺便阅读和分析了jwSMTP源码,这里写篇文章记录下。本文不想对代码细节作太多分析,因为代码比较好读,并且文章末尾我会放出我注释过的源码链接,所以此文多介绍下原理吧。

    jwSMTP

    jwSMTP是一个由C++编写的发送邮件的库,支持Linux、Windows等平台。可使用HTML或纯文本方式发送邮件。也可添加附件,支持多个收件人。并且支持LOGIN和PLAIN两种服务器验证方式。

    两种调用方式

    第一种方式

    1
    2
    3
    4
    5
    6
    7
    mailer mail(“myfriend@friend.com”, // who the mail is too
    “someone@somewhere.net”, // who the mail is from
    “There is always room for FooBar”, // subject for the email
    “Foo\nBar”, // content of the message
    “ns.somewhere.net”); // the nameserver to contact
    // to query for an MX record
    mail.send( );

    第二种方式

    1
    2
    3
    4
    5
    6
    7
    8
    mailer mail(“myfriend@friend.com”, // who the mail is too
    “someone@somewhere.net”, // who the mail is from
    “There is always room for FooBar”, // subject for the email
    vec, // content of the message
    “mail.somewhere.net”, // the smtp server to mail to
    mailer::SMTP_PORT, // default smtp port (25)
    false); // do not query MX records,
    // mail directly to mail.somewhere.net

    主要区别是一个查询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
    From: A
    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编写的,内容为下:

    1. 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.

    2. 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@11 (发送给谁,这里可自由撰写,也是伪造邮件的一个入口,欺骗一般人可以,但会读源码的人欺骗不了)
    FROM:22@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#53
    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消息格式



沪ICP备19023445号-2号
友情链接