Amazon SESの送信イベントログをCloudWatch Logsに保存する設定をCDKv2でサクッとつくれるようにする

2023.08.23

ゲームソリューション部の新屋です。
SES検証中にメール送信のイベントログを取りたくなったので、もろもろの設定をCDKv2(TypeScript)で書いてみました。

実現すること

  • 送信元ドメインをSESに登録し検証済みにする
  • 配信・開封・ハードバウンス・苦情のメール送信イベントをトラッキングする
  • メール送信イベントのログはSNS→Lambda→CloudWatchLogsに保存する
  • ハードバウンス・苦情が発生したらアラームを発砲し、Slackに通知する

構成図

出来上がったコード

./bin/main.ts

import {
  App,
  Stack,
  StackProps,
  Duration,
  RemovalPolicy,
  aws_logs as logs,
  aws_cloudwatch as cloudwatch,
  aws_cloudwatch_actions as cloudwatch_action,
  aws_chatbot as chatbot,
  aws_sns as sns,
  aws_sns_subscriptions as subscription,
  aws_ses as ses,
  aws_lambda as lambda,
  aws_iam as iam,
} from 'aws-cdk-lib'
import * as dotenv from 'dotenv'

dotenv.config()
const accountId = process.env.AWS_ACCOUNT_ID ? process.env.AWS_ACCOUNT_ID : ''
const domain = process.env.FROM_DOMAIN ? process.env.FROM_DOMAIN : ''
const workspace = process.env.SLACK_WORKSPACE ? process.env.SLACK_WORKSPACE : ''
const channel = process.env.SLACK_CHANNEL ? process.env.SLACK_CHANNEL : ''

interface SESMonitorProps extends StackProps {
  domain: string
  accountId: string
  workspace: string
  channel: string
}

export class SESMonitorStack extends Stack {
  constructor(scope: App, id: string, props: SESMonitorProps) {
    super(scope, id, props)

    /**********************
     * Lambda
     **********************/
    // CloudWatchLogsへログをPutするLambdaFunction
    const emailDeliveryLambda = new lambda.Function(this, 'EmailDeliveryLambda', {
      functionName: 'EmailDeliveryLambda',
      runtime: lambda.Runtime.NODEJS_18_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'index.handler',
    })
    emailDeliveryLambda.addToRolePolicy(
      new iam.PolicyStatement({
        resources: ['*'],
        actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
      }),
    )

    /**********************
     * SNS
     **********************/
    // メール配信イベントの送信先SNS
    const emailDeliveryTopic = new sns.Topic(this, 'EmailDeliveryTopic', {
      topicName: 'EmailDeliveryTopic',
    })
    emailDeliveryTopic.addSubscription(new subscription.LambdaSubscription(emailDeliveryLambda))

    // メトリクスアラームの送信先SNS
    const metricsAlarmTopic = new sns.Topic(this, 'MetricsAlarmTopic', {
      topicName: 'MetricsAlarmTopic',
    })
    // CloudWatchからSNSへPublishする許可
    metricsAlarmTopic.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'CloudWatchSNSPublishingPermissions',
        actions: ['SNS:Publish'],
        resources: [metricsAlarmTopic.topicArn],
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('cloudwatch.amazonaws.com')],
      }),
    )
    // ResourceOwnerがPublishする許可
    metricsAlarmTopic.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'OwnerPublishPermission',
        actions: [
          'SNS:GetTopicAttributes',
          'SNS:SetTopicAttributes',
          'SNS:AddPermission',
          'SNS:RemovePermission',
          'SNS:DeleteTopic',
          'SNS:Subscribe',
          'SNS:ListSubscriptionsByTopic',
          'SNS:Publish',
          'SNS:Receive',
        ],
        resources: [metricsAlarmTopic.topicArn],
        effect: iam.Effect.ALLOW,
        principals: [new iam.AnyPrincipal()],
        conditions: {
          StringEquals: {
            'AWS:SourceOwner': props.accountId,
          },
        },
      }),
    )

    /**********************
     * Chatbot
     **********************/
    // Chatbotのロール
    const chatbotRole = new iam.Role(this, 'ChatbotRole', {
      roleName: 'ChatbotRole',
      assumedBy: new iam.ServicePrincipal('chatbot.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('CloudWatchLogsReadOnlyAccess')],
    })
    chatbotRole.addToPolicy(
      new iam.PolicyStatement({
        resources: ['*'],
        actions: ['cloudwatch:Describe*', 'cloudwatch:Get*', 'cloudwatch:List*'],
      }),
    )

    // メトリクスアラームの送信先Chatbot
    new chatbot.SlackChannelConfiguration(this, 'EmailDeliverySlackChannelConfig', {
      slackChannelConfigurationName: 'EmailDeliverySlackChannelConfig',
      slackWorkspaceId: props.workspace,
      slackChannelId: props.channel,
      notificationTopics: [metricsAlarmTopic],
      guardrailPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('CloudWatchLogsReadOnlyAccess'),
      ],
      role: chatbotRole,
    })

    /**********************
     * SES
     **********************/
    // メール配信イベント設定
    const emailDeliveryConfigurationSet = new ses.ConfigurationSet(
      this,
      'EmailDeliveryConfigurationSet',
      {
        configurationSetName: 'EmailDeliveryConfigurationSet',
      },
    )
    emailDeliveryConfigurationSet.addEventDestination('EmailDeliveryEventDestination', {
      configurationSetEventDestinationName: 'EmailDeliveryEventDestination',
      destination: ses.EventDestination.snsTopic(emailDeliveryTopic),
      enabled: true,
      events: [
        ses.EmailSendingEvent.DELIVERY, // 配信
        ses.EmailSendingEvent.OPEN, // 開封
        ses.EmailSendingEvent.BOUNCE, // バウンス
        ses.EmailSendingEvent.COMPLAINT, // 苦情
      ],
    })

    // SESのドメイン登録とメール配信イベント設定の紐づけ
    new ses.EmailIdentity(this, 'EmailDeliveryIdentity', {
      identity: ses.Identity.domain(props.domain),
      configurationSet: emailDeliveryConfigurationSet,
    })

    /**********************
     * CloudWatch Logs
     **********************/
    // ロググループ・ログストリーム作成
    const emailDeliveryLogGroup = new logs.LogGroup(this, 'EmailDeliveryLogGroup', {
      logGroupName: '/aws/lambda/emailDeliveryLogGroup', // LogGroup
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    new logs.LogStream(this, 'EmailDeliveryLogStream', {
      logGroup: emailDeliveryLogGroup,
      logStreamName: 'emailDeliveryLogStream', // LogStream
    })

    /**********************
     * CloudWatch Metrics Filter
     **********************/
    // バウンスのメトリクスフィルタ
    const bounceMetricFilter = new logs.MetricFilter(this, 'BounceMetricFilter', {
      logGroup: emailDeliveryLogGroup,
      metricNamespace: 'EmailDelivery',
      metricName: 'BounceCount',
      filterPattern: logs.FilterPattern.literal('Bounce'),
      metricValue: '1',
    })

    // 苦情のメトリクスフィルタ
    const complaintMetricFilter = new logs.MetricFilter(this, 'ComplaintMetricFilter', {
      logGroup: emailDeliveryLogGroup,
      metricNamespace: 'EmailDelivery',
      metricName: 'ComplaintCount',
      filterPattern: logs.FilterPattern.literal('Complaint'),
      metricValue: '1',
    })

    /**********************
     * CloudWatch Metrics Alarm
     **********************/
    // バウンスのアラーム
    const bounceAlarm = new cloudwatch.Alarm(this, 'BounceAlarm', {
      metric: bounceMetricFilter.metric({ period: Duration.minutes(5), statistic: 'Sum' }),
      evaluationPeriods: 1,
      threshold: 1,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      actionsEnabled: true,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
    })
    bounceAlarm.addAlarmAction(new cloudwatch_action.SnsAction(metricsAlarmTopic))

    // 苦情のアラーム
    const complaintAlarm = new cloudwatch.Alarm(this, 'ComplaintAlarm', {
      metric: complaintMetricFilter.metric({ period: Duration.minutes(5), statistic: 'Sum' }),
      evaluationPeriods: 1,
      threshold: 1,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      actionsEnabled: true,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
    })
    complaintAlarm.addAlarmAction(new cloudwatch_action.SnsAction(metricsAlarmTopic))
  }
}

const app = new App()

new SESMonitorStack(app, 'SESMonitorStack', {
  domain,
  accountId,
  workspace,
  channel,
})

app.synth()

ちょっと長いのでお好みで適当なスタックに分割してください。
また、Chatbotはワークスペースの初回連携時にSlack側で「許可」が必要です。詳しくは以下をご覧ください。


CloudWatchにログ送信するLambda関数は

./lambda/index.js

const { CloudWatchLogsClient, PutLogEventsCommand } = require('@aws-sdk/client-cloudwatch-logs')

const client = new CloudWatchLogsClient()

exports.handler = async (event) => {
  const input = {
    logGroupName: '/aws/lambda/emailDeliveryLogGroup',
    logStreamName: 'emailDeliveryLogStream',
    logEvents: [
      {
        timestamp: Date.now(),
        message: event['Records'][0]['Sns']['Message'],
      },
    ],
  }
  const command = new PutLogEventsCommand(input)
  await client.send(command)
}

Node18で動かすことを想定しているので AWS SDK for JavaScript v3 の書き方になっています。

確認

上記をCDKデプロイして success@simulator.amazonses.combounce@simulator.amazonses.comcomplaint@simulator.amazonses.com 宛に送信テストをします。

aws ses send-email \
  --from test@{your_verified_email_domain} \
  --to {your_test_email_address} \
  --subject "Test Email Subject" \
  --text "Test Email Text"

すると、以下のような結果が得られます。

CloudWatch Logs

CloudWatch Alarm

そして、Slackに通知が飛びます。

感想

SESの送信イベントのログ(Feedback)をSNSへ通知するコードはググるとそれなりの記事が出てくるのですが
CDKv1とCDKv2で若干書き方が変わってました。
私は、とりあえずググったコードをコピペして動くものを見ながら理解を進めるので、v2のコードが見当たらず苦労しました。
この記事がどなたかのAWS理解の一助になれば幸いです。

参考記事

Amazon Kinesis Firehose を使って正常ログとエラーログを区別して保存しています。 私のものは、すべてのイベントログをCloudWatchLogsに送ります。 CloudFormation のサンプルコードが載っています。

SESの評判メトリクスからハードバウンス・苦情の発生率が一定を超えると通知するという内容です。 私のものと違ってメトリクスを自作せず、SESにあるメトリクスからアラームを発砲しています。 こちらも CloudFormation のサンプルコードも載ってます。