アプリケーションの実装でメールを送る処理があるけれど、ローカル環境、開発環境でうっかり誰かに送ってしまうことを回避するために、ダミーのメールサーバに送るようにする、ということをしたい。
というのを叶えるツールもいろいろある。mailhogがとてもよさそう。
- mailhog/MailHog: Web and API based SMTP testing
- gessnerfl/fake-smtp-server: A simple SMTP Server for Testing purposes. Emails are stored in an in-memory database and rendered in a Web UI
- sj26/mailcatcher: Catches mail and serves it through a dream.
- mjl-/mox: modern full-featured open source secure mail server for low-maintenance self-hosted email
今回は、自分たちのつくっているGoアプリケーションに合わせていろいろやりたい(特定のIDをくっつけて追跡できるようにしたり、E2Eテストを簡単にするためのSDKを提供したり)ので、とくにそういったツールを使わずにまずは土台を固めてみる。
SMTPサーバを立てる
まず標準パッケージを見たもののサーバ機能はなかった。
smtp package - net/smtp - Go Packages
生TCPでSMTPサーバを実装するのはちょっと大変なので誰かの作った物を使いたい。いくつかパッケージを見たけれど、これがシンプルに使えそうでよさそう。
emersion/go-smtp: An SMTP client & server library written in Go
READMEに書いてあるものをコピペしたらもうSMTPサーバ(受け取りのみ)が立つ。Session
が1つのメールに相当し、次のような順番で各関数が呼ばれる。
- Backend.NewSession
- Session.AuthPlain
- Session.Mail
- Session.Rcpt
- Session.Data
- Session.Reset
- Session.Logout
NewSessionでSessionを作るときにデータストアのポインタを渡しておいて、Logoutのタイミングでそれを参照し自身のSessionを保存する。これでよさそうだけど、ここで待ちが発生するとクライアントを待たせるので、裏で保存したほうがよい。
func (b *smtpBackend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
return &smtpSession{store: b.store}, nil
}
func (s *smtpSession) Logout() error {
go func() {
s.store.SaveSMTPSession(s)
}()
return nil
}
ちなみにSTARTTLSは対応していないっぽいので、アプリケーション側はそれをしないようにする必要がある。
メールを送る
試すだけならREADMEに書いてあるとおり、telnetで試せる。telnetがなくてもcurlでできる(知らなかった)
$ curl -v telnet://localhost:1025
アプリケーションの設定も変えてこれに向ければOK。
MIMEをパースする
Session.Data で入ってくる内容は素のメールデータ、MIMEエンコーディングされたものが入っている。このままでは扱いにくいのでまずはパースしたい。こちらもいくつかパッケージを見たけれど、これがよさそうな雰囲気を感じた。
jhillyerd/enmime: MIME mail encoding and decoding package for Go
こんな感じで保存したデータを投入すればパースしてヘッダーやら本文やらを取得できる。
e, err := enmime.ReadEnvelope(strings.NewReader(data))
ヘッダーには全部入っているので .AddressList() したものと内容が被る。除外したければヘッダーを一個ずつ確認する必要がある。例えば…
keys := e.GetHeaderKeys()
headers = make([]*smtpHeader, 0, len(keys))
for _, k := range keys {
// ignore to/cc/bcc
switch strings.ToLower(k) {
case "to", "cc", "bcc":
continue
}
headers = append(headers, &smtpHeader{
Key: k,
Value: e.GetHeader(k),
})
}
いろいろやる
と、ここまできて、mailhogをメインに動かしてそのAPIを使ってどうこうするフロントエンドなラッパーを作る、でもいいんだよな〜〜って思ったのだった。ただここで挙げるようなことをやるのはちょっとむずかしそうなので、やっぱり自作がいいような気がする。
もともとの想定では特定のIDをくっつけてトレーサビリティを〜だとか、E2Eを〜だとか、そういったことをしたかったけれど、ここまで来たなら、クライアントはヘッダーに適当な値をつける + パース時に取り出す をすれば十分達成できそう。E2Eするならそれに加えて、特定のIDを検索する
使い勝手を上げるために類似ツールであるような画面を作るとかもできそう。パースしたものをDBに保存しておくか、生データをまるっと全文検索できるようにしたら、各種メールクライアントのような柔軟な検索も提供できそう。
類似ツールだとメールを転送できます!というのもあるけれど、生データを保持しておけば、それを emersion/go-smtp のクライアントで送ってもいいし、net/smtpでもいいし、任意のgo-mailパッケージでもいい。生データなのでどうにでもなる。
開発環境向けとはいえ、 誰もが無制限に全部のメールを見れる状態はあまり良くなさそうなので、RBACじゃないけど、決まったヘッダをメールにつけておいて、見える用の画面/APIで認証したユーザの属性によって返す返さないのコントロールをする、みたいなこともできそう。