$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

Amazon SESとSNSを利用してバウンスメールを自動的にハンドリングする

メールを送るアプリケーションを作成していると、使われていないメールアドレスで登録された時や、携帯のメールアドレス変更によって登録されているメールアドレスが使えない状態になって、メール送信時にバウンスメールとして返ってくることがある。この時バウンスメールとして返ってくるメールアドレスをアプリケーション側で無効にするなどしておかないと、メール送信の無駄が発生する。また、AWS SESを使っている場合、バウンス率が高くなった場合、規制されることもある( https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/e-faq-bn.html )。

今回は、AWSを利用してバウンスメールとして返ってきたメールアドレスを自動的にハンドリングするというのをやってみたので、それを書いてみる。

前提

今回はAWS SESを利用して、メールを送信しているということを前提とする。

概要

  • Amazon SESでバウンスメールが返ってきた時にAmazon SNSに通知する
  • Amazon SNSに通知が来たら、特定エンドポイントに向けてリクエストを飛ばす
  • エンドポイントでAmazon SNSからの通知を受け取り、無効なメールアドレスをハンドリングする

Amazon SESでバウンスメールが返ってきた時にAmazon SNSに通知する

まずAmazon SESでメールを送った際、バウンスメールが返ってきたらAmazon SNSに通知を送るようにする。Amazon SNSとはプッシュ通知サービス( https://aws.amazon.com/jp/sns/ )で、通常モバイル通知などを担当するが、SESのエラーハンドリングにも用いることが出来る。

これは https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/configure-sns-notifications.html あたりを見ながら設定すれば良い。

まず最初にAmazon SNS上で、バウンスメール通知を受け取るためのTopicを作成する。Amazon SNSのHomeからCreate Topicを選択し、Topic Nameなどを入力してTopicを作成する。
f:id:shiba_yu36:20150827091823p:plain:h500
f:id:shiba_yu36:20150827092013p:plain:h300

続いてAmazon SES上で、作成したSNS Topicに対して、Bounce通知が行われるように設定する。Amazon SESのコンソールにいき、Domains > (バウンス通知を設定したいドメイン)を選択し、View Detailsを押し、NotificationsのEdit Configurationから設定する。Bouncesに先ほどのTopicを選択すればOK。

f:id:shiba_yu36:20150827092327p:plain:h300
f:id:shiba_yu36:20150827092348p:plain:h300

最後にAmazon SNS上でそのTopicをSubscribeすれば通知されるようになる。ここではひとまずEmailでBounce通知を受け取れるようにする。Amazon SNS上で設定したいTopicを選択し、Subscribe to topicしてEmailを選択すると良い。
f:id:shiba_yu36:20150827093223p:plain:h300
f:id:shiba_yu36:20150827092609p:plain:h300

ここまででAmazon SESでメールを送った際に、送り先からバウンスメールが届いたら、Amazon SNSに通知を飛ばし、その内容をメールで受け取る、ということが出来た。

Amazon SNSに通知が来たら、特定エンドポイントに向けてリクエストを飛ばす

Amazon SNSでは、通知が届いた時にHTTPもしくはHTTPSのエンドポイントにリクエストを飛ばすように設定もできる。これは簡単。先ほどのように、Subscribe to Topicをして、HTTPもしくはHTTPSを選択し、URLを入れるだけで良い。

f:id:shiba_yu36:20150827093342p:plain:h300

エンドポイントでAmazon SNSからの通知を受け取り、無効なメールアドレスをハンドリングする

Subscribeを手動で承認する

以上の設定まで行っても、まだHTTPへの通知がPending状態になっている。そのエンドポイントに送られたSubscriptionConfirmationに応答しないといけないためである。

一番簡単な方法は、そこに通知されたJSONからSubscribeURLをコピーし、ブラウザでアクセスすることである。これでSubscribeは完了できる。

ただしこれだと毎回設定のたびに人力でアクセスすることになるので、ここを自動化する。

Subscribeを自動で承認する

Subscribeを自動で承認するには、

のを知る必要がある。上のエントリを読んでおくと良い。

まず別の場所からのなりすましのアクセスを防ぐために、署名の検証をする必要がある。これは公式でAWS SDKが提供されている場合はそれを利用すれば良い。今回はPerlで実装したので、CPANモジュールのAWS::SNS::Verifyというのを利用する。

また、他の人が作成した別の人のTopicからのSubscribeでなりすます、というのを防ぐために、TopicArnの検証も同時に行う必要がある。これは http://dev.classmethod.jp/cloud/aws/amazon-sms-message-verification/ を見ると分かりやすい。


これらの検証が済んだら、SubscribeURLに対してアクセスすれば良い。自動的に承認するためのコードは以下のとおりである。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'SubscriptionConfirmation') {
    my $furl = Furl->new(timeout => 3);
    $furl->get($sns->message->{SubscribeURL});
}

Bounce通知が届いた時に無効なメールアドレスをハンドリングする

ここまででBounce通知が届いたらエンドポイントにJSONがPOSTされるようになっているので、次は無効なメールアドレスをハンドリングする。

SESでのBounce通知の形式は https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/notification-contents.html を参照する。これによると

  • もう使えないアドレスの場合はnotificationTypeにBouncebounce->bounceTypeにPermanentが入っている
  • bounce->bouncedRecipientsに無効なメールアドレスが入っている

という事がわかる。そこで無効なメールアドレスをハンドリングするために、これらを用いる。もちろんこの時も署名の検証などは行わないといけないので注意すること。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'Notification') {
    my $ses_message = decode_json($message->{Message});
    return unless $ses_message->{notificationType} eq 'Bounce' && $ses_message->{bounce}->{bounceType} eq 'Permanent';

    my $unavailable_mail_addresses = [
        map { $_->{emailAddress} } @{ $ses_message->{bounce}->{bouncedRecipients} },
    ];

    # あとはこの$unavailable_mail_addressesに対して順に処理をしていく
}

これで無効なメールアドレスを処理することができる。

最終的なエンドポイントの実装

Subscribeを自動で承認するのと、無効なメールアドレスのハンドリングは同じエンドポイントで行わないといけないので、最終的なコードは以下のようになる。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'SubscriptionConfirmation') {
    my $furl = Furl->new(timeout => 3);
    $furl->get($sns->message->{SubscribeURL});
}
elsif ($message->{Type} eq 'Notification') {
    my $ses_message = decode_json($message->{Message});
    return unless $ses_message->{notificationType} eq 'Bounce' && $ses_message->{bounce}->{bounceType} eq 'Permanent';

    my $unavailable_mail_addresses = [
        map { $_->{emailAddress} } @{ $ses_message->{bounce}->{bouncedRecipients} },
    ];

    # あとはこの$unavailable_mail_addressesに対して順に処理をしていく
}

またバウンスメールのテストをしたい場合は、そのためのメールアドレスがあるので利用すると良い。
https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/mailbox-simulator.html

まとめ

今回はAmazon SESとAmazon SNSを利用して自動的にバウンスメールに対応する方法について書いた。AWSは便利。


Scalaスケーラブルプログラミングを読んだ

Scalaの勉強をしたかったので読んだ。

この本はScalaの利用について、かなり詳しく書かれている本。Scalaの使い方にとどまらず、Scalaの内部についても触れているので、どういう仕組みで動いているかについても知ることが出来る。ただし、そのためにけっこう読むのに苦労するので、Scalaをとりあえず入門しようという用途には向かなそうだった。なんとなくわかりづらい理由はScalaの使い方と内部実装と、例に上がっているアルゴリズムの理論と、型言語の理論の説明が混ざってるからだと思う。

さらっと流し読みしたけど面白いところも多かった。例えば

  • 演算子はメソッド呼び出しにマッピングされ、全てがメソッド呼び出しとして扱われる
  • forに書かれた内容が、実際にはflatMapやmap、foreach、filterなどの関数呼び出しにマッピングされることがある
  • メソッドに渡せる型制約の柔軟な指定

など。


ただし、やはり入門でこれを読むのは挫折すると思う。入門の方法はまた今度まとめようと思うが、軽く調べた感じだと、以下の様な本をさらっと流し読みするのが良さそう。まだ読んでないけど。

またこの本だけでは、Scalaの例外処理に必須といえる、OptionやEither周りの話について全く言及されていないので、この辺りは個別に勉強する必要がある。例えば以下の様なエントリが参考になる。
yuroyoro.hatenablog.com
yuroyoro.hatenablog.com
hakobe932.hatenablog.com


まとめると、勉強になる本だが、難しいところも多かった。Perlでいうところの、初めてのPerlとプログラミングPerlの間くらいの位置付けの本だなと思う。

scalaでcase classをエイリアスする

Scalaではクラスをエイリアスしたいときはtypeというキーワードを利用する。

class Hoge() {
  def print(): Unit = { println("hoge") }
}

type Fuga = Hoge
val fuga = new Fuga()
fuga.print()

しかしこのやり方だけではcase classのエイリアスは出来ない。以下のコードを実行するとエラーが出力される。

case class Hoge() {
  def print(): Unit = { println("hoge") }
}

type Fuga = Hoge
val fuga = Fuga()
fuga.print()

// error: not found: value Fuga
// val fuga = Fuga()
//            ^
// one error found

case classの場合、valとtypeの両方を利用してエイリアスしなければならない。以下のコードでうまくいく。

case class Hoge() {
  def print(): Unit = { println("hoge") }
}

type Fuga = Hoge
val Fuga = Hoge
val fuga = Fuga()
fuga.print()

仕組み

どうしてこうなっているのかというと、以下の二点が絡んでくる。

  • object定義はtypeではエイリアス出来ず、valでエイリアス出来る
  • case classは暗黙的にコンパニオンオブジェクトを作成する

object定義はtypeではエイリアス出来ず、valでエイリアス出来る

objectは型ではないので、typeでエイリアス出来ない。(追記: xuweiさんによると、この認識は少し間違っているそうです。正しくはobjectの定義は「型と値を両方同時に定義」するとのことでした。教えて頂いてありがとうございます。)

object Hoge {
  def print(): Unit = { println("hoge") }
}

type Fuga = Hoge
Fuga.print()
// 2015-08-25-091747.scala:5: error: not found: type Hoge
// type Fuga = Hoge
//             ^
// 2015-08-25-091747.scala:6: error: not found: value Fuga
// Fuga.print()
// ^
// two errors found

そこでvalを使ってobjectをエイリアスする。

object Hoge {
  def print(): Unit = { println("hoge") }
}

val Fuga = Hoge
Fuga.print()

なぜこれだとうまくいくのか、詳細は分かっていないが、Scalaスケーラブルプログラミングによると

一般に、Scalaは定義のための名前空間を2個しか持っていない (Javaは4個ある)。

  • 値 (フ ィ ー ル ド、メ ソ ッ ド、パッケージ、シングルトンオブジェクト)
  • 型 (クラス、トレイト)

と書いてあるので、型の名前空間のaliasはtypeで、値の名前空間エイリアスはvalで出来るのではないかと思った。ただ詳しいことは分かってないので違ってるかもしれない。

case classは暗黙的にコンパニオンオブジェクトを作成する

case classは暗黙的にコンパニオンオブジェクトを作成し、そこにapplyやunapplyのメソッドを自動生成する。つまり勝手にclassとobjectを定義するということになる。

以上の二点からcase classはtypeだけではエイリアス出来ず、valも合わせてエイリアスする必要があることが分かる。

まとめ

今回はscalaでのcase classのエイリアスの方法について書いた。valでobjectがエイリアス出来る仕組みについては詳しく分かってないので、もし分かる人がいたら教えて下さい。