Go言語でSMTPメール送信

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

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

というコミュニケーションを諦め[^comm]、

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

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

[^comm]: 別に大した事じゃないんですけど、なぜかすごくエネルギー使いますよね。。。
[^fict]: この話はフィクションです。。。フィクションです:sob:。

# SMTP
メールサーバのプロトコルにはPOP、IMAPもありますが、今回は送信に限った話なので[SMTP](https://ja.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol)についてのみ解説します[^proto]。

SMTPはSimple Mail Transfer Protocolの略称で名前の通りメール転送用のプロトコルです([RFC5321](https://tools.ietf.org/html/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](https://tools.ietf.org/html/rfc4409)

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

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

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

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

## GoでSMTP対話の確認
仕様を一通り確認したところで、次は実際のconversationを覗いて見ましょう。Go言語には[net/smtpパッケージ](https://golang.org/pkg/net/smtp/)があります。メールを送信するには、

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

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

``` bash
# 環境は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パッケージをインポートして、メール送信テストコードを作成します。

``` go: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)`

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

``` go: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:587](https://support.google.com/a/answer/176600?hl=ja)、[office365はsmtp.office365.com:587](https://support.office.com/ja-jp/article/%E4%B8%80%E8%88%AC%E6%B3%95%E4%BA%BA%E5%90%91%E3%81%91-Office-365-%E7%94%A8%E3%81%AE-Outlook-POP-%E3%81%8A%E3%82%88%E3%81%B3-Outlook-IMAP-%E3%81%AE%E8%A8%AD%E5%AE%9A-7fc677eb-2491-4cbc-8153-8e7113525f6c
smtp.gmail.com)ですね。

## 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認証](http://www.bnote.net/dataroom/smtp_auth.shtml)

そしてGo言語のnet/smtpパッケージなんですが、実は**LOGINタイプの認証を実装していません**[^LOGIN]。サンプルコードを後述しますが、[こちらの実装](https://gist.github.com/andelf/5118732)を参考にしています。


[^EHLO]: EHLOコマンドの文字を初めて見たとき、RFC文書に誤字がある:interrobang:って勘違いして驚きました:sweat_smile:。HELOコマンドもありますが、EHLOコマンドは後にできたHELOコマンドの拡張版で、基本的にEHLOコマンドを使うことを推奨されています。
[^LOGIN]: Goの[net/smtpパッケージは機能追加禁止](https://golang.org/pkg/net/smtp/)のようです。LOGIN AUTHの実装くらいはあってもよいかと思うんですが、「SMTPというプロトコルは未来がないから力入れてもしょうがない。全部RESTにしようぜ:exclamation:」って話なんでしょうか？:thinking:

## gmail:アプリパスワードの設定
googleアカウントで2段階認証を設定している場合、[アプリパスワード](https://support.google.com/mail/answer/185833?hl=ja)が必要になります。今回、新規にメール送信クライアントを作成しますので、まずは[ここ](https://myaccount.google.com/apppasswords)で新しいパスワードを作成し、ユーザ名=\<gmailアドレス\>、パスワード=\<作成したパスワード\>としてPLAINタイプの認証 で通りました。

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

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

## RFC 822スタイルのメールフォーマット
net/smtpパッケージのSendEmail関数は[RFC822スタイル](https://tools.ietf.org/html/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:。

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