Amazon API Gateway から Amazon SES API を直接叩いてメール送信する (AWS Lambda 不使用)

こんにちは、広野です。

本題だけ挨拶抜きで冒頭に話します。本記事では、Amazon API Gateway と Amazon SES を Lambda 抜きで 連携する方法を紹介します。

はじめに

先月、AWS Summit Japan 2024 が開催されました。その中で、印象に残った AWS さんのセッションがありました。

「サーバーレス開発のベストプラクティス~より効果的に、より賢く使いこなすために~」

このセッションでは、サーバーレス開発のアーキテクチャ事例を数多く紹介してくれて非常に有用だったのですが、特に印象に残ったのは

AWS サービス間をプロキシするだけの Lambda 使用はやめましょう

というようなメッセージでした。

常々、私はサーバーレス開発において、コードやランタイムのメンテが必要になる AWS Lambda 関数が乱立することを負担に感じていました。また、昨今のアーキテクチャ時流では AWS Step Functions が AWS サービスの API を直接叩けるようになったり、Amazon EventBridge を活用したサービス間連携アーキテクチャの台頭があったりして、「とりあえず Lambda」から「脱 Lambda」に変わってきています。

そういう意味で、このメッセージには非常に共感を持つことができました。セッションでは Amazon API Gateway から 他の AWS サービスの連携も Lambda 関数抜きで行うアーキテクチャを紹介しており、やってみたくなりました。

私が題材に選んだのは、Amazon API Gateway と Amazon SES の直接連携です。現在主流のアーキテクチャでは、メールを送信する AWS Lambda 関数がメールのコンテンツを加工し、Amazon SES の API を叩く役割を持っています。しかしながら、加工と言っても文字列をちょこっといじるだけのようなもので、何らビジネスロジックがないケースが多いのではないかと思います。であれば、AWS Lambda 関数はいらんだろう!ということでチャレンジしたくなりました。

やりたいこと

本記事で紹介するアーキテクチャは、以前 jQuery でメール送信 API を叩くアーキテクチャを紹介した記事をベースにしています。その記事ではメール送信 API を Amazon API Gateway + AWS Lambda + Amazon SES で作成していたので、そこから Lambda を抜く!部分にフォーカスして執筆いたします。

ベースとなるアーキテクチャ、技術情報は以下リンクをご覧下さい。

前回記事の構成から

  • Amazon API Gateway REST API は Amazon SES との直接連携をサポートしている。統合タイプの設定は AWS を選択する。
  • 公開 WEB サイトの問い合わせフォームで使用するため、ユーザ認証はない。CORS はある。
  • CORS でハマるのが嫌なので、AWS CloudFormation でデプロイする。
  • WEB サイトから送信するデータは以下の JSON フォーマットとなっている。
{
  "subj": "件名",
  "name": "名前",
  "email": "xxx@xxxxxxx.com",
  "body": "問い合わせメッセージ本文"
}
  • 従来、このデータを AWS Lambda 関数にパススルーして、文字列加工したデータを Amazon SES に渡していた。AWS Lambda 関数がやっていたことは Amazon API Gateway REST API の「統合リクエスト」内、「マッピングテンプレート」で肩代わりさせる。
  • マッピングテンプレートは VTL (Velocity Template Language) で定義する。(AWS AppSync では多用するので AppSync 信者にはお馴染み)
  • Amazon SES の API は SendEmail を使用する。

マッピングテンプレートをどう書くか

AWS Lambda 関数でやっていたことを Amazon API Gateway のマッピングテンプレートに肩代わりさせるので、そこが肝になります。イメージしてもらいやすくするため、AWS Lambda 関数と対比して紹介します。

AWS Lambda 関数

細かい違いはあれど、だいたい以下のような AWS Lambda 関数 (Python) で Amazon SES を呼び出すと思います。

import json
import datetime
import boto3
def datetimeconverter(o):
  if isinstance(o, datetime.datetime):
    return str(o)
def lambda_handler(event, context):
  try:
    # Define Variables
    ses = boto3.client('ses',region_name='ap-northeast-1')
    d = json.loads(event['body'])
    print({"ReceivedData": d})
    RECEIVERS = []
    RECEIVERS.append('xxxxx@xxxxxxx.xxxx')
    SENDER = 'xxxxxx@xxxxx.xxxxxx'
    # Send Email
    res = ses.send_email(
      Destination={ 'ToAddresses': RECEIVERS },
      Message={
        'Body': {
          'Text': {
            'Charset': 'UTF-8',
            'Data': 'NAME: ' + d['name'] + '\nMAILADDRESS: ' + d['email'] + '\n\nMESSAGE:\n' + d['body']
          }
        },
        'Subject': {
          'Charset': 'UTF-8',
          'Data': d['subj']
        }
      },
      Source=SENDER,
      ReplyToAddresses=[d['email']]
    )
  except Exception as e:
    print(e)
    return {
      'isBase64Encoded': False,
      'statusCode': 200,
      'body': str(e)
    }
  else:
    return {
      'isBase64Encoded': False,
      'statusCode': 200,
      'body': json.dumps(res, indent=4, default=datetimeconverter)
    }

マッピングテンプレート

マッピングテンプレートだと、以下のようになります。

## API Gateway が受け取った引数を $inputRoot に格納する
#set($inputRoot = $input.path('$'))

## メール本文の文字列加工 改行を入れたければここでリアルに改行すればよい(かなり違和感を感じるw)
#set($tempbodya = "NAME: $inputRoot.name
MAILADDRESS: $inputRoot.email

MESSAGE:
$inputRoot.body")

## Windows 環境からだと改行コードが CRLF で来ることがあるので、LF に置換しておく
#set($tempbodyb = $tempbodya.replaceAll("\r\n", "\n"))

## メール本文は URL エンコードすること
#set($body = $util.urlEncode($tempbodyb))

## メールアドレスは URL エンコードすること
#set($destination = $util.urlEncode("xxxxx@xxxxx.xxx"))
#set($replyto = $util.urlEncode($inputRoot.email))
#set($source = $util.urlEncode("xxxxx@xxxxxx.xxxx"))

## 件名はエンコードしなくてよい、なぜか。エンコードするとエラーになる。改行が入っても通った。謎。
#set($subject = $inputRoot.subj)

## SES API 呼出はこのフォーマットで書く URL の続きに?付きでパラメータ指定するのと同じ形式
Action=SendEmail&Destination.ToAddresses.member.1=$destination&Message.Body.Text.Data=$body&Message.Subject.Data=$subject&ReplyToAddresses.member.1=$replyto&Source=$source

AWS Lambda 関数では、URL エンコードなんて一切意識していなかったと思います。AWS が提供してくれる SDK がいい感じにデータを加工してくれていたものと思います。VTL は低級言語?なのか SDK をロードできないですし、組み込み関数も少ないです。ただ URL エンコードする関数 ($util.urlEncode) は用意してくれているので助かりました。

URL エンコードが必要な理由は、マッピングテンプレートから Amazon SES SendEmail API を呼び出すときに API の URL に続けてパラメータを書いて渡すためです。(想像)
URL には使用してはいけない文字列、記号があり、UTF-8 の文字コードの文字列を URL で使用できる文字列、記号の範囲内におさまるようにエンコードする必要があります。それが URL エンコードです。
例えば、メールアドレスの @ は使えませんし、改行、日本語なんてもってのほかです。@ は %40 に、改行(\n) は %3A に置き換えられます。

メールの To だけでなく、Cc、Bcc ももちろん設定可能です。これらは複数指定できますが、仕様でそれぞれ 50 個までしか指定できません。To を複数指定するときは、Destination.ToAddress.member.1= のような記述を1始まりで50まで羅列させます。配列が使用できないので、このような書き方になります。AWS Lambda 関数では配列で SDK に渡していたので意識しなかった部分です。

VTL ではダブルクォーテーションとシングルクォーテーションで動きが異なるので、このサンプルコードにあるクォーテーションのまま使用することをお勧めします。

ここまで動く状態に到達するのに、情報が少なくて結構苦労しました。

他にも Amazon API Gateway の設定はあるのですが、メインはマッピングテンプレートでしたので、AWS CloudFormation テンプレートのところで補足いたします。

実際の動作

WEB サイトの問い合わせフォームから、問い合わせを送信します。

こんなメールが予め設定しておいた送信先メールアドレスに届きます。

AWS CloudFormation テンプレート

前回記事で紹介した AWS CloudFormation を修正したものを貼り付けます。一式、Amazon CloudFront や Amazon S3 なども含まれています。一応執筆時点で動いたことは確認済みです。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a static website hosting environment with a backend API for receiving emails from feedback form.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SiteName:
    Type: String
    Description: Your Site name. (Not any upper case characters)
    Default: xxxxx
    MaxLength: 40
    MinLength: 1

  DomainName:
    Type: String
    Description: Domain name for URL.
    Default: example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\\.)+[a-zA-Z]{2,}"

  SubDomainName:
    Type: String
    Description: Sub domain name for URL.
    Default: xxxxx
    MaxLength: 20
    MinLength: 1

  FeedbackSenderEmail:
    Type: String
    Description: E-mail address that sends feedback emails.
    Default: sender@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[a-zA-Z0-9_+-]+(.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\\.)+[a-zA-Z]{2,}"

  FeedbackReceiverEmail:
    Type: String
    Description: E-mail address that receives feedback emails.
    Default: receiver@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[a-zA-Z0-9_+-]+(.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\\.)+[a-zA-Z]{2,}"

  CertificateId:
    Type: String
    Description: ACM certificate ID. CloudFront only supports ACM certificates in us-east-1 region.
    Default: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    MaxLength: 36
    MinLength: 36

Resources:
# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------#
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub website-${SiteName}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: Cost
          Value: !Sub website-${SiteName}
  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action:
              - "s3:GetObject"
            Effect: Allow
            Resource: !Sub "arn:aws:s3:::${S3Bucket}/*"
            Principal:
              Service: cloudfront.amazonaws.com
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}
    DependsOn:
      - S3Bucket
      - CloudFrontDistribution

# ------------------------------------------------------------#
# CloudFront
# ------------------------------------------------------------#
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub CloudFront distribution for website-${SiteName}
        Aliases:
          - !Sub ${SubDomainName}.${DomainName}
        HttpVersion: http2
        IPV6Enabled: true
        PriceClass: PriceClass_200
        DefaultCacheBehavior:
          TargetOriginId: !Sub S3Origin-website-${SiteName}
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
          CachedMethods:
            - GET
            - HEAD
          CachePolicyId: !Ref CloudFrontCachePolicy
          OriginRequestPolicyId: !Ref CloudFrontOriginRequestPolicy
          ResponseHeadersPolicyId: !Ref CloudFrontResponseHeadersPolicy
          Compress: true
          SmoothStreaming: false
        DefaultRootObject: index.html
        CustomErrorResponses:
          - ErrorCachingMinTTL: 10
            ErrorCode: 403
            ResponseCode: 404
            ResponsePagePath: /404.html
        Origins:
          - Id: !Sub S3Origin-website-${SiteName}
            DomainName: !Sub ${S3Bucket}.s3.${AWS::Region}.amazonaws.com
            OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
            S3OriginConfig:
              OriginAccessIdentity: ""
            ConnectionAttempts: 3
            ConnectionTimeout: 10
        ViewerCertificate:
          AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
          MinimumProtocolVersion: TLSv1.2_2021
          SslSupportMethod: sni-only
      Tags:
        - Key: Cost
          Value: !Sub website-${SiteName}
    DependsOn:
      - CloudFrontCachePolicy
      - CloudFrontOriginRequestPolicy
      - CloudFrontResponseHeadersPolicy
      - CloudFrontOriginAccessControl
      - S3Bucket

  CloudFrontOriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Properties: 
      OriginAccessControlConfig: 
        Description: !Sub CloudFront OAC for website-${SiteName}
        Name: !Sub OriginAccessControl-website-${SiteName}
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  CloudFrontCachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub CachePolicy-website-${SiteName}
        Comment: !Sub CloudFront Cache Policy for website-${SiteName}
        DefaultTTL: 3600
        MaxTTL: 86400
        MinTTL: 60
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingBrotli: true
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: whitelist
            Headers:
              - Access-Control-Request-Headers
              - Access-Control-Request-Method
              - Origin
          QueryStringsConfig:
            QueryStringBehavior: none

  CloudFrontOriginRequestPolicy:
    Type: AWS::CloudFront::OriginRequestPolicy
    Properties:
      OriginRequestPolicyConfig:
        Name: !Sub OriginRequestPolicy-website-${SiteName}
        Comment: !Sub CloudFront Origin Request Policy for website-${SiteName}
        CookiesConfig:
          CookieBehavior: none
        HeadersConfig:
          HeaderBehavior: whitelist
          Headers:
            - Access-Control-Request-Headers
            - Access-Control-Request-Method
            - Origin
        QueryStringsConfig:
          QueryStringBehavior: none

  CloudFrontResponseHeadersPolicy:
    Type: AWS::CloudFront::ResponseHeadersPolicy
    Properties:
      ResponseHeadersPolicyConfig:
        Name: !Sub ResponseHeadersPolicy-website-${SiteName}
        Comment: !Sub CloudFront Response Headers Policy for website-${SiteName}
        SecurityHeadersConfig:
          ContentSecurityPolicy:
            ContentSecurityPolicy: !Sub >-
              default-src 'self' *.${DomainName};
              img-src 'self' *.${DomainName} data: blob:;
              style-src 'self' *.${DomainName} fonts.googleapis.com;
              font-src 'self' *.${DomainName} fonts.gstatic.com;
              script-src 'self' *.${DomainName} 'unsafe-inline';
              script-src-elem 'self' *.${DomainName};
              connect-src 'self' *.${DomainName} *.amazonaws.com;
            Override: true
          ContentTypeOptions:
            Override: true
          FrameOptions:
            FrameOption: DENY
            Override: true
          ReferrerPolicy:
            Override: true
            ReferrerPolicy: strict-origin-when-cross-origin
          StrictTransportSecurity:
            AccessControlMaxAgeSec: 31536000
            IncludeSubdomains: true
            Override: true
            Preload: true
          XSSProtection:
            ModeBlock: true
            Override: true
            Protection: true
        CustomHeadersConfig:
          Items:
            - Header: Cache-Control
              Value: no-store
              Override: true

# ------------------------------------------------------------#
# Route 53
# ------------------------------------------------------------#
  Route53RecordIpv4:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub ${SubDomainName}.${DomainName}.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt CloudFrontDistribution.DomainName
    DependsOn:
      - CloudFrontDistribution
  Route53RecordIpv6:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub ${SubDomainName}.${DomainName}.
      Type: AAAA
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt CloudFrontDistribution.DomainName
    DependsOn:
      - CloudFrontDistribution

# ------------------------------------------------------------#
# API Gateway
# ------------------------------------------------------------#
  RestApiSendEmail:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub SendEmail-website-${SiteName}
      Description: !Sub REST API to send user's feedback email for website-${SiteName}
      EndpointConfiguration:
        Types:
          - REGIONAL
      Tags:
        - Key: Cost
          Value: !Sub website-${SiteName}

  RestApiDeploymentSendEmail:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref RestApiSendEmail
    DependsOn:
      - RestApiMethodSendEmailPost
      - RestApiMethodSendEmailOptions

  RestApiStageSendEmail:
    Type: AWS::ApiGateway::Stage
    Properties:
      StageName: prod
      Description: production stage
      RestApiId: !Ref RestApiSendEmail
      DeploymentId: !Ref RestApiDeploymentSendEmail
      MethodSettings:
        - ResourcePath: "/*"
          HttpMethod: "*"
          LoggingLevel: INFO
          DataTraceEnabled : true
      TracingEnabled: false
      Tags:
        - Key: Cost
          Value: !Sub website-${SiteName}

  RestApiResourceSendEmail:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RestApiSendEmail
      ParentId: !GetAtt RestApiSendEmail.RootResourceId
      PathPart: sendemail

  RestApiMethodSendEmailPost:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RestApiSendEmail
      ResourceId: !Ref RestApiResourceSendEmail
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS
        IntegrationHttpMethod: POST
        Credentials: !GetAtt ApigSesInvocationRole.Arn
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:email:action/SendEmail
        PassthroughBehavior: WHEN_NO_TEMPLATES
        RequestParameters:
          integration.request.header.Content-Type: "'application/x-www-form-urlencoded'"
        RequestTemplates:
          application/json: !Sub
            - |-
              #set($inputRoot = $input.path('$'))
              #set($tempbodya = "NAME: $inputRoot.name
              MAILADDRESS: $inputRoot.email

              MESSAGE:
              $inputRoot.body")
              #set($tempbodyb = $tempbodya.replaceAll("\r\n", "\n"))
              #set($body = $util.urlEncode($tempbodyb))
              #set($destination = $util.urlEncode("${FeedbackReceiverEmail}"))
              #set($replyto = $util.urlEncode($inputRoot.email))
              #set($subject = $inputRoot.subj+"(${SubDomainName}.${DomainName})")
              #set($source = $util.urlEncode("${FeedbackSenderEmail}"))
              Action=SendEmail&Destination.ToAddresses.member.1=$destination&Message.Body.Text.Data=$body&Message.Subject.Data=$subject&ReplyToAddresses.member.1=$replyto&Source=$source
            - {}
        IntegrationResponses:
          - StatusCode: 200
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
          - StatusCode: 400
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
          - StatusCode: 403
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
          - StatusCode: 404
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
          - StatusCode: 500
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
          - StatusCode: 503
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
      MethodResponses:
        - StatusCode: 200
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
        - StatusCode: 400
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
        - StatusCode: 403
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
        - StatusCode: 404
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
        - StatusCode: 500
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
        - StatusCode: 503
          ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true

  RestApiRequestModelSendEmail:
    Type: AWS::ApiGateway::Model
    Properties:
      ContentType: application/json
      RestApiId: !Ref RestApiSendEmail
      Schema: |-
        {
          "$schema": "http://json-schema.org/draft-04/schema#",
          "title": "SendEmail",
          "type": "object",
          "properties": {
            "subj": {
              "type": "string"
            },
            "name": {
              "type": "string"
            },
            "email": {
              "type": "string"
            },
            "body": {
              "type": "string"
            }
          },
          "required": ["subj", "name", "email", "body"]
        }

  RestApiMethodSendEmailOptions:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RestApiSendEmail
      ResourceId: !Ref RestApiResourceSendEmail
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        Credentials: !GetAtt ApigSesInvocationRole.Arn
        IntegrationResponses:
          - ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'"
            ResponseTemplates:
              application/json: ''
            StatusCode: 200
        PassthroughBehavior: WHEN_NO_MATCH
        RequestTemplates:
          application/json: '{"statusCode": 200}'
      MethodResponses:
        - ResponseModels:
            application/json: Empty
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
            method.response.header.Access-Control-Allow-Origin: true
          StatusCode: 200

# ------------------------------------------------------------#
# API Gateway SES Invocation Role (IAM)
# ------------------------------------------------------------#
  ApigSesInvocationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ApigSesInvocationRole-website-${SiteName}
      Description: This role allows API Gateways to invoke SES.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - apigateway.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
      Policies:
        - PolicyName: !Sub ApigSesInvocationPolicy-website-${SiteName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "ses:SendEmail"
                Resource:
                  - "*"

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
  APIGatewayEndpointSendEmail:
    Value: !Sub https://${RestApiSendEmail}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${RestApiStageSendEmail}/sendemail
  SiteURL:
    Value: !Sub https://${SubDomainName}.${DomainName}

Amazon API Gateway を CORS 対応にしているので、レスポンスのところで CORS 関連の設定が多くなっています。ヘッダー関連の設定はあまりいじらない方がよいと思います。

統合リクエストの設定で、HTTP ヘッダーに Content-Type: ‘application/x-www-form-urlencoded’ を設定しています。こうしないと動きませんでした。

このテンプレートが動作する前提はいろいろあるのですが (すいません) 前回記事から追加していることだけ。Amazon API Gateway のログを Amazon CloudWatch Logs に push するために必要な IAM ロールがアカウントに登録済みでないとエラーになります。それについては以下の記事をご確認ください。

まとめ

いかがでしたでしょうか?

正直に申しますと、意気込んでやってはみたものの、AWS Lambda 関数抜きの Amazon SES SendEmail API 呼出は絶対に流行らないと確信しました。VTL の使い勝手が Python に比べて悪すぎるのと、SendEmail 呼出だと文字列のエンコードというデリケートな部分があるためです。気を遣わなくて済む AWS Lambda の方が楽です。でも、せっかく作ったので私はこの方法を使おうと思います。他人にはお勧めしませんが。w

ですがこの例は、Amazon API Gateway から Amazon SES でない、他の AWS サービスへの直接連携の参考になるかもしれません。

本記事が皆様のお役に立てれば幸いです。

最後に補足です。Amazon SES からメール送信するときは、Gmail と Yahoo Mail が実装した送信者要件を満たさないとそれらにはメールが届かない恐れがあります。Amazon SES 側で必要な設定があるので、要注意です。私も実装しました。

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWSは好きですが、それよりもフロントエンド開発の方が好きでして、バックエンド構築を簡単にしてくれたAWSには感謝の気持ちの方が強いです。
取得資格:AWS 認定は13資格、ITサービスマネージャ、ITIL v3 Expert 等
2020 - 2024 Japan AWS Top Engineer 受賞
2022 - 2024 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする
クラウドに強いによるエンジニアブログです。
SCSKは専門性と豊富な実績を活かしたクラウドサービス USiZE(ユーサイズ)を提供しています。
USiZEサービスサイトでは、お客様のDX推進をワンストップで支援するサービスの詳細や導入事例を紹介しています。
AWSアプリケーション開発クラウドソリューション
シェアする
タイトルとURLをコピーしました