frendguo's blog

UWP中发邮件之通过StreamSocket模拟SmtpClient发邮件

我们都知道在平常的桌面应用中, 如果要发邮件的话, 肯定首选SmtpClient 很简单就能发送邮件了, 但是在UWP中, 是无法使用System.Net.Email 这个namespace的, 所以我们要实现全自动化发送Email的话, 就得手动去模拟了.

注1:本文来源: https://blogs.msdn.microsoft.com/mim/2013/11/29/sending-an-email-within-a-windows-8-1-application-using-streamsocket-to-emulate-a-smtpclient/

注2: 在非特殊情况下, 不建议使用此方法, 可使用官方推荐的通过调用系统自带邮件APP来发邮件, 详情戳这里>>> 或者看这里: http://lindexi.oschina.io/lindexi/post/win10-UWP-%E5%8F%91%E9%82%AE%E4%BB%B6/

在此之前, 我们需要解决以下几个问题:

  1. Smtp协议是个什么样的, Smtp服务器又是怎么工作的?
  2. 怎么发送和接收来自服务器端的消息和数据?

Smtp协议

可以参考以下几个关于SMTP协议的链接:

  1. 维基百科: https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol
  2. 使用Telnet发邮件: http://thedaneshproject.com/posts/send-mail-through-smtp-using-telnet/
  3. SMTP命令参考: http://www.samlogic.net/articles/smtp-commands-reference.htm

接下来, 我们得了解客户端和服务器端是怎么交流的了

注1: 在Client发送完密码, Server通过验证后, 返回的代码应该是235 表示验证成功. 上图有误(实际中, 也不完全是这样的)

注2: 在编程的时候, 需要对用户名和密码必须通过base 64加密后再发送给Server, 不能使用明文发送!

在此次连接中用到的命令解释如下:

  • Ehlo : Send a command to the smtp server to know some properties like authentication mode, SSL, TSL support etc …
  • Auth Login : Send a command requesting authentication on the smtp server.
  • StartTls : Send a command requesting a TSL communication.
  • Mail From : Send a command with the mail author.
  • Rcpt To : Send a command with one or more receivers.
  • Data : Send a command for the body mail.
  • Quit : Send a command ending the connection with the smtp server.

此次连接中用到的Code解释如下:

  1. 220 : Service Ready.
  2. 250 : Request completed.
  3. 334 : Waiting for Authentication.
  4. 235 : Authentication successful.
  5. 354 : Start mail input.
  6. 221 : Closing connection.

以下是一个Client和Server交流的实例(smtp-mail.outlook.com)

Connect smtp-mail.outlook.com 587 
S : 220 BLU0-SMTP180.phx.gbl Microsoft ESMTP MAIL Service, Version: 6.0.3790.4675 ready at  Wed, 27 Nov 2013 08:28:59 -0800 
C : EHLO www.contoso.com 
S : 250-BLU0-SMTP180.phx.gbl Hello [94.245.87.37] 
    250-TURN 
    250-SIZE 41943040 
    250-ETRN 
    250-PIPELINING      
    250-DSN 
    250-ENHANCEDSTATUSCODES 
    250-8bitmime 
    250-BINARYMIME 
    250-CHUNKING 
    250-VRFY 
    250-TLS 
    250-STARTTLS 
    250 OK 
C : STARTTLS 
S : 220 2.0.0 SMTP server ready 
C : EHLO www.contoso.com 
S : 250-BLU0-SMTP180.phx.gbl Hello [94.245.87.37] 
    250-TURN 
    250-SIZE 41943040 
    250-ETRN 
    250-PIPELINING 
    250-DSN 
    250-ENHANCEDSTATUSCODES 
    250-8bitmime 
    250-BINARYMIME 
    250-CHUNKING 
    250-VRFY 
    250-AUTH LOGIN PLAIN XOAUTH2 
    250 OK 
C : AUTH LOGIN 
S : 334 VXNlcm5hbWU6 
C : john.doe@outlook.com 
S : 334 UGFzc3dvcmQ6 
C : MyF4bulousP@zzw0rd 
S : 235 2.7.0 Authentication succeeded 
C : MAIL FROM:<john.doe@outlook.com> 
S : 250 2.1.0 john.doe@outlook.com….Sender OK 
C : RCPT TO:<spertus@microsoft.com> 
S : 250 2.1.5 spertus@microsoft.com 
C : DATA 
S : 354 Start mail input; end with <CRLF>.<CRLF> 
C : Date: Wed, 27 Nov 2013 17:28:56 +0000 
    X-Priority: 0 
    To: sebastien.pertus@gmail.com, spertus@microsoft.com 
    MIME-Version: 1.0 
    Content-Transfer-Encoding: 7bit 
    Content-Disposition: inline 
    Subject: Hi Guy. 
    Content-Type: text/plain; charset="utf-8"

    Hi Sebastien, how are you ?? 
     John D. 
    . 
S : 250 2.6.0 <BLU0-SMTP180Np7vXee0000a899@BLU0-SMTP180.phx.gbl> Queued mail for delivery 
C : QUIT 
S : 221 2.0.0 BLU0-SMTP180.phx.gbl Service closing transmission channel

Client发送邮件

首先上代码:

public async Task<bool> SendEmail(SmtpMessage message)
{
    if (!IsConnected)
    {
        await this.Connect();
    }
    if (!IsConnected)
    {
        throw new Exception("Can't connect server!");
    }

    if (!IsAuthentication)
    {
        await this.Authenticate();
    }

    var rs = await this.SmtpSocket.Send(string.Format("Mail From:<{0}>", message.MailFrom));
    if (!rs.IsContainsStatus(SmtpCode.RequestedMailActionCompleted))
    {
        return false;
    }

    foreach (var to in message.RcptTo)
    {
        var rsTo = await this.SmtpSocket.Send(string.Format("Rcpt To:<{0}>", to));
        if (!rsTo.IsContainsStatus(SmtpCode.RequestedMailActionCompleted))
        {
            break;
        }
    }

    var rsD = await this.SmtpSocket.Send("Data");
    if (!rsD.IsContainsStatus(SmtpCode.StartMailInput))
    {
        return false;
    }
    var rsM = await this.SmtpSocket.Send(message.GetBody());
    if (!rsM.IsContainsStatus(SmtpCode.RequestedMailActionCompleted))
    {
        return false;
    }
    var rsQ = await this.SmtpSocket.Send("Quit");
    if (!rsQ.IsContainsStatus(SmtpCode.ServiceClosingTransmissionChannel))
    {
        return false;
    }

    return true;
}

通过代码可以很清晰的看出, 完全是按照 Client 发送, 然后等待回应, 然后判断回应是否正确来判断是否发送成功.

通过SendEmail() 可以看出, 整个过程主要可以分为以下几个部分:

  1. 连接 Connect();
  2. 认证Authenticate();
  3. 向Server发送消息或命令: Send();
  4. 获取Server的回应: GetResponse();

连接Server的话, 这里使用StreamSocket中的ConnectAsync()方法来实现:

if (this.isssll)
{
    await this.socket.ConnectAsync(this.hostName, this.port.ToString(), SocketProtectionLevel.Ssl);
}
else
{
    await this.socket.ConnectAsync(this.hostName, this.port.ToString(), SocketProtectionLevel.PlainSocket);
}

进行连接之后, 我们得看看Server返回的数据, 以此来判断我们的连接是否真正的成功.也就是我们的GetResponse();

首先需要提到的是, 我们使用DataReaderDataWriter 来对流进行读写(namepace 是 Windows.Storage.Streams)

this.reader = new DataReader(this.socket.InputStream);
this.reader.InputStreamOptions = InputStreamOptions.Partial;

this.writer = new DataWriter(this.socket.OutputStream);

其次, Server返回的数据都是含有多条消息的(这里将一个Code和一个string 称为一条消息), 在上面的例子中我们也能看到, 所以, 这里选用 List<KeyValuePair<SmtpCode, string>> 类型的来存储响应数据.

do
{
    char chr = (char)buffer[charpos];

    if (!isStringLine)
    {
        // code部分
        codeStr += chr;
        if (codeStr.Length == 3)
        {
            if (int.TryParse(codeStr, out int codeInit))
            {
                code = (SmtpCode)codeInit;
            }
            isStringLine = true;
        }
    }
    else if (chr == '\r' || chr == '\n')
    {
        // string 结束
        if (chr == '\r' && charpos < (charlen - 1))
        {
            // 再判断下一个字符
            charpos++;
            chr = (char)buffer[charpos];
        }
        if (chr == '\n')
        {
            // 回车换行符 string结束
            KeyValuePair<SmtpCode, string> kvp = new KeyValuePair<SmtpCode, string>(
                code, stringBuilder.ToString());
            response.Values.Add(kvp);

            Debug.WriteLine("{0}:{1}", ((int)code).ToString(), stringBuilder.ToString());

            isStringLine = false;
            stringBuilder = null;
            codeStr = string.Empty;
            code = SmtpCode.None;
            stringBuilder = new StringBuilder();
        }
    }
    else
    {
        // 表示是string部分
        stringBuilder.Append(chr);
    }

    charpos++;

} while (charpos < charlen);

接下来便是认证的过程了, 认证就开始需要向Server发送数据了, 所以这里向实现Send(); 这里利用DataWriter 的WriteBytes()和StoreAsync();

writer.WriteBytes(buffer);
await writer.StoreAsync();

好啦, 总算来到认证了, 认证分为Login 和Plain 两种方式, 不过我们一般都选择Login, 具体是哪种方式, 需要根据Server返回的数据来判断

Login:

private async Task<bool> AuthenticateByLogin()
{
    if (!IsConnected)
    {
        return false;
    }

    var rs = await this.SmtpSocket.Send("Auth Login");
    if (!rs.IsContainsStatus(SmtpCode.WaitingForAuthentication))
    {
        return false;
    }
    var rsUserName = await this.SmtpSocket.Send(Convert.ToBase64String(Encoding.UTF8.GetBytes(this.UserName)));
    if (!rsUserName.IsContainsStatus(SmtpCode.WaitingForAuthentication))
    {
        return false;
    }
    var rsPasswd = await this.SmtpSocket.Send(Convert.ToBase64String(Encoding.UTF8.GetBytes(this.Password)));
    if (!rsPasswd.IsContainsStatus(SmtpCode.AuthenticationSuccessful))
    {
        return false;
    }
    return true;
}

Plain:

private async Task<bool> AuthenticateByPlain()
{
    if (!this.IsConnected)
    {
        return false;
    }

    var rs = await this.SmtpSocket.Send("Auth Plain");
    if (!rs.IsContainsStatus(SmtpCode.WaitingForAuthentication))
    {
        return false;
    }

    var lineAuthentication = string.Format("{0}\0{0}\0{1}", this.UserName, this.Password);
    var rsAuth = await this.SmtpSocket.Send(Convert.ToBase64String(Encoding.UTF8.GetBytes(lineAuthentication)));
    if (!rsAuth.IsContainsStatus(SmtpCode.AuthenticationSuccessful))
    {
        return false;
    }

    return true;
}

到这里为止, 发送的流程基本上就完了, 是不是很简单的啊, 但是其中会有很多小细节需要注意的
这里, 再放一个邮件Body格式的代码, 仅供参考

public string GetBody()
{
    StringBuilder sb = new StringBuilder();

    var dateFormat = "ddd, dd MMM yyyy HH:mm:ss +0000";
    sb.AppendFormat("Date:{0}{1}", DateTime.Now.ToString(dateFormat), System.Environment.NewLine);

    if (string.IsNullOrEmpty(this.MailFrom))
    {
        throw new Exception("MailFrom is mandatory!");
    }

    sb.AppendFormat("X-Priority: {0}{1}", ((byte)this.SmtpPriority).ToString(), System.Environment.NewLine);
    if (rcptTo.Count == 0)
    {
        throw new Exception("RCPT is mandatory!");
    }
    sb.Append("TO: ");
    for (int i = 0; i < this.rcptTo.Count; i++)
    {
        var to = rcptTo[i];
        if (i == rcptTo.Count - 1)
        {
            sb.AppendFormat("{0}{1}", to, System.Environment.NewLine);
        }
        else
        {
            sb.AppendFormat("{0}{1}", to, ",");
        }
    }

    if (this.cc.Count != 0)
    {
        sb.Append("CC: ");
        for (int i = 0; i < this.cc.Count; i++)
        {
            var c = cc[i];
            if (i == cc.Count - 1)
            {
                sb.AppendFormat("{0}{1}", c, System.Environment.NewLine);
            }
            else
            {
                sb.AppendFormat("{0}{1}", c, ",");
            }
        }
    }

    sb.AppendFormat("MIME-Version: 1.0{0}", System.Environment.NewLine);
    sb.AppendFormat("Content-Transfer-Encoding: {0}{1}", this.TransferEncoding, System.Environment.NewLine);
    sb.AppendFormat("Content-Disposition: inline{0}", System.Environment.NewLine);
    sb.AppendFormat("Subject: {0}{1}", this.Subject, System.Environment.NewLine);

    if (this.IsHtml)
    {
        sb.AppendFormat("Content-Type: text/html; {0}", System.Environment.NewLine);
    }
    else
    {
        sb.AppendFormat("Content-Type: text/plain; charset=\"{0}\"{1}", this.Encoding.WebName, System.Environment.NewLine);
    }

    sb.Append(System.Environment.NewLine);
    sb.Append(this.Body);
    sb.Append(System.Environment.NewLine);
    sb.Append(".");

    return sb.ToString();
}

好啦, 到这里就正式结束了~

Happy Coding!o(* ̄▽ ̄*)o

Add comment

Loading