Golang《基於 MIME 協議的郵件資訊解析》部分實現

awensir發表於2022-01-12

Golang 郵件

golang 中郵件相關的包

net/mail
net/smtp
net/textproto
mime
mime/multipart
mime/quotedprintable

基於 smtp 包的郵件傳送示例,基於官方文件:

// Set up authentication information.
  auth := smtp.PlainAuth("", "xxxx@qq.com", "xxxxx", "smtp.qq.com")

  // Connect to the server, authenticate, set the sender and recipient,
  // and send the email all in one step.
  to := []string{"xxxx@xx.com"} //收件地址
  msg := []byte("To: xxx@xxx.com\r\n" +
    "Subject: discount Gophers!\r\n" +
    "\r\n" +
    "This is the email body.\r\n")
  err := smtp.SendMail("smtp.qq.com:25", auth, "xxx@xx.com", to, msg)
  if err != nil {
    log.Fatal(err)
  }

程式碼解析:

func PlainAuth(identity, username, password, host string) Auth

// identity 引數一般為空
// username 一般為傳送者郵件地址
// password 通過郵箱賬號得到的登入口令,在通過代理髮送郵件時候需要。
// host 設定使用的那個平臺的郵箱


func SendMail(addr string, a Auth, from string, to []string, msg []byte) error 
// addr 引數是平臺地址引數,各大平臺都有自己的地址,不同的平臺去官網查詢即可,該地址引數要帶一個埠,預設一般是25
// a 是上面生成的 安全認證(也可以說是一個賬戶資訊,如同使用郵件之前需要登陸郵箱一樣)
// from 引數是傳送者地址,此引數要保持和auth 中的引數使用者名稱一致
// to 存放一個地址收件郵箱資訊。
// msg 是一個訊息體,要滿足標準協議

SMTP 協議分析:

https://www.rfc-editor.org/rfc/rfc821.html#section-3.1

傳送完整郵件的訊息體主要包括一下幾個部分:

是 \r\n 是 ascii 轉移字元,用於標識每個部分結束的資訊

發件人 (在開發者角度上面可以理解為發件人,該資訊主要是讓 SMTP 伺服器知道我們要用哪個賬號傳送郵件)

MAIL FROM:

收件人:

RCPT TO:

資料:

DATA

以上三點是最基礎的 3 個部分,在郵件中還可以指定其他內容資訊,比如時間,附件,訊息格式等等。

有關協議中的一小部分部分內容整理如下以便理解大概:

    SMTP 郵件事務分為三個步驟。交易以 MAIL 命令開始,該命令為發件人提供鑑別。一系列一個或多個 RCPT 命令如下給接收者資訊。然後一個 DATA 命令給出郵件資料。最後,郵件資料指示器的結尾確認交易。

    該過程的第一步是 MAIL 命令。這<reverse-path> 包含源郵箱。
    MAIL <SP> FROM:<reverse-path> <CRLF>
    此命令告訴 SMTP 接收器新郵件事務正在開始並重置其所有狀態表和緩衝區,包括任何收件人或郵件資料。它給出了可用於報告錯誤的反向路徑。如果接受,接收方 SMTP 返回 250 OK 回覆。

<reverse-path> 可以包含的不僅僅是郵箱。這<reverse-path> 是主機的反向源路由列表,源郵箱。 <reverse-path> 中的第一個主機應該是傳送此命令的主機。

    該過程的第二步是 RCPT 命令。
    RCPT <SP> TO:<forward-path> <CRLF>

該命令給出了識別一個接收者的前向路徑。如果接受,接收方 SMTP 將返回 250 OK 回覆,並且儲存前向路徑。如果收件人未知接收者 SMTP 返回 550 失敗回覆。這第二步該過程可以重複任意次。

    該過程的第三步是 DATA 命令。
    DATA <CRLF>
    如果接受,接收方-SMTP 將返回 354 中間回覆並將所有後續行視為訊息文字。當文字的結尾被接收並儲存時,SMTP 接收器傳送 250 OK 回覆。

有關訊息型別頭的標準協議文件:

https://www.rfc-editor.org/rfc/rfc1049.html

郵件擴充套件標準

Internet 郵件擴充套件 (MIME)第一部分: Internet 訊息體的格式

https://www.rfc-editor.org/rfc/rfc2045

Internet 郵件擴充套件 (MIME) 第二部分: 媒體型別

https://www.rfc-editor.org/rfc/rfc2046

該部分主要講述了,有關在構建郵件訊息體以及,如何混合不同型別的訊息格式。

MIME(多用途 Internet 郵件擴充套件)第三部分: 非 ASCII 文字的訊息頭擴充套件

https://www.rfc-editor.org/rfc/rfc2047

第四部分

https://www.rfc-editor.org/rfc/rfc4289.html#page-5

Internet 郵件擴充套件 (MIME) 第五部分: 一致性標準和示例

https://www.rfc-editor.org/rfc/rfc2049#page-15

該部分,有著複雜的混合訊息組合例子

通過上面的 5 協議,簡單的實現了一個傳送郵件的內容分裝。

設計訊息訊息:

根據第二部分的協議,需要支援多媒體型別混合訊息傳送,需要設計一個用於遞迴解析的結構體來生成最終傳送的內容,如下所示

// Message 郵件中的訊息部分
type Message struct {
  boundaryStart string     //邊界分割
  header        []*Header  //訊息頭
  body          string     //正文,一般為字串,HTML,或者編碼後的字串
  msg           []*Message //巢狀訊息
  boundaryEed   string     //邊界結尾
}

完成訊息體的設計,接下來只需要把訊息體,解析成為我們需要的格式即可,程式碼實現如下:

// 對訊息進行解析,生成最終傳輸的訊息體,該解析實現了 rfc2046 的多部份訊息混合解析
func parseMessage(message *Message) []byte {
  buf := &bytes.Buffer{}

  //解析郵件邊界分割
  if message.boundaryStart != "" && message.body != "" { //如果當前 message 沒有任何訊息正文,則不新增分割符
    buf.WriteString(CRLF + message.boundaryStart + CRLF)
  }
  //解析郵件頭 檢驗本身是否屬於一個多部份混合訊息,如果是多部份混合訊息 則自己本身也要使用自己的訊息分割符
  if message.header != nil {
    for i := 0; i < len(message.header); i++ {
      if message.header[i].Name == ContentType { //檢索是否有內容型別頭
        for _, value := range message.header[i].Value { //檢索是否需要分段
          if strings.HasPrefix(value, "boundary") {
            //如果有該屬性,那麼後續的 msg           []*Message //巢狀訊息 都需要使用這個作為分割
            //message.boundaryStart = "--" + value[9:]
            message.boundaryEed = "--" + value[9:] + "--"
            if message.msg != nil {
              for j := 0; j < len(message.msg); j++ {
                message.msg[j].boundaryStart = "--" + value[9:] //此處初始化被巢狀的訊息起始分割符號
              }
            }
          }
        }
      }
      buf.Write(message.header[i].Encoding())
    }
  }

  if message.body != "" {
    buf.WriteString(message.body)
  }

  if message.msg != nil {
    // 開始遞迴解析巢狀訊息
    for i := 0; i < len(message.msg); i++ {
      s := parseMessage(message.msg[i])
      buf.Write(s)
    }
  }
  if message.boundaryEed != "" {
    buf.WriteString(CRLF + message.boundaryEed + CRLF)
  }
  return buf.Bytes()
}

核心程式碼基本上已經結束了。

設計附件資訊:

// File 郵件附件資訊
type File struct {
  Filename         string   //檔名稱
  Type             []string //內容型別
  Disposition      []string //檔案描述
  TransferEncoding string   //傳輸編碼
  Encoding         string   //編碼後的字串
  data             []byte   //檔案位元組
}

欄位解析:

Filmename:指定的檔名

Type:封裝訊息體的內容型別資訊,一般有兩個資訊在裡面:application/octet-stream,name="xxx"

Disposition: 檔案描述資訊,一般為:attachment,filename="xxxx",(此處的屬性是基於其他郵件訊息格式得出,Type 和 Disposition 此處決定了郵件附件的檔名)

TransferEncoding : 標識以什麼方式編碼傳輸,本文統一以 Base64 編碼

郵件客戶端設計:

type client struct {
  host     string
  username string
  password string
  auth     smtp.Auth
  boundary string //標識該郵件是否為混合訊息體,如果是混合訊息,則需要用此引數去初始化第一個message的邊界符號
  from     string //發件人資訊

  //一下為郵件內容設定
  subject string           //郵件主題
  main    *Message         //用於初始化構建訊息
  html    string           //html訊息
  text    string           //文字訊息
  file    map[string]*File //檔案資訊
}

重點看一下檔案儲存上面

// File 設定傳送郵件的附件資訊
// 檔案預設的傳輸格式採用Base64
func (c *client) File(name string, data []byte) {
  if c.file == nil {
    c.file = make(map[string]*File)
  }
  encodeToString := base64.StdEncoding.EncodeToString(data)
  file := &File{
    Filename:         name,
    Type:             []string{"application/octet-stream", "name=" + name},
    Disposition:      []string{"attachment", "filename=" + name + CRLF},
    TransferEncoding: Base64,
    data:             data,
    Encoding:         encodeToString,
  }
  c.file[name] = file
}

讓我們對官方 demo 稍加包裝一下即可:

func NewEmail(user, password, host string) *client {
  c := &client{host: host, username: user, password: password, from: user}
  c.auth = smtp.PlainAuth("", user, password, host)
  return c
}

// 傳送郵件
func (c *client) SendEmail(addr ...string) (bool, error) {
  c.build()
  if c.main == nil {
    return false, errors.New("email content is empty")
  }
  c.main.header = append(c.main.header[:1], append([]*Header{NewHeader(To, addr...)}, c.main.header[1:]...)...)
  message := parseMessage(c.main)
  if message == nil {
    return false, errors.New("email content is empty")
  }
  err := smtp.SendMail(c.host+":25", c.auth, c.from, addr, message)
  if err != nil {
    return false, err
  }
  return true, nil
}

在傳送郵件之前對內容設定好即可。部分解析的 api 過於繁瑣,有興趣的可以去看一下原始碼.

https://github.com/awensir/aurora-email

測試 demo

func TestText(t *testing.T) {
  email := NewEmail("xxxx@qq.com", "xxxxxxxxxxxxxx", "smtp.qq.com")
  email.Subject("test")
  //email.Text("test 普通文字訊息")
  email.Html(`<!DOCTYPE html>
  <html>
  <body>
  <p style="font-size:30px;color:orange">測試HTML 資訊</p>
  </body>
  </html>`)
  file, err := os.ReadFile("E:\\space\\src\\project\\email\\1111.pdf")
  if err != nil {
    fmt.Println(err.Error())
    return
  }
  email.File("test.pdf", file)
  _, err = email.SendEmail("xxxxxxx@qq.com")
  if err != nil {
    fmt.Println(err.Error())
    return
  }
}

注意:本次實現的郵件傳送,目前僅支援 html 和附件 ,對於 html 和文字,和其他 資訊的複雜解析,還有待改進支援。

更多原創文章乾貨分享,請關注公眾號
  • Golang《基於 MIME 協議的郵件資訊解析》部分實現
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章