1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SESでのメール送信履歴をCloudWatch Logs Insightsでゆるく眺める!

Posted at

完成イメージ

CloudWatch Logs Insightsの見え方

22.PNG

ゆるく眺めるということで、timestamp、eventtype(送信した、バウンスされた、など)、送信先メールアドレスをとりあえず表示してみました。
CloudWatch Logs Insightsのクエリで、timestamp範囲での検索、eventtypeでの集計、mailアドレスでの検索が可能なので、まずはこのくらいのカラムで良い気がしてます。

アーキ

キャプチャ.PNG

SESから各イベントをSNSへpublishします。
SNSはログ記載用のlamndaをsubscribeし、受け取ったeventをlambdaへ渡します。
lambdaはSESイベント履歴ロギング用のロググループへイベント内容をputします。

やってみた

SES IDを作成する

SESのメール送信元となるSES IDを作成します。

SES > 設定 > ID に移動する

「IDの作成」をクリックします。

01.PNG

送信元メールアドレスを指定する

今回は簡単のため、メアドでIDを作成します。
「Eメールアドレス」ラジオを選択します。

02.PNG

メールアドレスを設定し、IDを作成する

検証用に用意したメールアドレスを入力し、「IDの作成」をクリックします。
記載の通り、受信したメールを確認できるメールアドレスである必要があります。

04.PNG

IDの作成を確認する

IDが作成されました!!
IDステータスが「検証保留中」であることが確認できます。
入力したメールアドレス宛に検証リンクを含むメールが飛んでいるはずなので、確認しにいきます。

05.PNG

メールアドレスを確認し、IDを有効化する

IDが作成されたので、メールアドレスを検証し、IDを有効化しましょう!

受信メールを確認する

ID作成時に使用したメアドの受信ボックスを確認します。
AWSからメアド検証用リンクを含むメールが飛んでいるはずです。

07.PNG

リンクをクリックし、メアド検証を完了させる

メールに記載のリンクをクリックします。
良い感じの文言が出ればオッケーです。

08.PNG

SES IDのステータスを確認する

再度SES IDの概要画面を表示します。
先ほど「検証保留中」だったIDステータスが「検証済み」に変更されていることが確認できます。

09.PNG

SES→SNSの設定に行きたいのですが、SNSトピックが存在しないといけないため、次は後続リソースを作成していきます!

SNS、lambda、Cloudwatchリソースを作成する

terraformでサクッと作成する

今回はSESの設定が主目的のため、SNS→lambda→cloudwatch logsの部分はterraformでサクッと実装します。

名称未設定ファイル.drawio (6).png

ディレクトリ構造はこんな感じ。

ses-logging/
├── main.tf                      // 各リソースを定義するtf
├── lambda-ses-function/         // ses実行用lambda
|     └── index.mjs
└── lambda-logging-function/     // SNSから実行されるログ用lambda
      └── index.mjs

main.tfは長いので省略

SNS→lambda→cloudwatch logs作成Terraform
main.tf
############################################################################
## terraformブロック
############################################################################
terraform {
  # Terraformのバージョン指定
  required_version = "~> 1.7.0"

  # Terraformのaws用ライブラリのバージョン指定
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.33.0"
    }
  }
}

############################################################################
## providerブロック
############################################################################
provider "aws" {
  # リージョンを指定
  region = "ap-northeast-1"
}

locals {
  project = "send_email_by_lambda"
}

###########################################################################
## SES実行用Lambda IAMロール
############################################################################
data "aws_iam_policy_document" "assume_lambda" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "lambda_ses" {
  name               = "${local.project}-lambda-ses-role"
  assume_role_policy = data.aws_iam_policy_document.assume_lambda.json
}

data "aws_iam_policy_document" "lambda_ses" {
  statement {
    effect = "Allow"
    actions = [
      "ses:SendEmail",
      "ses:SendRawEmail"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "lambda_ses" {
  name   = "${local.project}-lambda-ses-policy"
  policy = data.aws_iam_policy_document.lambda_ses.json
}

resource "aws_iam_role_policy_attachment" "lambda_ses" {
  role       = aws_iam_role.lambda_ses.name
  policy_arn = aws_iam_policy.lambda_ses.arn
}

############################################################################
## SES実行用Lambda 関数
#############################################################################
# zip
data "archive_file" "lambda_ses" {
  type             = "zip"
  output_file_mode = "0666"
  source_dir  = "${path.module}/lambda-ses-function"
  output_path = "${path.module}/lambda-ses-function.zip"
}

# 関数
resource "aws_lambda_function" "lambda_ses" {
  function_name = "${local.project}_lambda"
  role          = aws_iam_role.lambda_ses.arn

  runtime        = "nodejs20.x"
  filename = data.archive_file.lambda_ses.output_path
  handler  = "index.handler"
  architectures  = ["arm64"]

  source_code_hash = filebase64sha256(data.archive_file.lambda_ses.output_path)

  # zip内の変更は無視する
  lifecycle {
    ignore_changes = [filename, source_code_hash]
  }
}

############################################################################
## SES証跡用Cloudwatch logs
#############################################################################
resource "aws_cloudwatch_log_group" "lambda_ses_logging" {
  name = "/aws/ses/${local.project}-logging"
}

###########################################################################
## SES証跡Lambda IAMロール
############################################################################
resource "aws_iam_role" "lambda_ses_logging" {
  name               = "${local.project}-lambda-ses-logging-role"
  assume_role_policy = data.aws_iam_policy_document.assume_lambda.json
}

data "aws_iam_policy_document" "lambda_ses_logging" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogStream",
      "logs:DescribeLogStreams",
      "logs:PutLogEvents",
    ]

    resources = ["${aws_cloudwatch_log_group.lambda_ses_logging.arn}:*"]
  }
}

resource "aws_iam_policy" "lambda_ses_logging" {
  name   = "${local.project}-lambda-ses-logging-policy"
  policy = data.aws_iam_policy_document.lambda_ses_logging.json
}

resource "aws_iam_role_policy_attachment" "lambda_ses_logging" {
  role       = aws_iam_role.lambda_ses_logging.name
  policy_arn = aws_iam_policy.lambda_ses_logging.arn
}

############################################################################
## SES証跡用Lambda 関数
#############################################################################
# zip
data "archive_file" "lambda_ses_logging" {
  type             = "zip"
  output_file_mode = "0666"
  source_dir  = "${path.module}/lambda-logging-function"
  output_path = "${path.module}/lambda-logging-function.zip"
}

# 関数
resource "aws_lambda_function" "lambda_ses_logging" {
  function_name = "${local.project}_lambda-logging"
  role          = aws_iam_role.lambda_ses_logging.arn

  runtime        = "nodejs20.x"
  filename = data.archive_file.lambda_ses_logging.output_path
  handler  = "index.handler"
  architectures  = ["arm64"]

  source_code_hash = filebase64sha256(data.archive_file.lambda_ses_logging.output_path)

  # zip内の変更は無視する
  lifecycle {
    ignore_changes = [filename, source_code_hash]
  }
}

############################################################################
## SES証跡用SNS Topic
#############################################################################
resource "aws_sns_topic" "lambda_ses_logging" {
  name = "${local.project}-ses-send-topic"
}

resource "aws_sns_topic_subscription" "lambda_ses_logging" {
  topic_arn = aws_sns_topic.lambda_ses_logging.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.lambda_ses_logging.arn
}

resource "aws_lambda_permission" "lambda_ses_logging" {
  function_name = aws_lambda_function.lambda_ses_logging.function_name
  statement_id  = "AllowExecutionFromSNS"
  principal     = "sns.amazonaws.com"
  action        = "lambda:InvokeFunction"
  source_arn    = aws_sns_topic.lambda_ses_logging.arn
}

SES実行用のlambda
特に変なことはしてません。lambda経由でSESからメールを送信します。

lambda-ses-function/index.mjs
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";

const ses = new SESClient({ region: "ap-northeast-1" }); // 適宜リージョンを変更

export const handler = async (event) => {
    const params = {
        Destination: {
            ToAddresses: ["makoto@gmail.com"], // 送信先のメールアドレス
        },
        Message: {
            Body: {
                Text: { Data: "Hello from AWS Lambda using SES!" }, // メール本文
            },
            Subject: { Data: "Test Email from Lambda" }, // メール件名
        },
        Source: "makoto@gmail.com", // 検証済みの送信元メールアドレス
    };

    try {
        const result = await ses.send(new SendEmailCommand(params));
        console.log("Email sent successfully:", result);
        return { statusCode: 200, body: "Email sent successfully" };
    } catch (error) {
        console.error("Error sending email:", error);
        return { statusCode: 500, body: "Failed to send email" };
    }
};

SES実行用履歴ロギング用のlambda
SNSをsubscribeし、SNS経由でSESのeventを特定のロググループに保存します。

lambda-logging-function/index.mjs
// 日次ログストリーム付きSNSイベントロガー
import { CloudWatchLogsClient, PutLogEventsCommand, CreateLogStreamCommand } from "@aws-sdk/client-cloudwatch-logs";

// CloudWatch Logsクライアントの初期化
const logsClient = new CloudWatchLogsClient();

// 設定
const LOG_GROUP_NAME = '/aws/ses/send_email_by_lambda-logging';

export const handler = async (event, context) => {
  try {
    console.log('受信したイベント:', JSON.stringify(event, null, 2));
    
    // 今日の日付でログストリーム名を生成
    const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD形式
    const LOG_STREAM_NAME = `sns-events-${today}`;
    
    // イベントをログに記録
    await logEvent(event, LOG_STREAM_NAME);
    
    return { statusCode: 200, message: 'イベントを記録しました' };
  } catch (error) {
    console.error('エラー:', error);
    throw error;
  }
};

/**
 * イベントをCloudWatch Logsに記録
 */
async function logEvent(event, streamName) {
  try {
    // まずログストリームを作成(既に存在する場合はエラーをキャッチして無視)
    const createStreamCommand = new CreateLogStreamCommand({
      logGroupName: LOG_GROUP_NAME,
      logStreamName: streamName
    });
    
    try {
      await logsClient.send(createStreamCommand);
    } catch (error) {
      // ResourceAlreadyExistsException は無視(ストリームが既に存在する場合)
      if (error.name !== 'ResourceAlreadyExistsException') {
        throw error;
      }
    }
    
    // ログイベントの作成
    const logEvent = {
      timestamp: Date.now(),
      message: JSON.stringify(event, null, 2)
      // TODO: SESと疎通後は以下のコードに変更する
      // message: event.Records[0].Sns.Message
    };
    
    // ログをCloudWatchに送信
    const putCommand = new PutLogEventsCommand({
      logGroupName: LOG_GROUP_NAME,
      logStreamName: streamName,
      logEvents: [logEvent]
    });
    
    await logsClient.send(putCommand);
  } catch (error) {
    console.error('ログ記録中のエラー:', error);
    throw error;
  }
}

以下の部分だけ注意です

index.mjs
    // ログイベントの作成
    const logEvent = {
      timestamp: Date.now(),
      message: JSON.stringify(event, null, 2)
      // TODO: SESと疎通後は以下のコードに変更する
      // message: event.Records[0].Sns.Message
    };

message: event.Records[0].Sns.Message
SESからpublishされたeventをそのままログ保存するより、一定スコープをしぼって保存する方がinsightでの可視性があがります。

message: JSON.stringify(event, null, 2)
上述した通り、eventをそのまま投げ込むのは良くないのですが、
この後SNS→lambda→cloudwatch logsの疎通テストをするため、まずはeventをそのまま投げ込む形でデプロイします(SNSで適当なメッセージでテストした場合、event.Records[0].Sns.Messageが取得できず落ちる可能性があるため)。

コメントにもある通り、SNS→lambda→cloudwatch logsの疎通確認後、コードを編集します。

SNS→lambda→cloudwatch logsの疎通を確認する

terraformでリソースがdeployできたら、疎通確認をします。

SNS > トピック > メッセージの発行をクリックする

作成したSNS トピックから、「メッセージの発行」をクリックします。

23.PNG

件名・メッセージ本文を用意し、メッセージを発行する

件名・メッセージ本文を用意し、「メッセージの発行」をクリックします。

11.PNG

12.PNG

実行結果を確認する

cloudwatch logsからロググループにアクセスし、ログを確認します。
SNSからtest用のメッセージを受け取り、ロググループへputしていることが確認できました!

13.PNG

これでついにSESとSNSをつなぐことができます!!!!

    // ログイベントの作成
    const logEvent = {
      timestamp: Date.now(),
-     message: JSON.stringify(event, null, 2)
-     // TODO: SESと疎通後は以下のコードに変更する
-     // message: event.Records[0].Sns.Message
+     message: event.Records[0].Sns.Message
    };

ログ保存用lambdaも直しておきます。

SES → SNS連携のため、SES 設定セットを作成する

SESの送信イベントを他サービスへ連携する設定は、SESの「設定セット」というリソースで管理されています。
「設定セット」はSESでのメール送信単位、またはID単位で共通の振る舞いを行わせるために、設定群を管理できるリソースです。
RDSのパラメータグループみたいなものだとふんわり認識しています。

SES 設定セット作成画面へ遷移する

SES > 設定 > 設定セット から、「セットの作成」をクリックします。

24.PNG

送信するイベントタイプを選択する

「すべて選択」をクリックします!レンダリングの失敗などは基本不要ですが、0-100で設定しておいたほうが他者に意図が伝わりやすいと思っています。

14.PNG

「次へ」をクリックします。

15.PNG

送信先を選択する

SESイベントの送信先を選択します。
Cloudwatchのメトリクスや、Firehoseなどが選べます。
SNSを選択し、作成したトピックを指定します。

16.PNG

設定セットの作成を完了する

設定に問題がないことを確認し、設定セットを作成します!!

17.PNG

設定セットの作成を確認する

SES > 設定 > 設定セットから確認します。
作成できてそうです。

25.PNG

作成したSES IDのデフォルト設定セットに、作成したSES 設定セットを指定する

設定セットは、SendEmail API実行時に指定するほか、IDごとにデフォルトのセットを持つことができます。

今回は先ほど作成した設定セットを、IDのデフォルトの設定セットに指定してみます!!

SES > 設定 > ID > 設定セットタブへ移動する

デフォルト設定セットがブランクであることが確認できます。
「編集」をクリックし、設定を更新しましょう。

19.PNG

作成した設定セットを、IDのデフォルトの設定セットに指定する

そのまんまですね

20.PNG

良い感じ!

21.PNG

SESでメールを送信してみる

ではSESを利用してメールを送信し、SNS経由でイベント情報をcloudwatch logに保存してみます。

SESでメールを送信する

作成したlambdaをinvokeし、SES経由でメールを送信してみます。
問題なく動いてそうです。Sonnet3.7最強!

26.PNG

メールも届きました。

29.PNG

logsを確認する

無事logsに実行ログが保存されています。
Send(送信したよ)とDelivery(正常に配信完了したよ)の2つのイベントがログ出力されています。
良い感じですね。

27.PNG

CloudWatch Logs Insightsでゆるく眺める

ログの海をそのまま目で追うのはあまりにも過酷です。
タイトルにもある、CloudWatch Logs Insightsをつかってみます。

CloudWatch Logs Insights画面に移動する

ロググループ画面から、「Logs Insights で表示」をクリックします。

28.PNG

CloudWatch Logs Insights表示画面が表示されます!
33.PNG

クエリを変更する

以下クエリに変更し、実行します。

fields @timestamp, eventType, mail.destination.0
| sort @timestamp desc
| limit 2

31.PNG

ゆるく眺める

きたーーーーー
良い感じに整形されてSESイベント履歴が表示されました!

32.PNG

より詳しく

CloudWatch Logs Insightsの記法については、以下の記事がめっちゃわかりやすいです!
クエリを色々カスタマイズしたいと思うので、ぜひ参考にしてください!!

おわりに

SESの送信履歴を管理・確認したいと思い、アーキを考えてみました。
dynamodbなどに送信イベントを保存する案なども考えたのですが、まずはmvp的にCloudWatch Logs Insightsを使ってどうにかできないか?と実験してみました。
SNSを経由していることもあり、拡張性もあるため、最初はゆるく運用していけばいいんじゃないかなーと感じました!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?