こんにちは。コミュ障社会人のにとれすと申します。みなさんは出社してまず何をしますか?いや、出社直後でなくともどこかの時間に通知を確認するルーチンワークがありますよね?メールを確認して、チケット管理システムからの通知を確認して、社内システムがあればその通知を確認して。。。特に上位レイヤーのポジションについている方には承認のタスクがあり、OK、NGの判断を下す依頼がたくさんあると思います。依頼者はこの承認フローを一営業日中には終わらせてくれるだろうと期待していますので、もし上位レイヤーの方がこれを忘れてしまうと予想外の遅れが発生します。前置きが長くなりました。この記事はコミュ障の筆者が、あまり通知を確認してくれない上司に対し、
- 毎日自分が承認依頼状況を確認し、上司に伝える
- 承認依頼を定期的に確認するルーチンを組むよう上司にお願いする
というコミュニケーションを諦め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 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言語のシンプルな取り回しはこういうとき楽ですね。
mainパッケージからコピーしてきたsmtpパッケージをインポートして、メール送信テストコードを作成します。
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)
を通して行ってることがわかります。ここにログ出力を仕込みましょう。
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の流れをまとめると次のようになります。
- EHLOで接続確認+TLS機能有無確認
- STARTTLSでTLS通信開始
- AUTHで認証
- MAILで送信元設定
- RCPTで送信先設定
- DATAでメール内容設定
- QUITでSMTP通信終了、メール送信
office365とgmailでメール送信
とまあSMTPについて細かく見てきましたが、どんな言語にもSMTPのライブラリはあります。多くの場合ライブラリのAPIリファレンスを参照するだけで事足りるかと思います。ただ、自分の場合はoffice365とgmailどちらも認証でハマったので、その点についてお話します
SMTPサーバ確認
gmailはsmtp.gmail.com:587、[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認証
そして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パッケージが機能追加禁止の状態にあることを知ったのは、筆者にとって発見でした。
最後に、冒頭の問題が解消したのかどうなったのか。。。をここに書くには、この記事は余白が少なすぎるみたいです。お読みいただきありがとうございます。
-
別に大した事じゃないんですけど、なぜかすごくエネルギー使いますよね。。。 ↩
-
この話はフィクションです。。。フィクションです。 ↩
-
POPとIMAPは受信用、SMTPは送信用プロトコルです。また、今時のメールサービスならREST API(HTTP)での操作も可能みたいですね。 ↩
-
EHLOコマンドの文字を初めて見たとき、RFC文書に誤字があるって勘違いして驚きました。HELOコマンドもありますが、EHLOコマンドは後にできたHELOコマンドの拡張版で、基本的にEHLOコマンドを使うことを推奨されています。 ↩
-
Goのnet/smtpパッケージは機能追加禁止のようです。LOGIN AUTHの実装くらいはあってもよいかと思うんですが、「SMTPというプロトコルは未来がないから力入れてもしょうがない。全部RESTにしようぜ」って話なんでしょうか? ↩
-
作成しました、、、が、gmailとoffice365に限ればloginAuthだけで十分ですね。 ↩