Amazon SESの送信ログをS3バケットに保存する設定をCloudFormationでサクッと作る

2023.08.04

初めに

Amazon SESでメールを送信されている皆様、送信されたメールのログはとっていますか?

Amazon SESでは特に何も設定しなくてもログが取られる...ということはなく自分で意図してログを設定しないと送信したメールのログは取られません。

そのため特に何も意識せずとりあえず使ってみた、としているとバウンスレートが上がってきた場合や未着問い合わせ時に調査ができず手詰まりとなってしまう場合もあります。

ログの設定くらい数ページぽちぽちすればできるでしょう。と思いたいところですがいくつかのサービスを組み合わせる必要がありIAMロールの設定等地味に手間です。

シンプルなS3バケットへのログ保管設定も意外と当ブログになかったので、CloudFormationでサクッと作れるテンプレートを作ってみます。

構成図

今回はSESはドメイン単位で検証しているものとして執筆いたしますがメールアドレス単位で認証を行っても同じように利用は可能です。

SESのドメイン検証も行ってしまいたいところですが、残念ながら標準ではCloudFormationはドメイン認証未対応なので含めていません(メール認証は対応)。

一応カスタムリソースを使うことでドメイン認証もできるのですがサクッと行かなくなってしまうので利用はしません。
また、その設定については今回の記事では特に触れませんのでご了承ください。

想定している環境像

メール未着の確認やバウンスレート上昇時など困った時のトラブルシュート程度に後で確認できれば良く、メール送信量もあまり多くない(一日数千通程)場合を想定します。

サクッと最低限の設定値でとりあえず最低限のログが取りたいを前提にしていますし、また大規模で頻繁に検索のであればOpenSearch等別の格納先を利用した方が良いでしょう。

作成されるもの

  • 配信成功、バウンス、拒否、苦情を対象としてログを保管する変更セット
    • 上記のイベントを保管するためのS3とそこまで配達するFirehoseを作成し結びつける
  • Firehose自体のエラーを出力するCloudWatch Logsの設定
  • (オプション)バウンス、苦情時に個別に通知をメールで受け取るためのSNSのトピックとサブスクリプション

バウンス・苦情通知は高まるとSESの利用停止に繋がるので、普段からどの程度起こるのかリアルタイムで見られるように設定しておくのが好ましい場合もあるのでついでに作っておきます(S3から探さなくてもサクッと見れる意味でも)。

ただ発生頻度によってはものすごい量が通知されてうまくフィルタしないとメールボックスが埋められることもあるのでオプション扱いです。

作成しないもの

特に意識して欲しいものだけです。

  • ドメイン・アドレス自体の認証とそこへの設定の割り当て
    • 前述の通りCloudFormationがドメイン認証が現状対応していないため
  • メールの開封クリック等の直接未着に影響の出ないイベントの記録
    • 苦情については配信停止の原因になるので例外的に作成している
    • レンダリング失敗は送信以前の問題のため作成しない側に含む
  • ストレージクラスの変更をするライフサイクル
  • バウンス・苦情率に基づくCloudWatch Alarm
    • アカウント単位の設定になるため今回は含めない

テンプレート

本テンプレートはID(ドメイン)の作成や認証とは独立しているためスタック作成タイミングに縛りはありません。

DNSレコードの検証待ちにスタックを作成するのも良いのではないでしょうか。

AWSTemplateFormatVersion: 2010-09-09
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: 
          default: General
        Parameters:
          - NoColonDomain
          - LogExpirationInDays
      - Label:
          default: Amazon SNS
        Parameters:
          - BaounceFeedbackAddress
          - ComplaintFeedbackAddress
Parameters:
  NoColonDomain:
    Description: The domain name without the "." ex) example.com -> examplecom
    Type: String
  LogExpirationInDays:
    Description: Log expiration (day)
    Type: Number
  BaounceFeedbackAddress:
    Description: E-mail address for bounce feedback. empty if not needed
    Type: String
  ComplaintFeedbackAddress:
    Description: E-mail address for complaint feedback. empty if not needed
    Type: String


Conditions:
  ExistBounceFeedbackAddress: !Not [!Equals ["", !Ref BaounceFeedbackAddress]]
  ExistComplaintFeedbackAddress: !Not [!Equals ["", !Ref ComplaintFeedbackAddress]]
Resources:
  #----------------------
  #--- SES Configuration
  #----------------------
  ConfigurationSet:
    Type: AWS::SES::ConfigurationSet
    Properties:
      Name: !Sub ${NoColonDomain}-configuration
      DeliveryOptions:
        TlsPolicy: REQUIRE
  MailLogDeliverEvent:
    Type: AWS::SES::ConfigurationSetEventDestination
    Properties:
      ConfigurationSetName: !Ref ConfigurationSet
      EventDestination:
        Name: !Sub ${NoColonDomain}-ses-log-deliver
        Enabled: True
        MatchingEventTypes:
          - delivery
          - reject
          - bounce
          - complaint
        KinesisFirehoseDestination: 
          DeliveryStreamARN: !GetAtt [SESLogDeliver, Arn]
          IAMRoleARN: !GetAtt [SESMailDeliverRole, Arn]
  SESMailDeliverRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${NoColonDomain}-ses-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
            Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: ses.amazonaws.com
      Path: /
      Policies:
        - PolicyName: !Sub ${NoColonDomain}-ses-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              Effect: Allow
              Action:
                - firehose:PutRecordBatch
              Resource: !GetAtt [SESLogDeliver, Arn]
  #----------------------
  #--- S3
  #---------------------- 
  MailLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${NoColonDomain}-mail-log
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True    
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - BucketKeyEnabled: True
            ServerSideEncryptionByDefault: 
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: !Sub Delete-After-${LogExpirationInDays}Days	
            ExpirationInDays: !Ref LogExpirationInDays
            Status: Enabled
  #----------------------
  #--- Firehose
  #----------------------
  SESLogDeliver:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties: 
      DeliveryStreamName: !Sub ${NoColonDomain}-ses-log-deliver
      DeliveryStreamType: DirectPut
      ExtendedS3DestinationConfiguration: 
        BucketARN: !GetAtt [MailLogBucket, Arn]
        BufferingHints: 
          IntervalInSeconds: 900
          SizeInMBs: 128
        CloudWatchLoggingOptions:
          Enabled: True
          #NOTE: LogGroup側からRefで撮りたいが${SESLogDeliver}がLogGroup側で欲しく循環参照がかかるのでベタ書きする
          LogGroupName: !Sub /aws/kinesisfirehose/${NoColonDomain}-ses-log-deliver
          LogStreamName: S3Delivery
        CompressionFormat: GZIP
        ErrorOutputPrefix: "!{firehose:error-output-type}/"            
        RoleARN: !GetAtt [FirehoseRole, Arn]
  FirehoseCWLogGroup:
    Type: AWS::Logs::LogGroup
    Properties: 
      LogGroupName: !Sub /aws/kinesisfirehose/${SESLogDeliver}
      RetentionInDays: !Ref LogExpirationInDays
  FirehoseCWLogStream:
    Type: AWS::Logs::LogStream
    Properties: 
      LogGroupName: !Ref FirehoseCWLogGroup
      LogStreamName: S3Delivery
  FirehoseRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${NoColonDomain}-firehose-role
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: firehose.amazonaws.com
      Policies:
        - PolicyName: !Sub ${NoColonDomain}-firehose-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 's3:PutObject'
                Resource: !Sub ${MailLogBucket.Arn}/*

  #----------------------
  #--- SNS
  #----------------------
  BounceFeedbackTopic:
    Condition: ExistBounceFeedbackAddress
    Type: AWS::SNS::Topic
    Properties: 
      TopicName: !Sub ${NoColonDomain}-bounce-feedback-topic
      DisplayName: !Sub ${NoColonDomain}-bounce-feedback-topic
  BounceFeedbackSubscription:
    Condition: ExistBounceFeedbackAddress
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref BounceFeedbackTopic
      Protocol: email
      Endpoint: !Ref BaounceFeedbackAddress
  ComplaintFeedbackTopic:
    Condition: ExistComplaintFeedbackAddress
    Type: AWS::SNS::Topic
    Properties: 
      TopicName: !Sub ${NoColonDomain}-complaint-feedback-topic
      DisplayName: !Sub ${NoColonDomain}-complaint-feedback-topic
  ComplaintFeedbackSubscription:
    Condition: ExistComplaintFeedbackAddress
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref ComplaintFeedbackTopic
      Protocol: email
      Endpoint: !Ref ComplaintFeedbackAddress

SNSによる通知先アドレスを設定している場合対象アドレスに確認のメールが届くのでリンク内のアドレスをクリックして検証を済ませておいてください。

入力値

  • NoColonDomain
    • SESで利用するホスト名からコロンを抜いたもの
      • テンプレート内の視認性確保のために抜きで入力する方式を採用
    • プレフィックスに使ってるだけなので区別できる英数字なら基本大丈夫です
  • LogExpirationInDays
    • ログの保存期間(日単位)
  • BaounceFeedbackAddress(必要な場合のみ)
    • バウンス発生時の通知先アドレス
  • ComplaintFeedbackAddress(必要な場合のみ)
    • 苦情発生時の通知先アドレス

作成された設定の割り当て

作成やドメインの認証についてはここでは割愛しますので別途済ませておいてください。

マネジメントコンソール内の左メニュー「検証済み ID」を開き割り当てたいドメインを選択し詳細より以下の設定を行います。

変更セットの割り当て

「変更セット」のタブから「編集」を開きます。

「デフォルト設定セットの割り当て」にチェックを入れ、作成された変更セット({{NoColonDomain}}-configuration)を割り当て保存します。

メール通知の割り当て

「通知」のタブから「編集」を開きます。

現時点の仕様ですとデフォルトで「フィードバック転送」は有効なので設定は不要ですが、もし無効になっている場合は有効化してください。

バウンス・苦情フィードバックに作成されたトピック({{NoColonDomain}}-{{bounce or cmplaint}}-feedback-topic}})を割り当て保存します。

通知時点で詳細な情報が欲しい場合は「元のEメールヘッダを含める」にチェックを入れておきましょう。

終わりに

以上で設定は終わりです。

入力を減らすために決め打ちの部分も多いものですが、個人的には小規模なシステムで偶のメール未着等の問い合わせやバウンス・苦情が上がり止められそうになった場合に調査、くらいの感じであればこれでも十分かなとは思っています。

ログ出したいけどどのようにすればいいかいまいちイメージつかなって人が一度実物見てイメージを掴むために使ってもいいでしょう。

できる限りログファイルがまとまるようにFirehoseのバッファを最大限まで伸ばしていますが、とは言え15分or128MBで1ファイルとなるのでどうしても塊が小さく横断検索はやや手間なのでアプリ等送信元から時間をある程度推定するかAthenaを使う形にはなります。

調査の意味ではSESやFirehoseの指定先としてCloudWatch Logsがあれば良いのですが現時点では直接の利用ができなさそうなのが残念です。

Amazon SES単体で送信履歴が取れるようになりました(2023/09/08追記)

2023/08/31のアップデートにてAmazon SESのVirtual Delivery Managerに機能追加が追加され複数のサービスを組み合わせることなくAmazon SESで送信履歴が取れるようになりました。

こちらは最長30日までであることと今回の方法ほど詳細な情報が取れるものではありませんが、日常的な調査の場合はこちらの方が便利な可能性がありますのでご一読いただければ幸いです。