go 的标准库中有一个 smtp 包提供了一个可以非常方便的使用 smtp 协议发送邮件的函数,通常情况下使用起来简单方便,不过我在使用中却意外遇到了一个会导致邮件发送出错的情况。

smtp 协议发送邮件

sendmail 函数

go 标准库的 net/smtp 包提供了一个 SendMail 函数用于发送邮件。

func SendMail(addr string, a Auth, from string, to []string, msg []byte) error

SendMail: 连接到 addr 指定的服务器;如果支持会开启 TLS;如果支持会使用 a(Auth) 认证身份;然后以 from 为邮件源地址发送邮件 msg 到目标地址 to。(可以是多个目标地址:群发)

addr: 邮件服务器的地址。

a: 身份认证接口,可以由 func PlainAuth(identity, username, password, host string) Auth 函数创建。

简单发送邮件示例

package main

import (
    "fmt"
    "net/smtp"
    "strings"
)

func main() {
    auth := smtp.PlainAuth("", "username@qq.com", "passwd", "smtp.qq.com")
    to := []string{"to-user@qq.com"}
    nickname := "test"
    user := "username@qq.com"
    subject := "test mail"
    content_type := "Content-Type: text/plain; charset=UTF-8"
    body := "This is the email body."
    msg := []byte("To: " + strings.Join(to, ",") + "\r\nFrom: " + nickname +
        "<" + user + ">\r\nSubject: " + subject + "\r\n" + content_type + "\r\n\r\n" + body)
    err := smtp.SendMail("smtp.qq.com:25", auth, user, to, msg)
    if err != nil {
        fmt.Printf("send mail error: %v", err)
    }
}

autu: 这里采用简单的明文用户名和密码的认证方式。

nickname: 发送方的昵称。

subject: 邮件主题。

content_type: 可以有两种方式,一种 text/plain,纯字符串,不做转义。一种 text/html,会展示成 html 页面。

body: 邮件正文内容。

msg: msg 的内容需要遵循 smtp 协议的格式,参考上例。

特定邮件服务器出错

certificate signed by unknown authority

在通过公司内部自己搭建的邮件服务器发送邮件时报了上述错误,看上去是因为认证不通过的问题,检查了一下用户名和密码没有问题。

我通过抓包以及手动 telnet 执行了一遍 smtp 的过程,发送问题出现在是否加密和身份验证上。

SMTP 协议

smtp 协议开始时客户端主动向邮件服务器发送 EHLO,服务器会返回支持的所有命令,例如

250-PIPELINING
250-SIZE 10240000
250-VRFY
250-ETRN
250-STARTTLS
250-AUTH PLAIN LOGIN
250-AUTH=PLAIN LOGIN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250 DSN

如果有 STARTTLS,说明支持加密传输,golang 的标准库中会进行判断然后决定是否选择使用 STARTTLS 加密传输。

如果没有 AUTH=PLAIN LOGIN,说明不支持 PLAIN 方式。

一共有3种验证方式,可以参考这篇 blog: http://blog.csdn.net/mhfh611/article/details/9470599

STARTTLS 引起的错误

公司内部的邮件服务器返回了 STARTTLS,但是实际上却不支持加密传输的认证方式,所以就导致了身份认证失败。

大部分国内的邮件服务器都支持 LOGINPLAIN 方式,所以我们可以在代码中直接采用 PLAIN 的方式,不过安全性就降低了。

想要强制使用 PLAIN 方式也不是这么容易的,因为涉及到修改 net/smtpSendMail 函数,当然标准库我们修改不了,所以只能重新实现一个 SendMail 函数。

标准库中 SendMail 函数代码如下:

func SendMail(addr string, a Auth, from string, to []string, msg []byte) error {
    c, err := Dial(addr)
    if err != nil {
        return err
    }
    defer c.Close()
    if err = c.hello(); err != nil {
        return err
    }
    if ok, _ := c.Extension("STARTTLS"); ok {
        config := &tls.Config{ServerName: c.serverName}
        if testHookStartTLS != nil {
            testHookStartTLS(config)
        }
        if err = c.StartTLS(config); err != nil {
            return err
        }
    }
    if a != nil && c.ext != nil {
        if _, ok := c.ext["AUTH"]; ok {
            if err = c.Auth(a); err != nil {
                return err
            }
        }
    }
    if err = c.Mail(from); err != nil {
        return err
    }
    for _, addr := range to {
        if err = c.Rcpt(addr); err != nil {
            return err
        }
    }
    w, err := c.Data()
    if err != nil {
        return err
    }
    _, err = w.Write(msg)
    if err != nil {
        return err
    }
    err = w.Close()
    if err != nil {
        return err
    }
    return c.Quit()
}

重点就在于下面这一段

if ok, _ := c.Extension("STARTTLS"); ok {
    config := &tls.Config{ServerName: c.serverName}
    if testHookStartTLS != nil {
        testHookStartTLS(config)
    }
    if err = c.StartTLS(config); err != nil {
        return err
    }
}

逻辑上就是检查服务器端对于 EHLO 命令返回的所支持的命令中是否有 STARTTLS,如果有,则采用加密传输的方式。我们自己实现的函数中直接把这部分去掉。

我们仿照 SendMail 函数实现一个 NewSendMail 函数

func NewSendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
    c, err := smtp.Dial(addr)
    if err != nil {
        return err 
    }   
    defer c.Close()
    if err = c.Hello("localhost"); err != nil {
        return err 
    }   
    err = c.Auth(a)
    if err != nil {
        return err 
    }   

    if err = c.Mail(from); err != nil {
        fmt.Printf("mail\n")
        return err 
    }   
    for _, addr := range to {
        if err = c.Rcpt(addr); err != nil {
            return err 
        }   
    }
    w, err := c.Data()
    if err != nil {
        return err
    }
    _, err = w.Write(msg)
    if err != nil {
        return err
    }
    err = w.Close()
    if err != nil {
        return err
    }
    return c.Quit()
}

通过这个函数发送邮件,则身份认证时不会采用加密的方式,而是直接使用 PLAIN 方式。