giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

Go で AWS SES SDK を使ってメールを送る

こんにちは、ギフティでエンジニアをやっている中屋(@nakaryo79)です!

最近(というほどでもないですけど)、LINE や Facebook Messenger、Slack などの普及でメールを書くことはめっきり減ってしまった現代ですが、システム開発をしていると何かとメール送信を扱う場面がありますよね。

ちょうど AWS SDK for Go を利用してメール送信を実装したので、今回はその解説をします。

※ ちなみにサムネ画像の gopher くんは tenntenn さんことTakuya Ueda さんのものを拝借しました(かわいい)

前提

Go SDK v2 SES v2 を利用します。

Go Version は 1.17 です。

SMTP or API or SDK

SES にリクエストを送る方法として、SMTP で送る方法と API を叩く方法があります。

これから新規で作る場合は基本的に API を利用しますが、元々 SMTP を利用した実装が既にあり、そこから乗り換える場合などは SMTP を引き続き使うのもありでしょう。

API の場合、API エンドポイントを直接叩く方法と、SDK を使う方法がありますが、特に理由がなければ SDK を使った方が実装が簡略化できます。

SDK のバージョンに注意

Go の AWS SDK は複数バージョンあるので注意が必要です。 まず SDK 自体が v1、v2 があります。

「Go SES」でググると割と v1 の記事も出てきてうっかり v1 で実装しちゃったりしてややこしいのですが、v2 を使うようにしましょう。

ただし、SDK v2 を使うには Go のバージョンが1.15以上である必要があります。

さらに SDK の中に SES v1 と SES v2 があって、最新版は v2 です。

ですので、以下のように自分の環境に合わせて SDK を選択しましょう。

※ SDK v2 は2021年1月に GA されており、CPU やメモリ効率の向上や IF の簡素化、config の統一など利便性が向上されているので、できれば v2 を使うのを推奨します

使い方

最新の SDK v2 SES v2 を利用した実装サンプル

メソッド

v2 でメール送信を実行するには以下の二つのメソッドを使います。

  • SendEmail
    • 最も基本的なメソッド
  • SendBulkEmail
    • 名前の通り大量送信用で、宛先(To、CC、BCCのセット)を複数指定可能

v1 にはメールテンプレートを使う時用のメソッドなどが個別に定義されていましたが、v2 ではメソッドの引数の構造体の中にそれらの設定が押し込まれ、メソッドとしては統一された形になっています。 これによりインターフェースを作るときに定義するメソッドを減らせるというちょっとした恩恵がありました。

実装

以下は SendEmail メソッドを利用した実装例です。 ※ エラーハンドリングは省略

package main

import (
    "context"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/sesv2"
    "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)

const region = "ap-northeast-1"

var (
    fromEmailAddress = "bill@example.com"
    toEmailAddress   = "steve@example.com"
    subject          = "hey"
    body             = "hello world"
)

func main() {
    ctx := context.Background()

    // sdk API Client 作成
    cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
    if err != nil {
        // error handling
    }
    client := sesv2.NewFromConfig(cfg)

    // SES API に投げ込むパラメタを作る
    input := &sesv2.SendEmailInput{
        FromEmailAddress: &fromEmailAddress,
        Destination: &types.Destination{
            ToAddresses: []string{toEmailAddress}, // 配列なので複数指定可能
        },
        Content: &types.EmailContent{
            Simple: &types.Message{
                Body: &types.Body{
                    Text: &types.Content{
                        Data: &body, // 本文
                    },
                },
                Subject: &types.Content{
                    Data: &subject, // 件名
                },
            },
        },
    }

    // メール送信
    res, err := client.SendEmail(ctx, input)
    if err != nil {
        // error handling
    }
    fmt.Println(res.MessageId)
    fmt.Println("success!")
}

SES 側でのメールアドレスの認証と、config の初期化を正しく設定すれば、これでメールが送信できるはずです。

(SES 側の諸々のセットアップは公式を確認)

送信結果の取得

SendEmail が成功した(= エラーがリターンされなかった)からと言って、メールが相手に届いたわけではありません。 SES API ではメール送信のエンキューまでしか行われず、実際の送信処理は AWS 側で非同期で行われます。

そのエンキューに成功したら SDK メソッドは成功を返す作りになっているので、ここではキューイングに成功したということしかわかりません。 バウンスしたかどうかを知るには別途、SNS やフィードバック転送などを利用して送信結果を取得しないとわからないので注意が必要です。

https://aws.amazon.com/jp/premiumsupport/knowledge-center/ses-options-for-bounce-information/

よく見るのは SNS からの Lambda 経由で DynamoDB などにバウンス情報を突っ込んでおく方法です。 SDK のメソッドが MessageId を返してくれるので、アプリケーション側ではそれを保存しておいて、後で突合するといった流れになります。

ちなみにこの MessageId は SES 内でユニークな値らしく、いわゆる RFC に則った メールID とは別物なので、別の配信サービスから乗り換える際はデータ形式の違いやユニーク制約など、過去データのマイグレーションにも気を配っておきたいところです。

タイムアウト

API コールのタイムアウトには context を使います。 以下は 3秒でタイムアウトする例です。

   ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    // メール送信
    res, err := client.SendEmail(ctx, input)
    if err != nil {
        // error handling
    }

テスト

単体テスト

SDK v1 には ***iface というテストモッキング用のインターフェース定義パッケージが提供されていましたが、v2 ではなくなってしまったので自分でインターフェースを定義する必要があります(議論はあったようです https://github.com/aws/aws-sdk-go-v2/issues/786 )。

現状では「Accept interface, return struct」に則って、Go らしく使う側が依存したいメソッドのみを定義したインターフェースを作るというのが理にかなっていそうです。

(参考:https://github.com/golang/go/wiki/CodeReviewComments#interfaces

以下のようにインターフェースを定義して、その型の struct を引数として受け取れるようにしておきます。

type sesAPI interface {
    SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)
}

func main() {
    ctx := context.Background()
    cfg, _ := config.LoadDefaultConfig(ctx)
    client := sesv2.NewFromConfig(cfg)
    _ := send(ctx, client)
}

// 引数の型をインターフェースにしておく
func send(ctx context.Context, client sesAPI) error {
    // 省略
    res, err := client.SendEmail(ctx, input)
    // 省略
}

こうしておけばテストで SendEmail メソッドをモックすることができるので、send 関数を SDK に依存させない形でテストが書けます。

type sesAPIMock struct{}

func (m *sesAPIMock) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
    // モックの振る舞いを記述する
    return nil, nil
}

func TestSend(t *testing.T) {
    mock := sesAPIMock{}
    if err := send(context.Background(), &mock); err != nil {
        // write test code
    }
}

結合テスト

結合テストのように実際にビルドしたものを動かしてテストしたい場合は、テスト用の SES を立てるか、テスト用のオレオレコンテナを立てるか、テストダブルを使うかを設定できるようにしておくなどの選択肢があります。

テスト用に本物の SES を立てる場合は、(後述しますが)レートリミットに気を配る必要があります。一度に送信できるメール数に上限があるため、テストをパラレルで実行したり、1日に大量にテストを回したりする状況では少し厳しいかもしれないです。

エラーハンドリング

公式 には、errors.As を利用した型アサーションで、エラー型をハンドリングしようと書いてあります。

SDK メソッドが返しうるエラーの型定義は以下の types パッケージに定義されています。

https://github.com/aws/aws-sdk-go-v2/blob/main/service/sesv2/types/errors.go

これを使ってエラーハンドリングをしてみましょう。

   res, err := client.SendEmail(ctx, input)
    if err != nil {
        var ase *types.AccountSuspendedException
        var aee *types.AlreadyExistsException
        var bre *types.BadRequestException
        if errors.As(err, &ase) {
            // handle AccountSuspendedException
        }
        if errors.As(err, &aee) {
            // handle AlreadyExistsException
        }
        if errors.As(err, &bre) {
            // handle BadRequestException
        }
        panic("panic!!")
    }

メールヘッダーの文字化け

アラートメールなどのフィルタリングのために エラー通知 のような文字列を送信者名に含めたいといったことはあると思います。

メールヘッダーに日本語のようなマルチバイト文字を入れたい場合、そのままだと文字化けしてしまいます。
(Gmail 宛てに送った結果、件名と本文は文字化けしませんでしたが、ヘッダーはダメでした)

そのため、対象文字列の base64 でのエンコードとデコード用のパラメーターを付与してあげる必要があります。

var fromEmailAddress = "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte("エラー通知")) + "?= <sample@example.com>"

※ 適切にデコードされるかはクライアントのエンコード設定に依存する部分があります

レートリミット

SES には送信クオータという概念があり、秒間、日間それぞれに対して流量制限がかけられています。
(サンドボックス環境では秒間1リクエスト、日間200リクエストまで)
今のクオータがいくつなのかはコンソールから確認することができます。

ユースケース次第ではありますが、クオータは申請すればある程度まで上げることが可能です。

設定されたクオータを超過すると API コールがエラーになるため、スパイクする恐れがあるようなアプリケーションでは叩く側にレートリミットやリトライの機構を設ける必要があります。 トークンバケットのようなものを用意しておいて、一時的なスパイクに耐えられるようなアーキテクチャが望ましいです。

慢性的にリミットすれすれの場合は、そもそもクオータの引き上げを行った方が良いですね。

終わりに

Go と AWS SES を使ってメール送信する実装を見てきました。

インフラ周りで既に AWS を使っていれば追加の契約等もなく使えるので非常に便利です。

(とはいえ、 SPF や DKIM などの設定も自分でやらないといけないし、バウンスレートが高まると停止される恐れがあるといったデメリットもあります)

Go でメール送信を実装する際はぜひ参考にしてみてください!