GoでSMTPを喋る

SMTPを喋ったことがなかったので、書き初めついでに喋った。なのでこの記事では日記として要点をまとめています。

ちなみに、ここで言う「喋る」とはSMTPにおけるリプライとコマンドを自分で読み書きすることだけども、Goには標準でnet/smtpパッケージが備わっていて、単にメールを送信したいだけなら普通はそちらを使うと思う。この記事は趣味です。

SMTPサーバーに接続する

こだわりはないのでGmailのSMTPサーバー(smtp.gmail.com)を利用する。
25番ポートは経路が暗号化されていなかったりOP25Bされてたりなので、SMTP over TLSするために465番ポートに接続する。
Goの場合、crypto/tlsパッケージで提供されるDial関数で単に接続を確立すればいい。

conn, err := tls.Dial("tcp", "smtp.gmail.com:465", &tls.Config{})

リプライをパースする

リプライに応じて次に送るコマンドを決めるため、リプライをパースする実装を書いておきたい。SMTPは古き良きテキストベースのプロトコルなので特にパースは難しくない。
RFCによると、ABNFで

textstring = 1*(%d09 / %d32-126) ; HT, SP, Printable US-ASCII

Reply-line = *( Reply-code "-" [ textstring ] CRLF )
          Reply-code [ SP textstring ] CRLF

Reply-code = %x32-35 %x30-35 %x30-39

なので、これを先頭3文字のコードとそれ以降のスペース区切りのテキストにパースする。ただ、単にSMTPを喋ってメールを送信するだけならテキストの方は捨ててしまっても問題ない気がする。

type (
	smtpReply struct {
		Code     int
		Messages []string
	}
)

func parseReply(rawReply string) (*smtpReply, bool, error) {
	code, err := strconv.ParseInt(rawReply[0:3], 10, 31)
	if err != nil {
		return nil, false, fmt.Errorf("invalid code: %s", err)
	}

	if code < 100 || code > 599 {
		return nil, false, fmt.Errorf("unsupported code: %d", code)
	}

	reply := &smtpReply{
		Code:     int(code),
		Messages: strings.Split(rawReply[4:], " "),
	}
	multi := rawReply[3] == '-'

	return reply, multi, nil
}

コード直後の4文字目の文字がスペースかハイフンかでリプライが複数行リプライかどうかが決まり、パースする上ではそれなりに重要なのでそれも考慮する。

クライアントっぽいものを作る

SMTPサーバーに接続してリプライもパースできるようになったら、リプライをパースしてそれをハンドルし適切なコマンドを送信する、という処理を書きやすいようクライアントっぽいものを書きたくなる。

type (
	smtpClient struct {
		conn    *tls.Conn
		reader  *bufio.Reader
		handler smtpReplyHandler
	}

	[...]

	smtpReplyHandler func(*smtpReply) []byte
)

func connectToSMTPServer(host string, handler smtpReplyHandler) (*smtpClient, error) {
	conn, err := tls.Dial("tcp", host+":465", &tls.Config{})
	if err != nil {
		return nil, fmt.Errorf("failed to connect smtp server: %s", err)
	}

	return &smtpClient{
		conn:    conn,
		reader:  bufio.NewReader(conn),
		handler: handler,
	}, nil
}

func (sc *smtpClient) startTransaction() error {
	var continued *smtpReply
	for {
		rawReply, err := sc.reader.ReadString('\n')
		if err != nil {
			return fmt.Errorf("failed to read reply from smtp server: %s", err)
		}

		reply, multi, err := parseReply(strings.TrimSuffix(rawReply, "\r\n"))
		if err != nil {
			return fmt.Errorf("smtp server replied wrong: %s", err)
		}

		if continued != nil {
			if continued.Code != reply.Code {
				return fmt.Errorf("smtp server replied wrong(code mismatch: %d <=> %d)", continued.Code, reply.Code)
			}
			reply.Messages = append(continued.Messages, reply.Messages...)
		}

		if multi {
			continued = reply
			continue
		} else {
			continued = nil
		}

		fmt.Printf("[server] --> [client] %s\n", reply)
		if reply.Code == 221 {
			fmt.Printf("smtp server replied 221. transaction finished\n")
			return nil
		}

		msg := sc.handler(reply)
		if len(msg) == 0 {
			continue
		}

		fmt.Printf("[server] <-- [client] %s", string(msg))
		if _, err = sc.conn.Write(msg); err != nil {
			return fmt.Errorf("failed to write: %s", err)
		}
	}
}

startTransactionメソッドはソケットからリプライを読み込んでパースしハンドラ(smtpReplyHandler型の関数)に渡す。もしハンドラがコマンドを返していたら、それをソケットへ書き込む。これを、コードが221なリプライが届きセッションの終了が判明するまで行う。

動かしてみる

ここまで書くと、とりあえずSMTPサーバーに接続してみる程度のものは動くようになる。次のようなmain関数を書いて試してみる。

func main() {
	client, err := connectToSMTPServer("smtp.gmail.com", func(reply *smtpReply) []byte {
		switch reply.Code {
		default:
			return []byte("QUIT\r\n")
		}
	})

	if err != nil {
		panic(err)
	}
	defer client.close()

	if err := client.startTransaction(); err != nil {
		panic(err)
	}
}

QUITコマンドはセッション終了を指示するコマンドで、この実装ではどんなリプライが受信されたとしてもとりあえずこのコマンドが送信される。実行すると以下のように標準出力に出力が流れる。

$ go run main.go 
[server] --> [client] 220 smtp.gmail.com ESMTP i127sm76053635pfe.54 - gsmtp
[server] <-- [client] QUIT
[server] --> [client] 221 2.0.0 closing connection i127sm76053635pfe.54 - gsmtp
smtp server replied 221. transaction finished
$

問答無用でQUITコマンドを送りつけているため、SMTPサーバーからの挨拶を受け取った後即座にセッションが終了してしまうがしょうがない。リプライに応じた適切なハンドラを実装すればメールが送信できるようになる。

というわけで次の内容を順番に送るようハンドラを実装すると

・拡張HELLO(EHLO)コマンド
・SMTP AUTH(AUTH PLAINコマンド)
・認証情報
・MAIL FROMコマンド
・MAIL TOコマンド
・DATAコマンド
・メールの内容
・QUITコマンド

以下のようになる。

const (
	gmailUsername = "<username>"
	gmailPassword = "<password>"
	sendTo        = "<mail-address>"
)

func main() {
	cmdWhenOK := []string{
		"AUTH PLAIN\r\n",
		fmt.Sprintf("MAIL FROM:<%s@gmail.com>\r\n", gmailUsername),
		fmt.Sprintf("RCPT TO:<%s>\r\n", sendTo),
		"DATA\r\n",
		"QUIT\r\n",
	}

	client, err := connectToSMTPServer("smtp.gmail.com", func(reply *smtpReply) []byte {
		switch reply.Code {
		case 220:
			return []byte("EHLO gmail.com\r\n")

		case 235, 250:
			msg := cmdWhenOK[0]
			cmdWhenOK = cmdWhenOK[1:]
			return []byte(msg)

		case 334:
			auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\x00%s\x00%s", gmailUsername, gmailPassword)))
			return []byte(auth + "\r\n")

		case 354:
			mail := fmt.Sprintf(`From: %s@gmail.com 
To: %s
Subject: test from go

Hello!
.
`, gmailUsername, sendTo)
			return []byte(strings.ReplaceAll(mail, "\n", "\r\n"))

		default:
			return []byte("QUIT\r\n")
		}
	})

	if err != nil {
		panic(err)
	}
	defer client.close()

	if err := client.startTransaction(); err != nil {
		panic(err)
	}
}

SMTPサーバーに接続した際の挨拶はコード220で送信されてくるので、それを起点に拡張HELLO(EHLOコマンド)を送信する。
するとEHLOコマンドのリプライとしてコード235と共にサポートしている拡張情報が返されるので、その次にSMTP AUTH(AUTH PLAINコマンド)を送信する。
ここでは「どのコマンドを送信したか?」という状態管理が面倒だったので、肯定応答(コード235, 250)が得られたら送信するべきコマンドのキューからコマンドを1つ取り出して送信するという手抜きの実装にした。

肯定応答以外のリプライへの対応としてはコード334と354が対象で、334は認証情報待ち、354はメール内容待ちなのでそれぞれ適当な内容を送り返して上げれば良い。

認証情報はヌル文字、ユーザー名、ヌル文字、パスワードという文字列の並びをBASE64でエンコードして送るだけだが、ここでいうパスワードはGoogleのアカウントに直接紐付いているパスワードではなく、アプリパスワードと呼ばれるものなのでそれを発行して使わないといけない点に注意。拡張HELLOの応答を見るとOAuth等にも対応していることがわかるんだけど、トークンの発行が面倒なのでアプリパスワードで認証させることにした。

メール内容は<CRLF>.<CRLF>という文字列で終端させないといけないのでそれも忘れずに。

メールを送信する

ここまで実装すると、実際にメールを送信できるようになる。めでたしめでたし。

[server] --> [client] 220 smtp.gmail.com ESMTP 17sm75179930pfv.142 - gsmtp
[server] <-- [client] EHLO gmail.com
[server] --> [client] 250 smtp.gmail.com at your service, [2405:6580:ae00:1000:6c3a:a5ea:9031:9e40] SIZE 35882577 8BITMIME AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH ENHANCEDSTATUSCODES PIPELINING CHUNKING SMTPUTF8
[server] <-- [client] AUTH PLAIN
[server] --> [client] 334 
[server] <-- [client] <base64 encoded username and password>
[server] --> [client] 235 2.7.0 Accepted
[server] <-- [client] MAIL FROM:<mail from>
[server] --> [client] 250 2.1.0 OK 17sm75179930pfv.142 - gsmtp
[server] <-- [client] RCPT TO:<mail to>
[server] --> [client] 250 2.1.5 OK 17sm75179930pfv.142 - gsmtp
[server] <-- [client] DATA
[server] --> [client] 354  Go ahead 17sm75179930pfv.142 - gsmtp
[server] <-- [client] From: <mail from> 
To: <mail to>
Subject: test from go

Hello!
.
[server] --> [client] 250 2.0.0 OK  1578216660 17sm75179930pfv.142 - gsmtp
[server] <-- [client] QUIT
[server] --> [client] 221 2.0.0 closing connection 17sm75179930pfv.142 - gsmtp
smtp server replied 221. transaction finished

その他

実装全体はGistで。

この記事が気に入ったらサポートをしてみませんか?