CloudFormationで静的サイトのお問い合わせフォームを構築|API Gateway・Lambda・SES

この記事は約5分で読めます

こんにちは、 @Manabu です。

S3とCloudFrontで配信する静的サイトには、PHPなどを実行するサーバーがありません。

そのため、お問い合わせフォームを設置しても、HTMLとJavaScriptだけで安全にメールを送信することは難しいです。

今回は、API Gateway、Lambda、SESを使ったお問い合わせフォームを、CloudFormationで構築する方法について紹介します。

この記事は、以下のような方におすすめです。

こんな方におすすめ

  • 静的サイトにお問い合わせフォームを設置したい
  • API Gateway、Lambda、SESを組み合わせて使いたい
  • CloudFormationで同じ環境を再作成できるようにしたい

全体の流れ

お問い合わせフォーム
→ CloudFront /api/contact
→ API Gateway
→ Lambda
→ SES
→ 管理者へメール送信

フォームからは、名前、メールアドレス、問い合わせ種別、本文をJSONで送信します。

CloudFormationで作成するもの

CloudFormationでは、主に次のリソースを作成します。

  • API Gateway HTTP API
  • Lambda関数
  • Lambda実行ロール
  • LambdaからSESを実行する権限
  • API GatewayからLambdaを実行する権限
  • CloudWatch Logsのロググループ

送信先や許可するサイトは、テンプレートへ直接書かずにパラメータとして渡します。

Parameters:
  FromEmail:
    Type: String
  ToEmail:
    Type: String
  AllowedOrigin:
    Type: String

API Gatewayのルート

HTTP APIにPOST /api/contactを作成します。

ContactRoute:
  Type: AWS::ApiGatewayV2::Route
  Properties:
    ApiId: !Ref ContactApi
    RouteKey: POST /api/contact
    AuthorizationType: NONE
    Target: !Sub "integrations/${ContactIntegration}"

ステージには$defaultを使い、変更を自動で反映させます。

ContactStage:
  Type: AWS::ApiGatewayV2::Stage
  Properties:
    ApiId: !Ref ContactApi
    StageName: "$default"
    AutoDeploy: true

Lambdaで行う処理

Lambdaでは次を確認します。

  • 許可したサイトからのリクエストか
  • 必須項目が入力されているか
  • メールアドレスの形式が正しいか
  • 本文が長すぎないか
  • ハニーポット項目が空か

問題がなければSESから管理者へメールを送信し、入力者のメールアドレスをReply-Toへ設定します。

SESの権限はフルアクセスではなく、対象IDに対するses:SendEmailへ絞ります。

Effect: Allow
Action:
  - ses:SendEmail
Resource: arn:aws:ses:ap-northeast-1:123456789012:identity/example.com

CORSと送信制限

API Gatewayでは、フォームを設置するサイトだけを許可します。

CorsConfiguration:
  AllowOrigins:
    - https://example.com
  AllowMethods:
    - POST
  AllowHeaders:
    - content-type

あわせて、API Gatewayのステージへスロットリングを設定します。

DefaultRouteSettings:
  ThrottlingRateLimit: 2
  ThrottlingBurstLimit: 5

CORSだけで不正な送信を完全に防げるわけではありません。Lambda側でもOrigin、入力値、本文サイズを確認し、ハニーポットなどを組み合わせます。

STGと本番のオリジン

許可するオリジンを完全一致で確認する場合、STGと本番では設定が異なります。

STG:  https://stg.example.com
本番: https://example.com

両環境から同時に送信するなら、環境ごとにAPIを分けるか、複数オリジンへ対応させます。

CloudFormationをデプロイする

テンプレートをtemplate.ymlとして保存した場合は、次のようにデプロイします。

aws cloudformation deploy \
  --region ap-northeast-1 \
  --stack-name example-contact-form \
  --template-file template.yml \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    FromEmail=no-reply@example.com \
    ToEmail=receive@example.net \
    AllowedOrigin=https://example.com

メールアドレス、スタック名、ドメインは自分の環境へ合わせて変更してください。

CloudFrontから同じドメインで呼び出す

CloudFrontへAPI Gatewayをオリジンとして追加し、/api/*ビヘイビアをAPI Gatewayへ向けると、サイトと同じドメインからフォームを送信できます。

設定方法は以下の記事で紹介しています。

REFERENCECloudFrontでS3とAPI Gatewayを同一ドメインから配信する方法IT自習室

運用時の注意点

  • SESのドメインIDとLambdaは同じリージョンにする
  • SESがサンドボックス状態か確認する
  • API Gatewayでスロットリングを設定する
  • LambdaのエラーをCloudWatch Logsで確認する
  • APIレスポンスをCloudFrontでキャッシュしない

CloudFormationテンプレートの完成例

ここまでの内容を1つにまとめたCloudFormationテンプレートです。

template.ymlとして保存し、後述のコマンドでデプロイできます。

AWSTemplateFormatVersion: "2010-09-09"
Description: Contact form API using API Gateway, Lambda, and SES.

Parameters:
  SesIdentityDomain:
    Type: String
    Description: Verified SES domain identity in this AWS Region.
    AllowedPattern: "^[A-Za-z0-9.-]+$"

  FromEmail:
    Type: String
    Description: Sender address under the verified SES domain.
    AllowedPattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"

  ToEmail:
    Type: String
    Description: Address that receives contact form messages.
    AllowedPattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"

  AllowedOrigin:
    Type: String
    Description: Exact website origin without a trailing slash.
    AllowedPattern: "^https://[^/]+$"

  SubjectPrefix:
    Type: String
    Default: "[お問い合わせ]"
    Description: Prefix used for notification email subjects.
    MaxLength: 40

  ThrottlingRateLimit:
    Type: Number
    Default: 2
    MinValue: 1
    MaxValue: 10

  ThrottlingBurstLimit:
    Type: Number
    Default: 5
    MinValue: 1
    MaxValue: 20

Resources:
  ContactFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-handler"
      RetentionInDays: 30

  ContactFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: WriteFunctionLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub "${ContactFunctionLogGroup.Arn}:*"
        - PolicyName: SendContactEmail
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: ses:SendEmail
                Resource: !Sub >-
                  arn:${AWS::Partition}:ses:${AWS::Region}:${AWS::AccountId}:identity/${SesIdentityDomain}

  ContactFunction:
    Type: AWS::Lambda::Function
    DependsOn:
      - ContactFunctionLogGroup
      - ContactFunctionRole
    Properties:
      FunctionName: !Sub "${AWS::StackName}-handler"
      Description: Validates contact requests and sends email through SES.
      Runtime: python3.12
      Architectures:
        - arm64
      Handler: index.lambda_handler
      Role: !GetAtt ContactFunctionRole.Arn
      Timeout: 10
      MemorySize: 128
      Environment:
        Variables:
          ALLOWED_ORIGIN: !Ref AllowedOrigin
          FROM_EMAIL: !Ref FromEmail
          TO_EMAIL: !Ref ToEmail
          SUBJECT_PREFIX: !Ref SubjectPrefix
      Code:
        ZipFile: |
          import base64
          import json
          import os
          import re
          from urllib.parse import parse_qs

          import boto3

          ses = boto3.client("ses")
          EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
          SUBJECTS = {
              "development": "AWS・システム開発の相談",
              "article": "記事について",
              "other": "その他",
          }

          def response(status_code, message):
              return {
                  "statusCode": status_code,
                  "headers": {"content-type": "application/json; charset=utf-8"},
                  "body": json.dumps({"message": message}, ensure_ascii=False),
              }

          def request_origin(event):
              headers = event.get("headers") or {}
              return headers.get("origin") or headers.get("Origin") or ""

          def parse_body(event):
              raw = event.get("body") or ""
              if event.get("isBase64Encoded"):
                  raw = base64.b64decode(raw).decode("utf-8")
              if len(raw.encode("utf-8")) > 32768:
                  raise ValueError("送信内容が大きすぎます。")
              headers = event.get("headers") or {}
              content_type = (
                  headers.get("content-type")
                  or headers.get("Content-Type")
                  or ""
              ).lower()
              if "application/json" in content_type:
                  data = json.loads(raw)
              else:
                  parsed = parse_qs(raw, keep_blank_values=True)
                  data = {key: values[0] for key, values in parsed.items()}
              if not isinstance(data, dict):
                  raise ValueError("送信内容を確認してください。")
              return data

          def clean(value, max_length):
              value = str(value or "").strip()
              if not value or len(value) > max_length:
                  raise ValueError("入力内容を確認してください。")
              return value

          def lambda_handler(event, context):
              if request_origin(event) != os.environ["ALLOWED_ORIGIN"]:
                  return response(403, "このサイトからのみ送信できます。")

              try:
                  data = parse_body(event)
                  if str(data.get("website") or "").strip():
                      return response(200, "お問い合わせを受け付けました。")

                  name = clean(data.get("name"), 80)
                  email = clean(data.get("email"), 254)
                  subject_key = clean(data.get("subject"), 32)
                  message = clean(data.get("message"), 5000)

                  if not EMAIL_PATTERN.fullmatch(email):
                      raise ValueError("メールアドレスを確認してください。")
                  if subject_key not in SUBJECTS:
                      raise ValueError("お問い合わせ種別を確認してください。")
                  if str(data.get("privacy_agreed") or "").lower() not in (
                      "yes", "true"
                  ):
                      raise ValueError("プライバシーポリシーへの同意が必要です。")
              except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
                  return response(400, str(error) or "入力内容を確認してください。")

              subject_label = SUBJECTS[subject_key]
              body = "\n".join([
                  "お問い合わせフォームからメッセージが届きました。",
                  "",
                  f"お名前: {name}",
                  f"メールアドレス: {email}",
                  f"お問い合わせ種別: {subject_label}",
                  "",
                  "お問い合わせ内容:",
                  message,
                  "",
                  f"Request ID: {context.aws_request_id}",
              ])

              ses.send_email(
                  Source=os.environ["FROM_EMAIL"],
                  Destination={"ToAddresses": [os.environ["TO_EMAIL"]]},
                  ReplyToAddresses=[email],
                  Message={
                      "Subject": {
                          "Data": f"{os.environ['SUBJECT_PREFIX']} {subject_label}",
                          "Charset": "UTF-8",
                      },
                      "Body": {
                          "Text": {"Data": body, "Charset": "UTF-8"}
                      },
                  },
              )
              return response(200, "お問い合わせを受け付けました。")

  ContactApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub "${AWS::StackName}-api"
      ProtocolType: HTTP
      CorsConfiguration:
        AllowOrigins:
          - !Ref AllowedOrigin
        AllowMethods:
          - POST
        AllowHeaders:
          - content-type
        MaxAge: 3600

  ContactIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref ContactApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt ContactFunction.Arn
      PayloadFormatVersion: "2.0"
      TimeoutInMillis: 10000

  ContactRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref ContactApi
      RouteKey: POST /api/contact
      AuthorizationType: NONE
      Target: !Sub "integrations/${ContactIntegration}"

  ContactStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref ContactApi
      StageName: "$default"
      AutoDeploy: true
      DefaultRouteSettings:
        DetailedMetricsEnabled: false
        ThrottlingRateLimit: !Ref ThrottlingRateLimit
        ThrottlingBurstLimit: !Ref ThrottlingBurstLimit

  ContactInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref ContactFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub >-
        arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ContactApi}/*/POST/api/contact

Outputs:
  ContactApiEndpoint:
    Description: Direct API endpoint for testing.
    Value: !Join
      - ""
      - - !GetAtt ContactApi.ApiEndpoint
        - /api/contact

  ContactApiOriginDomain:
    Description: Domain name to register as the CloudFront custom origin.
    Value: !Sub >-
      ${ContactApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}

デプロイ時は、次の値を自分の環境に合わせて変更します。

  • SesIdentityDomain: SESで検証済みのドメイン
  • FromEmail: SESから送信するメールアドレス
  • ToEmail: お問い合わせの受信先
  • AllowedOrigin: フォームを設置するサイトのオリジン
aws cloudformation deploy \
  --region ap-northeast-1 \
  --stack-name example-contact-form \
  --template-file template.yml \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    SesIdentityDomain=example.com \
    FromEmail=no-reply@example.com \
    ToEmail=receive@example.net \
    AllowedOrigin=https://example.com

デプロイ後は、CloudFormationの出力にあるContactApiEndpointからAPIのURLを確認できます。

CloudFrontを経由させる場合は、ContactApiOriginDomainをカスタムオリジンとして追加します。

まとめ

今回は、API Gateway、Lambda、SESを使ったお問い合わせフォームを、CloudFormationで構築する方法について紹介しました。

入力値の確認や送信元の制限、APIのスロットリングを設定することで、単純にメールを送るだけよりも安全に運用できます。

静的サイトでお問い合わせ機能が必要になった時は、ぜひ参考にしてみてください。