【AWS】SESバウンスメール監視・通知について

最近、AWSから「Amazon SES Bounce Review Period for AWS Account」の通知が届いています。しかし、SESを使用する送信元メールアドレスがメーリングリストであるため、未達のメールがあっても通知が届かない仕様となっています。その結果、バウンスメールの閾値を超える警告が届いている状態で、具体的な未達のメールアドレスを判断しにくい状況になっています。

SESから送信されるメールのメトリクスには、Bounce RateとComplaint Rateの2つがあります。前者はアドレスが実在しないか、メールのドメインが実在しないか、受信者のサーバーがメールを拒否したなどが原因によるもので、Bounce Rateが閾値に近づくと、AWSから通知があり、対応が求められます。しかし、これを放置すると閾値を超えてしまい、送信が制限されてしまいます。特にインフラ監視メールなどは送信できない状態になると運用に大きな影響が出るため、これを防ぐ環境を整えるのは急務となります。
###### ■設計
・未達メールの詳細を調査できるように、メール情報をDynamoDBに保存します。ただし、大量のデータが溜まると、DynamoDBの保存料金が高くなる可能性があるため、TTLの設定が必要です。
・SESメトリクスを監視し、閾値を超えるとAWSから通知が届きます。運用者はこれを受けてDynamoDBに蓄積されたデータを確認し、未達のメールアドレスに対して適切な措置を講じます。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600067/3c9aca70-dc98-cfda-aab4-8d10d4f5f5a8.png)

###### ■実装
###### SNS
```ruby:qiita.rb
boundTopic
  `Lambda関数「bounceRecord」をサブスクリプションする`
CloudWatch_Alarms_Topic_SES
  `Lambda関数「ses-ms」をサブスクリプションする`
```
###### DynamoDB
```ruby:qiita.rb
SESNotifications
  `パーティションキーは、SESMessageId (String)`
  `Time to Live (TTL)は、ExpirationTime`
```
###### Lambda（bounceRecord）
###### 実行ロール
```ruby:qiita.rb
AmazonDynamoDBFullAccess
AWSLambdaBasicExecutionRole
```
```ruby:qiita.rb
# index.js
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
import { ddbClient } from "./ddbClient.js";
export const handler = async function (event) {
  console.log("event:", JSON.stringify(event, null, 2));
  let SnsPublishTime = event.Records[0].Sns.Timestamp;
  let SESMessage = event.Records[0].Sns.Message;
  SESMessage = JSON.parse(SESMessage);
  let SESMessageType = SESMessage.notificationType;
  let SESMessageId = SESMessage.mail.messageId;
  let SESSourceAddress = SESMessage.mail.source.toString();
  let SESDestinationAddress = SESMessage.mail.destination.toString();
  let SESSubject = SESMessage.mail.commonHeaders.subject;

  console.log(Math.floor(Date.now() / 1000))
  let ExpirationTime = Math.floor(Date.now() / 1000) + 3600
  let params = {};
  console.log("Type is:", SESMessageType);
  console.log("Subject is :", SESSubject);
  if (SESMessageType == "Bounce") {
  let SESbounceSummary = JSON.stringify(SESMessage.bounce.bouncedRecipients);
  params = {
    TableName: "SESNotifications",
    Item: {
      SESMessageId: { S: SESMessageId },
      SnsPublishTime: { S: SnsPublishTime },
      SESSourceAddress: { S: SESSourceAddress },
      SESDestinationAddress: { S: SESDestinationAddress },
      SESbounceSummary: { S: SESbounceSummary },
      SESMessageType: { S: SESMessageType },
      SESSubject: { S: SESSubject },
      ExpirationTime: { N: ExpirationTime.toString() },
    },
  };
  } else if SESMessageType == "Delivery") {
  } else if (SESMessageType == "Complaint") {
  }
  try {
    const data = await ddbClient.send(new PutItemCommand(params));
    console.log("Successfully create item into inventory SESNotifications.", data);
  } catch (error) {
    console.log("Error", error);
  }
}
```
```ruby:qiita.rb
# ddbClient.js
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
  const REGION = "ap-northeast-1"
  const ddbClient = new DynamoDBClient({ region: REGION })
  export { ddbClient }
```
###### Lambda（ses-ms）
###### 実行ロール
```ruby:qiita.rb
AmazonSESFullAccess
AWSLambdaBasicExecutionRole
```
```ruby:qiita.rb
# index.js
  import { msFunc } from "./mailSend.js";
  import dayjs from 'dayjs';

  export const handler = async function (event) {
  console.log("event:", JSON.stringify(event, null, 2));
  let mail_Subject = process.env.NOTICEMAIL_SUBJECT
  let mail_from = process.env.NOTICEMAIL_FROM
  let mail_to = process.env.NOTICEMAIL_TO
  let bounce_body = "";

  let AlertMessage = JSON.parse(event.Records[0].Sns.Message);
  let SnsPublishTime = event.Records[0].Sns.Timestamp;
  const time = dayjs(SnsPublishTime).format('YYYY-MM-DD HH: mm :ss').toString();
  console.log(time);
  let Threshold = AlertMessage.Trigger.Threshold.toString();

  bounce_body = bounce_body + "いつもお世話になっております。<br>";
  bounce_body = bounce_body + time + "にて、下記エラーは発生しております。<br>";
  bounce_body = bounce_body + "SESバウンス閾値: " + Threshold + "を超えておりますので、至急対応してください。";

  try {
    const mailSendRes = await msFunc(mail_to, mail_from, mail_Subject, bounce_body);
  } catch (e) {
    console.error(e);
  }};
```
```ruby:qiita.rb
# mailSend.js
import { SendEmailCommand } from "@aws-sdk/client-ses";
import { sesClient } from "./sesClient.js";

export const msFunc = async function (toAddress, fromAddress, mailSubject, mailbody) {
const createSendEmailCommand = (toAddress, fromAddress, mailSubject, mailbody) => {
  return new SendEmailCommand({
    Destination: {
      ToAddresses: [
      toAddress,
      ],
    },
    Message: {
      Subject: {
      Data: mailSubject,
      Charset: "UTF-8",
      },
      Body: {
        Text: {
        Data: mailbody,
        Charset: "UTF-8",
        },
        Html: {
        Data: mailbody,
        Charset: "UTF-8",
      },
    },
  },
  Source: fromAddress,
  ReplyToAddresses: [],
  });
};
try {
  const response = await sesClient.send(createSendEmailCommand(toAddress, fromAddress,mailSubject, mailbody));
  console.log(response);
} catch (err) {
  console.log(err)
}};
```
```ruby:qiita.rb
# sesClient.js
  import { SESClient } from "@aws-sdk/client-ses"
  const REGION = "ap-northeast-1"
  const sesClient = new SESClient({ region: REGION })
  export { sesClient }
```
###### ■CloudWatchAlarm(CloudWatch_Alerm_SES_BounceRate)
 ![s.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600067/722b905e-759f-86d4-b99b-a03bf0cb4eb3.png)

###### ■SES側の設定
Amazon SES　→　検証済み ID　→　×××@××.co.jp　→　BounceのSNSトピック設定
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600067/e57a0ad5-3601-2452-9db7-b2cf17f1937a.png)

###### ■検証
Alarm発生しましたら、メール通知
同時にDynamoDBにバウンスメールのレコードは登録されていることを確認しました。
![キャプ3チャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600067/4ba670aa-4641-507d-1270-d1f3b26a6002.png)

###### ■まとめ
メール送信が禁止されないように検知時、SESバウンスメールへの適切な対策を行うことは、システム運用上で不可欠です。SESMessageTypeが"Delivery"または"Complaint"の場合の対応については、今回はサンプルソースから割愛させていただきました。