ENGINEERING ARTICLE
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のスロットリングを設定することで、単純にメールを送るだけよりも安全に運用できます。
静的サイトでお問い合わせ機能が必要になった時は、ぜひ参考にしてみてください。