NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

Amazon SESのメール受信機能を使って、定型業務を自動化してみた

はじめに

はじめまして、入社1年目の藤本です。8月にクラウド事業推進部に配属され、AWSを用いた業務に日々奮闘しています。

私は、AWSの学習の一環として社内の勉強会に参加しています。この勉強会の内容はメールで送信されるのですが、案内のメールに気付いた人が自主的にSlackのAWSに関するチャンネルに投稿して周知する仕組みとなっています。しかし、この作業は手動で行われているため、時折連絡が漏れることや、勉強会直前の通知となることもあります。 上記運用の改善のため、勉強会案内の通知フローの自動化を考えたいと思います。そのため、今回はメールを受信してSlackに投稿するAWSアーキテクチャの検証を行いました。また、Amazon SESの受信機能が最近東京リージョンでも利用可能となったため、試しに使ってみます。

構成

勉強会の案内はメールで送られてくるため、送られてきたメールをAmazon SESで受信します。SESからSlackに直接通知することはできないので、Amazon S3に一度保存します。S3にメールが保存された際に、S3イベント通知によりAWS Lambdaが呼び出されます。呼び出されたLambdaはS3に保存された内容を取得して、Slackに通知するというフローになります。

上記のアーキテクチャは、今後も応用できそうなので、再利用可能なIaC(Infrastructure as Code)の一つであるAWS CloudFormationを用いて実装します。また、AWS SDK for Python(Boto3)を用いて、Lambdaでメールの処理を行うようにします。

作ってみた

Amazon Route53によるドメイン登録

SESでは、指定したドメインに代わって、Eメールの受信設定を行うことができます。初めに、Route53を用いてSESのメール受信設定を行うドメインを事前に登録します。 ドメインの登録方法については、こちらを参考にしてください。

また、後ほどMXレコードを作成する際にホストゾーンIDの指定が必要になるため、登録したドメインをRoute53にてパブリックホストゾーンに設定します。

SESのドメイン認証

Route53で登録したドメインをSESの送受信に使用するために、ID の作成・検証する必要があります。 SESの検証済みIDの項目から、IDの作成を行い、Route53で登録したドメインを指定します。 登録したIDのIDステータスが検証済みになれば、ドメイン認証完了です。

CloudFormationで作成するリソース

Route53によるMXレコードの公開

MXレコードとは、AWSのドキュメントによると、以下の通りです。

メールエクスチェンジャレコード (MX レコード ) は、ドメインに送信された E メールを受け入れることができるメールサーバーを指定する設定です。

指定したドメインに送られたメールをSESで受信するため、Route53でMXレコードを作成し、SESの受信用エンドポイントを設定します。 2023年9月8日のサービスアップデートにより、E メール受信サービスに東京リージョンが新たに追加されたので、今回はリージョンを東京に指定します。CloudFormationテンプレート内のResourceRecordsで、 「inbound-smtp.ap-northeast-1.amazonaws.com」を指定します。

Resources:
  MXrecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: [ホストゾーンID]
      Name: [Route53で登録したドメイン]
      Type: MX
      TTL: 300
      ResourceRecords:
        - 10 inbound-smtp.ap-northeast-1.amazonaws.com
SES受信ルール作成

SESでは、初めにルールセットを作成します。そして、作成したルールセット内で、受信ルールを定義する必要があります。受信ルールを定義する際に、 先ほど登録したドメインを含む社内勉強会通知のメール先のメールアドレスを指定します。

Resources:
  RuleSet:
    Type: AWS::SES::ReceiptRuleSet
    Properties: 
      RuleSetName: mail-ruleset
  ReceiptRule:
    Type: AWS::SES::ReceiptRule
    DependsOn: S3BucketPolicy
    Properties: 
      RuleSetName: !Ref RuleSet
      Rule: 
        Name: [メールのユーザー名]_[Route53で登録したドメイン]
        Recipients: 
          - [メールのユーザー名]@[Route53で登録したドメイン]
        Enabled: True
        TlsPolicy: Require
        ScanEnabled: True
        Actions:
          - S3Action:
              BucketName: S3Bucket

複数のルールセットを作成することができますが、一度にアクティブにできるのは 1 つのみになります。そのため、CloudFormationで受信ルール作成後、手動でルールセットを有効にする必要があります。

メール保存用のS3バケット作成

作成するS3バケットは、アクセスコントロールやパブリックアクセスブロック、暗号化の一般的な設定を行います。Slackに通知されたオブジェクト(メールの内容)は必要ないため、ライフタイムサイクルを設定し、1日でオブジェクトを削除するようにします。また、S3イベント通知によりLambdaが呼び出される設定も行います。

Parameters:
  S3BucketName:
    Type: String
Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref S3BucketName
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: Delete-LifeCycle-Rule
            Status: Enabled
            ExpirationInDays: 1
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Function: !GetAtt TriggerLambda.Arn
  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: AllowsesPutFromSES
            Action: s3:PutObject
            Effect: Allow
            Resource:
              - !Sub arn:aws:s3:::${S3Bucket}/*
            Principal:
              Service: ses.amazonaws.com
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:receipt-rule-set/mail-ruleset:receipt-rule/[メールのユーザー名]_[Route53で登録したドメイン]
S3に保存されたメールの内容を取得し、Slackに通知するLambda

S3に保存されたメールの内容取得・Slack通知のために、Lambdaとロール、リソースベースポリシーを作成します。

Parameters:
  LambdaName:
    Type: String
  LambdaRoleName:
    Type: String
  S3BucketName:
    Type: String
Resources:
  TriggerLambdaRole:
    Type: AWS::IAM::Role
    Properties: 
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
            - Action:
                - sts:AssumeRole
              Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
      ManagedPolicyArns: 
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
      RoleName: !Sub ${LambdaRoleName}
      
  TriggerLambda:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${LambdaName}
      Role: !GetAtt TriggerLambdaRole.Arn
      CodeUri: src/
      Handler: [Lambdaファイル名.ハンドラー名]

  TriggerLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt TriggerLambda.Arn
      Principal: s3.amazonaws.com
      SourceArn: !Sub arn:aws:s3:::${S3BucketName}

SESの保存先のS3にはMIME形式のraw E メールを保存されます。そのため、一度UTF-8でデコードし、必要なメールの本文だけを抜き出します。

def s3_get_text(BUCKET_NAME,OBJECT_KEY_NAME):
    s3= boto3.client('s3')
    response = s3.get_object(Bucket=BUCKET_NAME, Key=OBJECT_KEY_NAME)
    email_body = response['Body'].read().decode('utf-8')
    email_object = email.message_from_string(email_body)
    text=""
    for part in email_object.walk():
        if part.get_content_type() == "text/plain":
            charset = str(part.get_content_charset())
            text += part.get_payload(decode=True).decode(charset,errors="replace")
        else:
            logger.warning('メール本文がありません\n')
    return text

取得したメールの本文は、転送の際に挿入された引用符やメールの形式によって、CRLF等の改行コードが含まれる場合があります。そのため、Slackで通知する前に、メールの本文を読みやすいように整形する処理が必要になります。

上記のコードに加え、メール内容の整形・Slack通知の処理を作成します。

転送設定

最後に、OutlookやGmail等のメールソフトを使用して、社内勉強会のメールに対して、SESに登録したメールアドレスに転送設定を行います。

以上で構築完了です。

動作確認

以下の文章を今回登録したメールアドレスに転送して、Slackに通知されるか確認します。

皆様、

いつもお世話になっております。[名前]です。

この度、AWS(Amazon Web Services)に関する勉強会を開催する運びとなりました。皆様のご参加を心よりお待ちしております。以下に詳細を記載いたします。

日時: [日付] [開始時間] - [終了時間]
場所: [場所/会場名]
対象: AWSに興味のある方、初心者から上級者まで

内容概要:

- AWS基礎知識の解説
- AWSサービスの概要と活用方法
- デモンストレーションや実践的なハンズオンセッション

AWS認定資格に関する情報やキャリアの展望についてもご紹介いたします。

お申し込み方法:
[申込み方法や締め切り日など詳細を記載]

何かご質問やご不明点がございましたら、お気軽にお問い合わせください。

多くの皆様とのご参加を心よりお待ちしております。

よろしくお願い申し上げます。

転送されたメールは、SESにて受信され、S3に保存されます。S3に配信されるメールのファイル名は、一意のファイル名になるようにランダムな文字列や数字の羅列が自動で割り当てられます。

上手くメールの内容をSlackにて通知することができました。

困ったこと

どの権限を与えればよいか分からない

AWSサービスが他のサービスに対して操作を行うときには必ず、権限(IAMロール等)の設定が必要になります。 今回の場合、LambdaがCloudWatchのログを送信を許可するAWSLambdaBasicExecutionRoleの権限も与える必要があります。加えて、LambdaはS3のバケット内のメールの内容を読み込む必要があるため、AmazonS3ReadOnlyAccessを与える必要があります。 IAMのベストプラクティスである最小権限を念頭に、適切なマネージドポリシーを取捨選択できるように、ポリシーを把握しておく必要があります。

また、S3側にもリソースベースポリシーによる許可(バケットポリシー)が必要になります。同じアカウントが所有するS3とLambdaである場合、明示的にLambdaを許可する必要はないため、今回のアーキテクチャではSESのみをバケットポリシーによって許可します。そのため、リソースベースポリシーが必要なリソースも把握しておく必要があります。 同様にLambda側にもリソースベースポリシーによる許可が必要になります。CloudFormation作成時に、AWS::Lambda::Permissionタイプを指定することで、S3からのLambdaの呼び出しを許可するリソースベースポリシーを設定することができます。 リソースベースポリシーが必要なリソースはこちらから確認できます。

CloudFormationの構築手順を意識する

CloudFormationを利用する際に考慮すべきこととして、依存関係があります。依存関係とは、1つのリソースが他のリソースの作成または設定に依存している状況を指します。基本的に、CloudFormationは依存関係から自動的にスタック作成の順番を決めます。しかし、自動で決定された順番でCloudFotmationのスタックの作成が失敗する場合があります。

例えば、今回の場合だと、SESの受信ルール作成の際に、以下のエラーが発生します。

Could not write to bucket: ~ (Service: AmazonSimpleEmailService; Status Code: 400; Error Code: InvalidS3Configuration; Request ID: ~ ; Proxy: null)

このエラーは、バケットに書き込みができないことを指しています。S3バケットにSESの書き込みを許可するバケットポリシーが作成される前に、SESの受信先をS3に指定していることによってエラーが発生していると考えられます。この場合は、バケットポリシーが作成された後に、SESの受信ルールが作成されるように、DependsOnを用いて明示的に作成手順を指定します。

ReceiptRule:
    Type: AWS::SES::ReceiptRule
    DependsOn: S3BucketPolicy
        :

また、 DependsOnや!Ref、!GetAttなどの値を参照するような組み込み関数を用いると、相互的に依存関係を持つ状態である循環依存が発生する場合があります。循環依存が発生すると、スタックの作成や更新が失敗します。依存関係が発生しないようにする方法として、テンプレートを分ける方法があります。1つのテンプレートファイルにまとめて記述する場合は、!Subを用いてARNを疑似的に指定することで、循環依存を回避することができます。コードを例に見てみましょう。

以下のコードは、S3にオブジェクトがPutされた際に、Lambdaが呼び出されるCloudFormationのテンプレートの一部を抜粋したものです。32行目では、Lambdaのリソースベースポリシーで許可するS3を指定しています。ここで!GetAtt を利用した場合、S3側のLambdaへの参照とLambdaのリソースベースポリシーによる参照で、依存循環が発生します。

この場合は、!Sub arn:aws:s3:::${AWS::StackName}-${S3BucketName}-${AWS::AccountId}のように直接値を参照せずに、疑似的にARNを指定することで循環依存を回避できます。

  TriggerLambda:
    Type: AWS::Serverless::Function
    Properties:
        :

  TriggerLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt TriggerLambda.Arn
      Principal: s3.amazonaws.com
      # ここで、SourceArn: !GetAtt S3Bucket.Arnを指定すると循環依存が発生する
      SourceArn: !Sub arn:aws:s3:::${AWS::StackName}-${S3BucketName}-${AWS::AccountId}

  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
         :
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Function: !GetAtt TriggerLambda.Arn

おわりに

今回、一からアーキテクチャを考え、環境の構築を行いましたが、想像以上に時間がかかってしまった印象でした。IaCの強みをまだまだ活かしきれていない部分が多くあるので、CloudFormationのファイルをテンプレート化しておき、素早く開発できる準備が必要だと思いました。また、実際に作ってみないと分からないことは多く、リソース展開の手軽さもAWSの良さであると思うので、とりあえず作ってみる・動かしてみることが大切だと感じました。

最後までお読みいただきありがとうございました。

執筆者:藤本 匠海 インフラエンジニア(主にAWS)