Go
gmail
SMTP
Office365
Exchange

Go言語でSMTPメール送信

More than 1 year has passed since last update.

こんにちは。コミュ障社会人のにとれすと申します:nerd:。みなさんは出社してまず何をしますか?いや、出社直後でなくともどこかの時間に通知を確認するルーチンワークがありますよね?メールを確認して、チケット管理システムからの通知を確認して、社内システムがあればその通知を確認して。。。特に上位レイヤーのポジションについている方には承認のタスクがあり、OK、NGの判断を下す依頼がたくさんあると思います。依頼者はこの承認フローを一営業日中には終わらせてくれるだろうと期待していますので、もし上位レイヤーの方がこれを忘れてしまうと予想外の遅れが発生します:dizzy_face:。前置きが長くなりました。この記事はコミュ障の筆者が、あまり通知を確認してくれない上司に対し、

  • 毎日自分が承認依頼状況を確認し、上司に伝える
  • 承認依頼を定期的に確認するルーチンを組むよう上司にお願いする

というコミュニケーションを諦め1

  • 承認の依頼状況をチェックし、自動でメールを送るcronを組む

という手段を採った際に調査した内容のうち、メール送信に関わる部分を記載しております2

SMTP

メールサーバのプロトコルにはPOP、IMAPもありますが、今回は送信に限った話なのでSMTPについてのみ解説します3

SMTPはSimple Mail Transfer Protocolの略称で名前の通りメール転送用のプロトコルです(RFC5321)。通常はTCPのポート番号25利用する、との記載が見られますがメールサーバ間の通信は25番、メールユーザ<->メールサーバ間の通信は587番を使うことが多いようです。

Message relay and final delivery are unaffected, and continue to use SMTP over port 25.
When conforming to this document, message submission uses protocol specified here, normally over port 587.

From: RFC4409

クライアント<->サーバ間でSMTPセッションが確立されると、対話型の形式でメール操作のやりとりが行われます。SMTPの標準的なコマンドは下表の5つです。ちなみに、コマンドの終端記号はCRLFを使います。

コマンド 文法 説明
EHLO4   EHLO 疎通確認とともに、サーバが提供する機能を確認できる。
MAIL   MAIL FROM:<reverse-path> 送信元のメールアドレスを設定する。
RCPT TO:<forward-path> 送信先のメールアドレスを設定する。複数送信先がある場合は複数回同コマンドを実行する。
DATA DATA このコマンド発行後、続けてRFC822スタイル(後述します)でメールの本文を送信する。
QUIT QUIT メール本文の終了=メールの送信を行う。

これに加え、gmailとoffice365ではAUTHとSTARTTLSコマンドを提供しており、それぞれその名前の通り認証とTLS通信の開始を実行できます。

GoでSMTP対話の確認

仕様を一通り確認したところで、次は実際のconversationを覗いて見ましょう。Go言語にはnet/smtpパッケージがあります。メールを送信するには、

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

を使えば良さそうですね。ただ、verboseオプションのようなものは見当たらないので、net/smtp以下のファイルを個人用スペースにコピーしてログ出力を仕込みましょう。

# 環境はOSX、オフィシャルインストーラ利用。
# 別OSやbrewを利用している場合、ディレクトリが違うかと思います。
# また、筆者の場合、適当なGoプログラムを作成する際に
# $GOPATH/src/sandspace/以下にフォルダを作成しています。
$cp /usr/local/go/src/net/smtp/* $GOPATH/src/sandspace/smtp_test/smtp/

標準ライブラリでも単純にパッケージのファイルを持ってくるだけで中身に変更を施せます。ファイル移動させても依存関係は壊れないのでimportのパスをいじったりする必要はありません。Go言語のシンプルな取り回しはこういうとき楽ですね:relaxed:

mainパッケージからコピーしてきたsmtpパッケージをインポートして、メール送信テストコードを作成します。

mail.go
package main

import (
    "log"
    "os"

    "sandspace/smtp_test/smtp"
)

func main() {
    m := mail{
        from:     "XXXXX@gmail.com",
        username: "XXXXX@gmail.com",
        password: "XXXXX",
        to:       "XXXXX@hotmail.co.jp",
        sub:      "test sub",
        msg:      "test msg",
    }

    if err := gmailSend(m); err != nil {
        log.Println(err)
        os.Exit(1)
    }
}

type mail struct {
    from     string
    username string
    password string
    to       string
    sub      string
    msg      string
}

func (m mail) body() string {
    return "To: " + m.to + "\r\n" +
        "Subject: " + m.sub + "\r\n\r\n" +
        m.msg + "\r\n"
}

func gmailSend(m mail) error {
    smtpSvr := "smtp.gmail.com:587"
    auth := smtp.PlainAuth("", m.username, m.password, "smtp.gmail.com")
    if err := smtp.SendMail(smtpSvr, auth, m.from, []string{m.to}, []byte(m.body())); err != nil {
        return err
    }
    return nil
}

次に、コピーしたsmtpパッケージを眺めてみると、コマンド発行は全て

  • func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error)

を通して行ってることがわかります。ここにログ出力を仕込みましょう。

smtpパッケージ
func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
    if args != nil {
        log.Println("[SEND] ", fmt.Sprintf(format, args...))
    } else {
        log.Println("[SEND] ", format)
    }
    id, err := c.Text.Cmd(format, args...)
    if err != nil {
        return 0, "", err
    }
    c.Text.StartResponse(id)
    defer c.Text.EndResponse(id)
    code, msg, err := c.Text.ReadResponse(expectCode)
    log.Println("[RECIEVE] ", code, " ", msg)
    return code, msg, err
}

テストとしてgmailのメール送信を実行しました。出力されるログは以下のようになります。完全な生ログではなく、マスクあるいは削除している部分があります。

2017/02/27 20:55:26 [SEND]  EHLO localhost
2017/02/27 20:55:26 [RECIEVE]  250   smtp.gmail.com at your service, []
SIZE 35882577
8BITMIME
STARTTLS
ENHANCEDSTATUSCODES
PIPELINING
CHUNKING
SMTPUTF8
2017/02/27 20:55:26 [SEND]  STARTTLS
2017/02/27 20:55:26 [RECIEVE]  220   2.0.0 Ready to start TLS
2017/02/27 20:55:26 [SEND]  EHLO localhost
2017/02/27 20:55:27 [RECIEVE]  250   smtp.gmail.com at your service, []
SIZE 35882577
8BITMIME
AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH
ENHANCEDSTATUSCODES
PIPELINING
CHUNKING
SMTPUTF8
2017/02/27 20:55:27 [SEND]  AUTH PLAIN XXXXXXX=
2017/02/27 20:55:27 [RECIEVE]  235   2.7.0 Accepted
2017/02/27 20:55:27 [SEND]  MAIL FROM:<XXXXXXX@gmail.com> BODY=8BITMIME
2017/02/27 20:55:27 [RECIEVE]  250   2.1.0 OK  - gsmtp
2017/02/27 20:55:27 [SEND]  RCPT TO:<XXXXXXX@hotmail.co.jp>
2017/02/27 20:55:27 [RECIEVE]  250   2.1.5 OK  - gsmtp
2017/02/27 20:55:27 [SEND]  DATA
2017/02/27 20:55:28 [RECIEVE]  354    Go ahead - gsmtp
2017/02/27 20:55:29 [SEND]  QUIT
2017/02/27 20:55:29 [RECIEVE]  221   2.0.0 closing connection - gsmtp

おっと、DATAコマンドで送ってる本文の内容がログ出力できてませんね。DATAコマンドを発行する関数Dataを実行するとWriterインスタンスを返り値として受け取ります。SendMailはそのWriterを利用してメール本文のデータをサーバに送信します。メール本文として送る文字列はmail.goのfunc (m mail) body() stringで作成した文字列そのままなので省略しますね。

改めてconversationの中身を見てみますと、サーバからのレスポンスで重要なものはEHLOの結果くらいです。EHLOのレスポンスとしてそのSMTPサーバが提供している機能一覧をうけとれます。その一覧に応じて処理の切り分けを行えるようになってますね。また、STARTTLSを行い、TLS通信になっていないとAUTH機能を提供していないことが分かります。当然のことながら、認証は必ずSTARTTLS→AUTHの順番になります。その他のコマンドに対するレスポンスはOK、NGを返しているだけですね。以上よりSMTP conversationの流れをまとめると次のようになります。

  1. EHLOで接続確認+TLS機能有無確認
  2. STARTTLSでTLS通信開始
  3. AUTHで認証
  4. MAILで送信元設定
  5. RCPTで送信先設定
  6. DATAでメール内容設定
  7. QUITでSMTP通信終了、メール送信

office365とgmailでメール送信

とまあSMTPについて細かく見てきましたが、どんな言語にもSMTPのライブラリはあります。多くの場合ライブラリのAPIリファレンスを参照するだけで事足りるかと思います。ただ、自分の場合はoffice365とgmailどちらも認証でハマったので、その点についてお話します:exclamation:

SMTPサーバ確認

gmailはsmtp.gmail.com:587office365はsmtp.office365.com:587ですね。

office365:504 5.7.4 Unrecognized authentication type

office365にPLAINタイプの認証をトライすると、504 5.7.4 Unrecognized authentication typeが帰ってきます。結論から言いますと、office365はLOGINタイプの認証しか扱っていません。EHLOコマンドのResponseを見てみましょう。

2017/02/25 22:37:09 [RECIEVE]  XXXXX.outlook.office365.com Hello [:::::]
SIZE 157286400
PIPELINING
DSN
ENHANCEDSTATUSCODES
AUTH LOGIN
8BITMIME
BINARYMIME
CHUNKING
SMTPUTF8

gmailと違い、返されてるAUTHタイプはLOGINのみであることが確認できます。AUTHタイプはユーザ名、パスワードの受け渡しプロトコルを規定するものです。PLAINタイプは、base64変換したユーザ名、パスワードをAUTHコマンドと同時にサーバに投げつける認証方式です。LOGINタイプは対話的な認証方式になります。"Username:"、"Password:"と順番にサーバ側からレスポンスがあり、クライアントもその順番に認証情報を送ります。

参考:SMTP認証

そしてGo言語のnet/smtpパッケージなんですが、実はLOGINタイプの認証を実装していません5。サンプルコードを後述しますが、こちらの実装を参考にしています。

gmail:アプリパスワードの設定

googleアカウントで2段階認証を設定している場合、アプリパスワードが必要になります。今回、新規にメール送信クライアントを作成しますので、まずはここで新しいパスワードを作成し、ユーザ名=<gmailアドレス>、パスワード=<作成したパスワード>としてPLAINタイプの認証 で通りました。

サンプルコード

サンプルコードはこちらです。
gmail、office365どちらでもメールを送信できるサンプルコードです。認証がPLAINタイプ、LOGINタイプ、どちらにも対応するplainLoginAuthを作成しました6。SMTPサーバ、認証用ユーザ名、パスワードは環境変数から取ってくる形にしました。メール本文はtemplateパッケージを使って埋め込みする形を想定しています。テンプレートはRFC822スタイルで作成されることを想定しており、net/mailパッケージで宛先と送信元の情報をパースしてSendMail関数に渡しています。送信日付は実行時の時間をtime.Now()で取得して最後にテンプレートに加えています。

RFC 822スタイルのメールフォーマット

net/smtpパッケージのSendEmail関数はRFC822スタイルのメール本文を受取、送信します。メールを送る際は自力でこのフォーマットの文字列を作る必要があります。RFC822で規定されているメールはヘッダー部とボディ部で構成されます。ヘッダー部はヘッダーフィールドの集まりで、

     field       =  field-name ":" [ field-body ] CRLF

と言う形をとります。ヘッダ部からCRLFのみの空行を一つ挟み、それ以降をボディ部とします。とまあ、文章で説明しましたが、サンプルを見た方が分かりやすかったです。

Date: Mon, 23 Jun 2015 11:40:36 -0400
From: Gopher <from@example.com>
To: Another Gopher <to@example.com>, Other Gopher <to2@example.com>
Subject: Gophers at Gophercon

Message body

(※ヘッダー部の改行はCRLFです。ボディ部は基本自由ですが、すべてCRLFにしたほうが分かりやすいかもしれません)
MAILコマンド、RCPTコマンドで送信元、送信先の情報をSMTPサーバに送っていますが、メール本文のヘッダ部が自動的に挿入されるわけではありません。宛先指定したのに送信先にBCCで送られてしまっているようなケースは、メール本文のヘッダ部が設定されてない可能性があります。

最後に

本記事では今日あまり意識することもないかと思われるSMTPについてお話しました。本文中にも述べましたとおり、各言語のライブラリを利用すればSMTPの仕様詳細を気にせずとも気軽にSMTP通信を利用できるかと思います。とはいえ、普段使ってるメールクライアントがメールサーバとどう通信してるんだろう、とか、SMTPって聞いたことあるけど中身はどうなってるんだろう、といった興味を抱いてる方の理解の一助になれば幸いです。

また、Go言語を利用した側面で言えば、Goの標準ライブラリに変更を加えることが簡便であること、net/smtpパッケージにLOGIN認証の実装がなく、かつ、net/smtpパッケージが機能追加禁止の状態にあることを知ったのは、筆者にとって発見でした:grin:

最後に、冒頭の問題が解消したのかどうなったのか。。。をここに書くには、この記事は余白が少なすぎるみたいです。お読みいただきありがとうございます。


  1. 別に大した事じゃないんですけど、なぜかすごくエネルギー使いますよね。。。 

  2. この話はフィクションです。。。フィクションです:sob:。 

  3. POPとIMAPは受信用、SMTPは送信用プロトコルです。また、今時のメールサービスならREST API(HTTP)での操作も可能みたいですね。 

  4. EHLOコマンドの文字を初めて見たとき、RFC文書に誤字がある:interrobang:って勘違いして驚きました:sweat_smile:。HELOコマンドもありますが、EHLOコマンドは後にできたHELOコマンドの拡張版で、基本的にEHLOコマンドを使うことを推奨されています。 

  5. Goのnet/smtpパッケージは機能追加禁止のようです。LOGIN AUTHの実装くらいはあってもよいかと思うんですが、「SMTPというプロトコルは未来がないから力入れてもしょうがない。全部RESTにしようぜ:exclamation:」って話なんでしょうか?:thinking: 

  6. 作成しました、、、が、gmailとoffice365に限ればloginAuthだけで十分ですね:sweat:。