三、C#下创建基于TcpClient发送邮件组件
在上一节在Dos命令行下测试SMTP服务器连接时,已经使用了SMTP的部分命令,但是当时无法对信息进行编码和解码,也就无法继续进行身份验证和信息传输。在.Net库中,我们可以使用System.Net.Sockets.TcpClient类实现上一节发送邮件组件的同样功能(其实OpenSmtp也同样是基于这个组件开发的),这里仅作为测试以充分了解SMTP协议规范。
1、SMTP命令及其响应
邮件发送的基本过程是一问一答的方式与服务器交流的,所以我们需要先了解关于SMTP命令及其响应,详情请查阅RFC821。
常用的SMTP/ESMTP命令(命令的执行有一定顺序)包括:
命令 | 作用 |
HELO | 使用标准的SMTP,向服务器标识用户身份 |
EHLO | 使用ESMTP,向服务器标识用户身份,针对支持ESMTP的服务器 |
STARTTLS | 启用TLS,将普通连接提升为安全连接,针对支持STARTTLS 的服务器 |
AUTH LOGIN | 开始认证程序 |
MAIL FROM | 指定发件人地址 |
RCPT TO | 指定单个邮件接收人;可以有多个RCPT TO |
DATA | 传输数据,服务器接收到<CRLF>.<CRLF>就停止接收数据 |
VRFY | 验证指定的用户/邮箱是否存在,常被禁用 |
EXPN | 验证指定的邮箱列表是否存在,常被禁用 |
HELP | 查询服务器支持的命令 |
NOOP | 无操作,服务器响应 250 OK |
RSET | 重置会话,取消当前传输,服务器响应 250 OK |
QUIT | 结束会话 |
常见SMTP服务器响应:
500 语法错误,未知命令
501 参数语法错误
502 命令未执行
503 命令顺序错误
504 参数未赋值
211 系统状态,或者系统帮助响应
214 帮助信息
220 <domain> 服务就绪
221 <domain> 服务正在关闭传输通道
421 <domain> 服务不可用,正在关闭传输通道
250 操作完成
251 非本地用户;将转发至 <forward-path>
450 操作未完成:邮箱不可用[例如:邮箱忙]
550 操作未完成:邮箱不可用[例如:邮箱不存在,不可访问]
451 操作取消:处理过程中出错
551 非本地用户;请尝试 <forward-path>
452 操作未完成:系统存储空间不足
552 操作取消:超过分配的存储空间
553 操作未完成:邮箱名不可用[例如:邮箱名语法错误]
354 开始邮件数据输入,以 <CRLF>.<CRLF> 结束
554 操作失败
所以如果我们在控制台输出邮件发送全过程应该大体如下(不同服务器反馈的信息不同,且如果发送带多媒体邮件结构更为复杂),其中Receive是服务器接收数据,Send是向服务器发送数据:
Send: EHLO g1 Receive: 250-mail 250-PIPELINING 250-AUTH LOGIN PLAIN 250-AUTH=LOGIN PLAIN 250-STARTTLS 250 8BITMIME Send: AUTH LOGIN Receive: 334 dXNlcm5hbWU6 Send: cWluZ3NwYWNl Receive: 334 UGFzc3dvcmQ6 Send: NINULFzLnhtdQ== Receive: 235 Authentication successful Send: MAIL FROM: ******@***.com Receive: 250 Mail OK Send: RCPT TO: <******@***.com> Receive: 250 Mail OK Send: DATA Receive: 354 End data with <CR><LF>.<CR><LF> Send: From: <<******@***.com> Send: To: <<******@***.com> Send: Subject: =?utf-8?B?5Y+R6YCBIG0yIHZpYSBoMiBVc2VUY3BDbGllbnQg?= Send: Date: Fri, 16 May 2014 01:17:40 GMT Send: MIME-Version: 1.0 Send: Content-Type: text/html; Send: charset="utf-8" Send: Content-Transfer-Encoding: base64 Send: Send: 5rWL6K+V44CCSnVzdCBhIHRlc3QuPGJyLz48aW1nIHNyYz0nY2lkOlVtVnpiM1Z5WTJVdWFu Send: Qm4nIGFsdD0nJy8+ Send: . Receive: 250 Mail OK queued as AgAi0gCXn8M0Z3VTmF4QAA--.4500S2 Send: QUIT Receive: 221 Bye
2、C#编码实现邮件发送
接下来我们基于.Net类库中TcpClient类实现与服务器的交互:
先建立同样继承于ISendMail接口的类UseTcpClient
同时设定一个内部类Message作为数据载体,定义utf-8作为全局的字符编码,定义base64为全局的传输编码。
using System.Net.Sockets; public class UseTcpClient : ISendMail { private TcpClient Tcp { get; set; } private Stream Stream { get; set; } private Message Mail { get; set; } private string ContentTransferEncoding = "base64"; private Encoding Charset = Encoding.UTF8; private class Message { public Message() { } public Message(string from, string[] to) { From = from; To = to; Data = new List<string>(); } public string From { get; set; } public string[] To { get; set; } public List<string> Data { get; set; } } public void CreateHost(ConfigHost host) { throw new NotImplementedException(); } public void CreateMail(ConfigMail mail) { throw new NotImplementedException(); } public void CreateMultiMail(ConfigMail mail) { throw new NotImplementedException(); } public void SendMail() { throw new NotImplementedException(); } }
接下来实现CreateHost方法
在使用SSL连接服务器时需要将TcpClient.GetStream()返回的NetworkStream使用SslStream进行包装。在于服务器进行前期沟通的过程中,一问一答式是显而易见的
public void CreateHost(ConfigHost host) { if (host.Server != null && host.Port != 0) { Tcp = new TcpClient(host.Server, host.Port); Tcp.SendTimeout = 50000; Tcp.SendBufferSize = 1024; Tcp.ReceiveTimeout = 50000; Tcp.ReceiveBufferSize = 1024; if (host.EnableSsl) { var ssl = new SslStream(Tcp.GetStream()); ssl.AuthenticateAsClient(host.Server, null, System.Security.Authentication.SslProtocols.Tls, false); Stream = ssl; } else Stream = Tcp.GetStream(); LingerOption lingerOption = new LingerOption(true, 10); Tcp.LingerState = lingerOption; CheckErrorCode(ReadStream(), "220"); if (!string.IsNullOrEmpty(host.Username) && !string.IsNullOrEmpty(host.Password)) { WriteStream("EHLO " + Dns.GetHostName() + " "); CheckErrorCode(ReadStream(), "250"); WriteStream("AUTH LOGIN "); if (CheckReplyCode(ReadStream(), "334")) { WriteStream(ConvertToBase64(host.Username) + " "); CheckErrorCode(ReadStream(), "334"); WriteStream(ConvertToBase64(host.Password) + " "); CheckErrorCode(ReadStream(), "235"); } } else { WriteStream("HELO " + Dns.GetHostName() + " "); CheckErrorCode(ReadStream(), "250"); } } }
我们使用WriteStream()方法发送命令和数据,ReadStream()方法获得服务器反馈,CheckErrorCode()和CheckReplyCode()方法判断反馈的信息不是异常,以确保进行下一步。
由于TcpClient发送的数据是有限制的,因而当发送较长数据时最好将数据分几次发送。其实这样依然会带来问题,由于我们采用同步写入数据流的方式,大数据如附件的发送常常会因网络传输或服务器交互问题造成异常,因而在LumiSoft项目采用的是异步方式,这里我们全当测试,测试时使用较小的附件以避免这样的问题。
private void WriteStream(string request) { byte[] buffer = Charset.GetBytes(request); var pageSize = 72; var totalPages = (int)Math.Ceiling(((double)buffer.Length) / pageSize); for (var i = 0; i < totalPages; i++) { Stream.Write(buffer, i * pageSize, i == totalPages - 1 ? buffer.Length - i * pageSize : pageSize); Console.WriteLine("Send(" + i + "):" + Charset.GetString(buffer, i * pageSize, i == totalPages - 1 ? buffer.Length - i * pageSize : pageSize)); } } private string ReadStream() { var buffer = new byte[1024]; var size = Stream.Read(buffer, 0, buffer.Length); var response = Charset.GetString(buffer, 0, size); Console.WriteLine("Receive: " + response); return response; } private void CheckErrorCode(string response, string code) { if (response.IndexOf(code) == -1) { throw new Exception("Exception: " + response); } } private bool CheckReplyCode(string response, string code) { if (response.IndexOf(code) == -1) { return false; } else { return true; } }
下面的一些方法用来对数据进行base64编码,以便在网络中传输。
ConvertToBase64()方法:
ConvertToBase64()方法只是简单的将utf-8编码的字符串进行base64编码。传输编码定义了邮件标题、正文(包含多国语言),附件、嵌入资源(二进制数据)等转换为特定字符集的方式,以便适应纯文本的邮件传输环境。主要编码方式有quoted-printable和base64。
“Base64编码是将输入的数据全部转换成由64个指定ASCII字符组成的字符序列,这64个字符由{'A'-'Z', 'a'-'z', '0'-'9', '+', '/'}构成。编码时将需要转换的数据每次取出6bit,然后将其转换成十进制数字,这个数字的范围最小为0,最大为63,然后查询{'A'-'Z', 'a'-'z', '0'-'9', '+', '/'}构成的字典表,输出对应位置的ASCII码字符,这样每3个字节的数据内容会被转换成4个字典中的ASCII码字符,当转换到数据末尾不足3个字节时,则用“=”来填充。 ”
“Quoted-printable编码也是将输入的信息转换成可打印的ASCII码字符,但它是根据信息的内容来决定是否进行编码,如果读入的字节处于33-60、62-126范围内的,这些都是可直接打印的ASCII字符,则直接输出,如果不是,则将该字节分为两个4bit,每个用一个16进制数字来表示,然后在前面加“=”,这样每个需要编码的字节会被转换成三个字符来表示。”
为得到对中文更好的支持,建议设置为base64为宜。
ConvertHeaderToBase64()方法:
在邮件内容的各个类型中,包括邮件正文,附件和嵌入资源,可设定Content-Transfer-Encoding字段值来定义这个类型的传输编码。而在标题和文件名等本身就是字段的,设定其值的传输编码需要一种特殊方式,即ConvertHeaderToBase64()所要做的事。
这样邮件标题字段的值会被定义为:=?{字符编码}?{传输编码}?{编码后的字符串}?=。其中传输编码使用简称,B代表base64,Q代表quoted-printable,所以一个中文标题可能会定义成这样:=?utf-8?B?5Y+R6YCBIG0yIHZpYSBoMiBVc2VUY3BDbGllbnQg?= 。
ConvertFileToBase64()方法:
ConvertFileToBase64()方法将附件和其他内嵌资源由二进制的形式转换成base64位编码,这使得各种类型的文件可以通过邮件进行传输成为可能。
private string ConvertToBase64(string str) { byte[] buffer = Charset.GetBytes(str.ToCharArray()); return Convert.ToBase64String(buffer); } private string ConvertHeaderToBase64(string str) { if (MustEncode(str)) { return "=?" + Charset.WebName + "?B?" + ConvertToBase64(str) + "?="; } return str; } private string ConvertFileToBase64(string file) { var fs = new FileStream(file, FileMode.Open, FileAccess.Read); var buffer = new byte[(int)fs.Length]; fs.Read(buffer, 0, buffer.Length); var fileStr = Convert.ToBase64String(buffer); fs.Close(); return fileStr; } private bool MustEncode(string str) { if (!string.IsNullOrEmpty(str)) { foreach (char c in str) { if (c > 127) { return true; } } } return false; }
接下来实现CreateMail方法
这个方法创建邮件的内容,但不包括附件和内嵌资源,只是正文。可以看到它主要是简单的创建邮件内容字符串数组,以便在Data命令后,逐行发送到服务器。
public void CreateMail(ConfigMail mail) { Mail = new Message(mail.From, mail.To); Mail.Data.Add("From: <" + mail.From + "> "); foreach (var to in mail.To) { Mail.Data.Add("To: <" + mail.From + "> "); } Mail.Data.Add("Subject: " + ConvertHeaderToBase64(mail.Subject) + " "); Mail.Data.Add("Date: " + DateTime.Now.ToUniversalTime().ToString("R") + " "); Mail.Data.Add("MIME-Version: 1.0 "); Mail.Data.Add("Content-Type: text/html; "); Mail.Data.Add(" charset="" + Charset.WebName + "" "); Mail.Data.Add("Content-Transfer-Encoding: " + ContentTransferEncoding + " "); Mail.Data.Add(" "); // It is important, otherwise the body may be missing. Mail.Data.Add(ConvertToBase64(mail.Body) + " "); }
实现CreateMultiMail方法
这个方法创建邮件的内容,且包括附件和内嵌资源。邮件内容将被分为各个部分,各个部分标明了Content-Type和Charset,同时也设置了Content-Transfer-Encoding。上面已经讨论过Content-Transfer-Encoding,现在我们需要详细了解Content-Type。
Content-Type字段定义了邮件内容各部分的类型和相关属性。邮件内容中处于外围的都是multipart类型,而multipart包含3个子类型:multipart/mixed, multipart/related, multipart/alternative。这3种multipart的子类型在邮件内容中呈现的是一种嵌套关系:
multipart/mixed
|
如上图,如果包含附件则在附件外围声明multipart/mixed,如果包含内嵌资源则在内嵌资源外围声明multipart/related,如果同时存在text/plain 和text/html 则在文本外围声明multipart/alternative。这些类型内容范围由boundary属性定义的唯一标识决定,以 “—{boundary}”开始,以“--{boundary}--”结束,不同类型内容之间需要用空行分隔,所以邮件内容大概如下:
From: <******@***.com> To: <******@***.com> Subject: =?utf-8?B?5Y+R6YCBIG0yIHZpYSBoMiBVc2VUY3BDbGllbnQg?= Date: Thu, 15 May 2014 11:06:51 GMT MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="b4ed1357_39ae_4098_a043_df80407fb136" Message-Id: <53749FCC.00C525.01636@***.com> This is a multi-part message in MIME format. --b4ed1357_39ae_4098_a043_df80407fb136 Content-Type: multipart/related; boundary="4954e4a1_b756_497d_8daa_458ecf101a1c" --4954e4a1_b756_497d_8daa_458ecf101a1c Content-Type: multipart/alternative; boundary="91de458a_e772_46ac_ab87_0c6fa2009e35" --91de458a_e772_46ac_ab87_0c6fa2009e35 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64 SWYgeW91IHNlZSB0aGlzIG1lc3NhZ2UsIGl0IG1lYW5zIHRoYXQgeW91ciBtYWlsIGNsaWVudCBkb2VzIG5vdCBzdXBwb3J0IGh0bWwu --91de458a_e772_46ac_ab87_0c6fa2009e35 Content-Type: text/html; charset="utf-8" Content-Transfer-Encoding: base64 5rWL6K+V44CCSnVzdCBhIHRlc3QuPGJyLz48aW1nIHNyYz0nY2lkOlVtVnpiM1Z5WTJVdWFuQm4nIGFsdD0nJy8+ --91de458a_e772_46ac_ab87_0c6fa2009e35-- --4954e4a1_b756_497d_8daa_458ecf101a1c Content-ID: <UmVzb3VyY2UuanBn> Content-Type: application/octet-stream; name="Resource.jpg" Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="Resource.jpg" /9j/4QCpRXhpZgAASUkqAAgAAAAFABIBAwABAAAAAQAAADEBAgAVAAAASgAAADIBAgAUAAAAXwAAABMCAwABAAAAAQAAAGmHBAABAAAAcwAAAAAAAABBQ0QgU3lzdGVtcyDK/cLrs8nP8QAyMDEwOjExOjE2IDE1OjExOjQ5AAMAkJICAAQAAAA4MTIAAq --4954e4a1_b756_497d_8daa_458ecf101a1c-- --b4ed1357_39ae_4098_a043_df80407fb136 Content-Type: application/octet-stream; name="Attachment.docx" Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="Attachment.docx" FvoPRtdhKeiil2M6hP8c20PQBFGmdiNqSkDZ/b9+145hSEocmGIwZrUTrZVGa3BB21NxsbJiEVgpFXaLDL2NXuLH1kUUBglSmsgYzsIbJLf3qSznYMQ0bQJGVsiuifOg1xCJUJiHRj6UlhfCaRXvBOyGxAH4/Gj1waQ2CwRhrBsvTDzLgtYJoKjy --b4ed1357_39ae_4098_a043_df80407fb136--
下面我们通过编码实现:
public void CreateMultiMail(ConfigMail mail) { Mail = new Message(mail.From, mail.To); Mail.Data.Add("From: <" + mail.From + "> "); foreach (var to in mail.To) { Mail.Data.Add("To: <" + mail.From + "> "); } Mail.Data.Add("Subject: " + ConvertHeaderToBase64(mail.Subject) + " "); Mail.Data.Add("Date: " + DateTime.Now.ToUniversalTime().ToString("R") + " "); Mail.Data.Add("MIME-Version: 1.0 "); var mixedBoundary = Guid.NewGuid().ToString().Replace("-", "_"); if (mail.Attachments != null && mail.Attachments.Length > 0) { Mail.Data.Add("Content-Type: multipart/mixed; "); Mail.Data.Add(" boundary="" + mixedBoundary + "" "); Mail.Data.Add(" "); Mail.Data.Add("This is a multi-part message in MIME format. "); Mail.Data.Add(" "); Mail.Data.Add("--" + mixedBoundary + " "); } var relatedBoundary = Guid.NewGuid().ToString().Replace("-", "_"); if (mail.Resources != null && mail.Resources.Length > 0) { Mail.Data.Add("Content-Type: multipart/related; "); Mail.Data.Add(" boundary="" + relatedBoundary + "" "); Mail.Data.Add(" "); Mail.Data.Add("--" + relatedBoundary + " "); } var altBoundary = Guid.NewGuid().ToString().Replace("-", "_"); Mail.Data.Add("Content-Type: multipart/alternative; "); Mail.Data.Add(" boundary="" + altBoundary + "" "); Mail.Data.Add(" "); Mail.Data.Add("--" + altBoundary + " "); Mail.Data.Add("Content-Type: text/plain; "); Mail.Data.Add(" charset="" + Charset.WebName + "" "); Mail.Data.Add("Content-Transfer-Encoding: " + ContentTransferEncoding + " "); Mail.Data.Add(" "); Mail.Data.Add(ConvertToBase64("If you see this message, it means that your mail client does not support html.") + " "); Mail.Data.Add(" "); Mail.Data.Add("--" + altBoundary + " "); Mail.Data.Add("Content-Type: text/html; "); Mail.Data.Add(" charset="" + Charset.WebName + "" "); Mail.Data.Add("Content-Transfer-Encoding: " + ContentTransferEncoding + " "); Mail.Data.Add(" "); Mail.Data.Add(ConvertToBase64(mail.Body) + " "); Mail.Data.Add(" "); Mail.Data.Add("--" + altBoundary + "-- "); if (mail.Resources != null && mail.Resources.Length > 0) { foreach (var resource in mail.Resources) { var fileInfo = new FileInfo(resource); if (fileInfo.Exists) { Mail.Data.Add(" "); Mail.Data.Add(" "); Mail.Data.Add("--" + relatedBoundary + " "); Mail.Data.Add("Content-ID: <" + ConvertToBase64(fileInfo.Name) + "> "); Mail.Data.Add("Content-Type: " + GetMimeType(fileInfo.Extension) + "; "); Mail.Data.Add(" name="" + ConvertHeaderToBase64(fileInfo.Name) + "" "); Mail.Data.Add("Content-Transfer-Encoding: " + ContentTransferEncoding + " "); Mail.Data.Add("Content-Disposition: attachment; "); Mail.Data.Add(" filename="" + ConvertHeaderToBase64(fileInfo.Name) + "" "); Mail.Data.Add(" "); var fileStr = ConvertFileToBase64(resource); Mail.Data.Add(fileStr + " "); } } Mail.Data.Add(" --" + relatedBoundary + "-- "); } if (mail.Attachments != null && mail.Attachments.Length > 0) { foreach (var attachment in mail.Attachments) { var fileInfo = new FileInfo(attachment); if (fileInfo.Exists) { Mail.Data.Add(" "); Mail.Data.Add(" "); Mail.Data.Add("--" + mixedBoundary + " "); Mail.Data.Add("Content-Type: " + GetMimeType(fileInfo.Extension) + "; "); Mail.Data.Add(" name="" + ConvertHeaderToBase64(fileInfo.Name) + "" "); Mail.Data.Add("Content-Transfer-Encoding: " + ContentTransferEncoding + " "); Mail.Data.Add("Content-Disposition: attachment; "); Mail.Data.Add(" filename="" + ConvertHeaderToBase64(fileInfo.Name) + "" "); Mail.Data.Add(" "); var fileStr = ConvertFileToBase64(attachment); Mail.Data.Add(fileStr + " "); } } Mail.Data.Add(" "); Mail.Data.Add(" "); Mail.Data.Add("--" + mixedBoundary + "-- "); } }
实现SendMail方法
public void SendMail() { if (Tcp != null && Stream != null) { WriteStream("MAIL FROM: <" + Mail.From + "> "); CheckErrorCode(ReadStream(), "250"); foreach (var to in Mail.To) { WriteStream("RCPT TO: <" + to + "> "); CheckErrorCode(ReadStream(), "250"); } WriteStream("DATA "); CheckErrorCode(ReadStream(), "354"); foreach (var item in Mail.Data) { WriteStream(item); } WriteStream(" . "); CheckErrorCode(ReadStream(), "250"); WriteStream("QUIT "); CheckErrorCode(ReadStream(), "221"); Stream.Close(); Tcp.Close(); } }
3、测试
测试发送只包含正文的简单邮件:
class Program { static void Main(string[] args) { var h1 = new ConfigHost() { Server = "smtp.gmail.com", Port = 465, Username = "******@gmail.com", Password = "******", EnableSsl = true }; var m1 = new ConfigMail() { Subject = "Test", Body = "Just a test.", From = "******@gmail.com", To = new string[] { "******@gmail.com" }, }; var agent = new UseTcpClient(); var output = "Send m1 via h1 " + agent.GetType().Name + " "; Console.WriteLine(output + "start"); try { agent.CreateHost(h1); m1.Subject = output; agent.CreateMail(m1); agent.SendMail(); Console.WriteLine(output + "success"); } catch (Exception ex) { Console.WriteLine(ex.Message); } Console.WriteLine(output + "end"); Console.WriteLine("-----------------------------------"); Console.Read(); } }
测试发送多媒体邮件:
class Program { static void Main(string[] args) { var h2 = new ConfigHost() { Server = "smtp.163.com", Port = 25, Username = "******@163.com", Password = "******", EnableSsl = false }; var m2 = new ConfigMail() { Subject = "Test", Body = "Just a test. <br/><img src='cid:" + Convert.ToBase64String(Encoding.Default.GetBytes("Resource.jpg")) + "' alt=''/> ", From = "******@163.com", To = new string[] { "******@163.com" }, Attachments = new string[] { @"E:TestSendMailAttachment.pdf" }, Resources = new string[] { @"E:TestSendMailResource.jpg" } }; var agent = new UseTcpClient(); var output = "Send m2 via h2 " + agent.GetType().Name + " "; Console.WriteLine(output + "start"); try { agent.CreateHost(h2); m2.Subject = output; agent.CreateMultiMail(m2); agent.SendMail(); Console.WriteLine(output + "success"); } catch (Exception ex) { Console.WriteLine(ex.Message); } Console.WriteLine(output + "end"); Console.WriteLine("-----------------------------------"); Console.Read(); } }
测试过程中使用较小的附件或图片可以发送成功且一切正常,但大附件一般是失败的,因而代码是存在缺陷的,其原因可能是在复杂的网络环境下使用同步发送出现异常或服务器失去响应,也可能是对数据流的操作不够谨慎,或者兼而有之,这有待进一步深入研究。