0
1

SES+SNS+Lambdaを使ってバウンスメール通知機能を実装しよう!

Posted at

概要

SESを使ってメールを送信した際に存在しないメールアドレスの場合に検知できる機能をSES+SNS+Lambdaを使って実装する方法について解説します

前提

  • Pythonを使ってLambdaのコードを作成します

実装

今回は

  • S3
  • Lambda
  • SNS

の3種類のリソースをCloudFormationを使って構築します

S3の構築

Lambda用のzipファイルを格納するS3バケットを作成します

AWSTemplateFormatVersion: 2010-09-09
Description: "S3 Bucket Factory Settings Stack"

# -------------------------------------
# Metadata
# -------------------------------------
Metadata:
  AWS::CloudFormation::Interface:
    # パラメータの並び順
    ParameterGroups:
      - Label:
          default: "Project Configuration"
        Parameters:
          - ProjectName
          - Environment

# -------------------------------------
# Parameters
# -------------------------------------
Parameters:
  ProjectName:
    Description: "Enter the project name. (ex: shun198)"
    Type: String
    MinLength: 1
    ConstraintDescription: "ProjectName must be entered."
    Default: shun198
  Environment:
    Description: "Select a environment name."
    Type: String
    AllowedValues:
      - dev
      - stg
      - prd
    ConstraintDescription: "Environment name must be selected."

# -------------------------------------
# Resources
# -------------------------------------
Resources:
  # -------------------------------------
  # S3
  # -------------------------------------
  # For Lambda Archive
  LambdaArchiveBucket:
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      BucketName: !Sub ${ProjectName}-${Environment}-lambda-archive-${AWS::Region}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Enabled
      LifecycleConfiguration:
        Rules:
          - Id: ExpiresAfter365DaysFor4thOlderVersion
            Status: Enabled
            NoncurrentVersionExpiration:
              NewerNoncurrentVersions: 3
              NoncurrentDays: 365

# -------------------------------------
# Outputs
# -------------------------------------
Outputs:
  LambdaArchiveBucketName:
    Value: !Ref LambdaArchiveBucket
  LambdaArchiveBucketArn:
    Value: !GetAtt LambdaArchiveBucket.Arn

SNSとLambdaの構築

バウンスメールを通知するLambdaをバウンスを検知するSNSを構築します

  • SSMParameterNameForFromAdminEmail(バウンスメールの送信元)
  • SSMParameterNameForToEmail(バウンスメール受取先)

をパラメータストアから取得するので忘れずに作成しておきましょう
Lambda用のロールにはパラメータストアの環境変数の取得とSESを使ったメール送信用の権限を付与します

AWSTemplateFormatVersion: 2010-09-09
Description: "Lambda Function Stack For SES Bounce"

# -------------------------------------
# Metadata
# -------------------------------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Project Configuration"
        Parameters:
          - ProjectName
          - Environment
      - Label:
          default: "Lambda Configuration"
        Parameters:
          - LambdaArchiveBucketName
          - LambdaArchiveBucketObjectKey
          - LambdaArchiveObjectVersionID
          - ParametersSecretsLambdaExtensionArn
          - LambdaHandler
          - LambdaMemorySize
          - LambdaTimeout
          - LambdaRuntime
      - Label:
          default: "SSM Parameter Store Configuration"
        Parameters:
          - SSMParameterNameForFromAdminEmail
          - SSMParameterNameForToEmail
      - Label:
          default: "KMS Key Configuration"
        Parameters:
          - KMSKeyAliasName

# -------------------------------------
# Parameters
# -------------------------------------
Parameters:
  ProjectName:
    Description: "Enter the project name. (ex: shun198)"
    Type: String
    MinLength: 1
    ConstraintDescription: "ProjectName must be entered."
    Default: shun198
  Environment:
    Description: "Select a environment name."
    Type: String
    AllowedValues:
      - dev
      - stg
      - prd
    ConstraintDescription: "Environment name must be selected."
  LambdaArchiveBucketName:
    Description: "Enter the S3 Bucket name for Lambda zip archive."
    Type: String
  LambdaArchiveBucketObjectKey:
    Description: "Enter the S3 Bucket object key for Lambda zip archive."
    Type: String
  LambdaArchiveObjectVersionID:
    Description: "Enter the S3 object version ID for Lambda zip archive."
    Type: String
  # @see https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html#ps-integration-lambda-extensions-add
  ParametersSecretsLambdaExtensionArn:
    Description: "Enter the Lambda Extension ARN for AWS Parameters and Secrets."
    Type: String
    Default: arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11
  LambdaHandler:
    Description: "Enter the Lambda function handler."
    Type: String
    Default: lambda_function.lambda_handler
  LambdaMemorySize:
    Description: "Enter the Lambda function memory size. (MiB)"
    Type: Number
    Default: 128
    MinValue: 128
    MaxValue: 10240
  LambdaTimeout:
    Description: "Enter the Lambda function timeout second."
    Type: Number
    Default: 30
    MinValue: 1
    MaxValue: 900
  LambdaRuntime:
    Description: "Enter the Lambda function runtime."
    Type: String
    AllowedValues:
      - python3.12
    Default: python3.12
  SSMParameterNameForFromAdminEmail:
    Description: "Enter the Systems Manager parameter store name defined as 'FROM_ADMIN_EMAIL'. (ex: /shun198/dev/back/FROM_ADMIN_EMAIL)"
    Type: String
  SSMParameterNameForToEmail:
    Description: "Enter the Systems Manager parameter store name defined as 'TO_EMAIL'. (ex: /shun198/dev/back/TO_EMAIL)"
    Type: String
  KMSKeyAliasName:
    Description: "Enter the alias name for SNS KMS key."
    Type: String
    Default: alias/cmk/sns

# -------------------------------------
# Resources
# -------------------------------------
Resources:
  # -------------------------------------
  # Lambda Function
  # -------------------------------------
  SESBounceNotificationLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: !Ref LambdaArchiveBucketName
        S3Key: !Ref LambdaArchiveBucketObjectKey
        S3ObjectVersion: !Ref LambdaArchiveObjectVersionID
      Environment:
        Variables:
          FROM_ADMIN_EMAIL: !Ref SSMParameterNameForFromAdminEmail
          TO_EMAIL: !Ref SSMParameterNameForToEmail
      FunctionName: !Sub ${ProjectName}-${Environment}-notify-ses-email-bounce
      Description: "SES で発生した Bounce (不達) のメール情報を通知するための Lambda 関数"
      Layers:
        - !Ref ParametersSecretsLambdaExtensionArn
      Handler: !Ref LambdaHandler
      MemorySize: !Ref LambdaMemorySize
      Role: !GetAtt LambdaForSESBounceRole.Arn
      Runtime: !Ref LambdaRuntime
      Timeout: !Ref LambdaTimeout
      PackageType: Zip

  # -------------------------------------
  # IAM Role
  # -------------------------------------
  LambdaForSESBounceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub LambdaRoleForSESBounce-${ProjectName}-${Environment}
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /service-role/
      Policies:
        - PolicyName: !Sub LambdaAccessForSESBounce-${ProjectName}-${Environment}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - ssm:GetParameters
                  - ses:SendEmail
                Resource: "*"
              - Effect: Allow
                Action: logs:CreateLogGroup
                Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub
                  - arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunction}:*
                  - {LambdaFunction: !Sub "${ProjectName}-${Environment}-notify-ses-email-bounce"}

  # -------------------------------------
  # SNS Topic
  # -------------------------------------
  # SES バウンスの通知用
  SESBounceTopic:
    Type: AWS::SNS::Topic
    Properties:
      DisplayName: shun198-lambda-notify-ses-bounce
      TopicName: shun198-lambda-notify-ses-bounce
      KmsMasterKeyId: !Ref KMSKeyAliasName
  SESBounceTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: NotifySESBounceSNSPolicy
            Effect: Allow
            Principal:
              Service:
                - ses.amazonaws.com
            Action:
              - sns:Publish
            Resource: !Ref SESBounceTopic
            Condition:
              StringEquals:
                AWS:SourceAccount: !Ref AWS::AccountId
              StringLike:
                AWS:SourceArn: arn:aws:ses:*
      Topics:
        - !Ref SESBounceTopic

  # SES Topic Subscription
  SESTopicSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref SESBounceTopic
      Endpoint: !GetAtt SESBounceNotificationLambdaFunction.Arn
      Protocol: lambda

バウンスメール通知用のLambdaを作成します
SNSをトリガーに実行されるのでeventから対象のメールアドレス、件名などを取得していきます

lambda_function.py
import json
import logging
import os

import boto3

# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)


# メールの件名をデコード
def decode_subject(encoded_subject):
    try:
        subject_bytes = encoded_subject.encode('utf-8')
        subject = subject_bytes.decode('utf-8')
        return subject
    except Exception as e:
        logger.info(f"メールの件名のデコードに失敗しました: {e}")
        return encoded_subject


# SSM ParameterStore から指定されたパラメーター名で値を取得
def getParameterStoreValue(parameter_name):
    ssm = boto3.client('ssm')
    response = ssm.get_parameter(Name=parameter_name, WithDecryption=False)
    return response['Parameter']['Value']


# Bounce 処理
def lambda_handler(event, context):
    region_name = os.environ.get("AWS_DEFAULT_REGION")
    ses = boto3.client('ses', region_name=region_name)
    message_json = json.loads(event['Records'][0]['Sns']['Message'])
    destination_email = message_json['mail']['destination'][0]
    sender_email = getParameterStoreValue(os.environ.get("FROM_ADMIN_EMAIL"))
    recipient_email = getParameterStoreValue(os.environ.get("TO_EMAIL"))

    email_subject = '送信したメールが受信者に届きませんでした'
    subject = decode_subject(message_json['mail']['commonHeaders']['subject'])
    email_body = (
        "以下のメールを送信しましたが、受信者に届きませんでした。\n\n"
        f"• メールアドレス: {destination_email}\n"
        f"• メールの件名: {subject}\n\n"
        "本通知を受けて、お客様へのご確認をお願いします。\n\n"
    )

    response = ses.send_email(
        Source=sender_email,
        Destination={'ToAddresses': [recipient_email]},
        Message={
            'Subject': {'Data': email_subject},
            'Body': {'Text': {'Data': email_body}}
        }
    )

    logger.info(f"メールが送信されました: {response['MessageId']}")

コンソール上での設定

記事執筆の段階でSNSとSESの紐付けはCloudFormationでサポートされてないため、以下の作業をコンソール上で実施する必要があります

Lambda

Lambdaのトリガーの設定にCloudFormationで作成したSNSトピックを指定します
スクリーンショット 2024-08-30 10.38.50.png

SES

SESのバウンスフィードバックにCloudFormationで作成したSNSトピックを指定します
選択後、元のEメールヘッダーを含めるにチェックをつけます
スクリーンショット 2024-08-30 10.44.25.png

スクリーンショット 2024-08-30 10.39.11.png

実際に実行してみよう!

SESを使って存在しないメールアドレスへ送信するとSNSが検知し、SNSをトリガーにLambdaが実行されます
スクリーンショット 2024-08-30 10.41.07.png

以下のようにメールを受信できれば成功です
スクリーンショット 2024-08-30 10.42.45.png

参考

0
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
0
1