ちょっと時間かかるけど Amazon SES でのメール送信エラーをなるべく検出する方法

[Amazon SES](http://aws.amazon.com/jp/ses/) を使ってメールを送ると、普通は送信エラーをその場で検出できません。その場でっていうのは、非同期ではなくって意味です。ここでは SES に [Amazon SNS](http://aws.amazon.com/jp/sns/) と [Amazon SQS](http://aws.amazon.com/jp/sqs/) を組み合わせて、なるべく送信エラーをその場で検出するコードを紹介します。エラーチェックのためにちょっと時間かかりますが、我慢してください。

処理の概要としてはこんな感じです。

```text
俺コード → 下にある SQS のキューをチェック
   ↓ メール送信
Amazon SES
   ↓ 送信エラー等のフィードバック
Amazon SNS
   ↓ フィードバックのキューイング
Amazon SQS
```

なんだか分かりにくいですね。エラーがあるときと無さそうなときに分けるとこうなります。

```text:エラーがあるとき
SES でメール送信
　↓
（AWS 内で SES から SNS へ送信エラーがフィードバックされる）
　↓
（AWS 内で SNS から SQS へフィードバックがキューイングされる）
　↓
SQS のキューをチェック
　↓
送信エラーが発覚！
```

```text:エラーが無さそうなとき
SES でメール送信
　↓
SQS のキューをチェック...
　↓
SQS のキューをチェック...
　↓
SQS のキューをチェック...
　↓
フィードバックが無いからきっと成功したんだ
（もしかしたらまだエラーになっていないだけかもしれない）
```

SES, SNS, SQS のつなぎ込みの詳細については公式ドキュメントに譲ります（[SES → SNS](http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/NotificationsViaSNS.html), [SNS → SQS](http://docs.aws.amazon.com/sns/latest/dg/SendMessageToSQS.html)）。それぞれのサービスに対する設定はざっくりこんな感じです。途中で色々な ARN を参照する必要が出てくるので、サービスごとにタブを開いておくと捗ります。

* SES で送信元アドレスを作って、フィードバック設定に SNS のトピックを設定する
* SNS でトピックを作り（名前は例えば ses-feedback）、サブスクリプションにキューを設定する
* SQS でキューを作り、SNS のトピックに対して SendMessage パーミッションを設定する

この設定ができたら、送信エラー等のフィードバックが SQS に入ってくるか確認しましょう。コンソールで SES を開くなどして ``bounce@simulator.amazonses.com`` にメールしてください。このアドレスは [SES の sandbox](http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/MailboxSimulator.html) に書いてあるサンドボックス用アドレスです。サンドボックス外の人は ``zan-nen@sayaka.chan`` とかに送ってみても OK です。

メールを送信したら SQS のコンソールを開いてください。念のため右上の Refresh を押して Messages Available が 1 になってたらめでたく成功です。中身はたぶんこんな感じです。

```
{
  "Type" : "Notification",
  "MessageId" : "212f5821-b209-47bc-84c8-8cbed9de7c69",
  "TopicArn" : "arn:aws:sns:us-east-1:略:ses-feedback",
  "Message" : "JSONをエスケープしたもの",
  "Timestamp" : "2012-10-24T12:18:31.531Z",
  "SignatureVersion" : "1",
  "Signature" : "略",
  "SigningCertURL" : "略",
  "UnsubscribeURL" : "略"
}
```

ふぅ……ここまでで環境設定は終わり。ここからはコーディングです。言語は Python、ライブラリは [boto](http://docs.pythonboto.org/en/latest/) を使っています。Java でも aws-sdk 使えば同じ処理を書くことができます。

```python
# -*- coding: utf8 -*-

import boto
from boto.sqs.message import RawMessage
import json
import time

AWS_KEY = "YOUR_AWS_KEY"
AWS_SECRET_KEY = "YOUR_AWS_SECRET_KEY"

def send_email(source, subject, body, to_addresses, timeout=5):
    # SES、SQS へのコネクションを作成する。
    ses = boto.connect_ses(AWS_KEY, AWS_SECRET_KEY)
    sqs = boto.connect_sqs(AWS_KEY, AWS_SECRET_KEY)

    # キューの取得。RawMessage を指定しなかったらえらいことになった
    queue = sqs.get_queue("ses-feedback")
    queue.set_message_class(RawMessage)

    # ここまで下準備。ほんとはこの関数の外でやったほうがいい。

    # メールを送信する
    ses_resp = ses.send_email(source, subject, body, to_addresses)

    # メール送信結果の messageId を記録しておく。
    # キューの中を検索するときのキーとして使える唯一無二の文字列。
    ses_message_id = ses_resp["SendEmailResponse"]["SendEmailResult"]["MessageId"]
    print "Sent mail %s" % ses_message_id

    # タイムアウトするまでキューを調べる
    start_time = time.time()
    while time.time() < start_time + timeout:
        time.sleep(0.5)

        # キューに入っているメッセージを最大10個取得する。
        # キューから取ったメッセージはなるべく早く手放したいので visibility timeout は1秒。
        # 注: SQS の特性上、メッセージが10個入っていても一度に10個取れない場合がある
        sqs_messages = queue.get_messages(10, visibility_timeout=1)

        for sqs_message in sqs_messages:
            # メッセージの中からフィードバックそのものを取り出す
            ses_feedback = json.loads(json.loads(sqs_message.get_body())["Message"])

            # フィードバックがあったメール送信結果の messageId を出力
            print "Found feedback %s" % ses_feedback["mail"]["messageId"]

            # さっき送ったメール送信結果の messageId と一致すればキューから削除しておしまい。
            # 見つからなければタイムアウトまでキューをチェックし続ける。
            if ses_message_id == ses_feedback["mail"]["messageId"]:
                queue.delete_message(sqs_message)
                return False
    return True

if __name__ == "__main__":
    source = "sender として verify したアドレス"
    # 実行例
    print "should be true..."
    print send_email(source, "subject", "body", ["success@simulator.amazonses.com"])
    print "should be false..."
    print send_email(source, "subject", "body", ["bounce@simulator.amazonses.com"])
```

処理の説明は、コメントに書いたとおりです。正常にメールを送ることができた場合は、タイムアウトするまで待つことになります。タイムアウトの数字は各自のさじ加減でお願いします。個人的には長くて5秒、なるべく3秒かなぁと思ってます。実際にコードを走らせてみて、送信エラーの検出率を見ながら調整するのがベストですね。

サンプルコードの注意点とか:

- JSON の要素チェックとかザルです。突然の死、あります。
- SES からのフィードバックである bounce（バウンスメール）や complaint（苦情メール）以外がキューに入るケースを考慮していません。SQS への SendMessage を許可してないので入らないと思いますけど。
- お金はほとんどかからないと思います。
- 2012/10 現在、SES のフィードバックは US-East-1 の SNS にしか送れないようなので、日本リージョンへのこだわりがある方は残念でした。

参考にしたサイト:

- [RawMessage の指定について](http://globalconstant.scnay.com/2011/03/02/programming-sqs-with-python-and-boto/)
- [Amazon SNSからSQSへメッセージを送信する](http://yustam.hatenablog.com/entry/20120924/1348452883)