Amazon SES × AWS Lambdaでサーバーレスメール自動返信!お問い合わせフォーム実装ガイド

プロフィール画像

大西薫

2024年12月15日

リンクをコピーXでシェアするfacebookでシェアする

この記事は「BEMALab アドベントカレンダー 2024Open in new tab」15日目の記事です。

はじめに 

はじめまして、メンバーズルーツカンパニーの大西です。

お問い合わせフォームの自動返信機能を実装するには、バックエンド側で処理を行うことやサードパーティサービスを利用することが一般的に考えられますが、本記事ではAmazon Simple Email Service (SES)、AWS Lambda、およびAmazon API Gatewayを活用したサーバーレスなアーキテクチャで自動返信機能を実装する方法を紹介します。この方法で実装することで、サーバーレスに実装することでサーバーの管理コストが発生しないことや、サードパーティサービスを導入するよりも自由度の高いお問い合わせフォームを実装することが可能になるといったメリットがあります。

本記事の構成は下記の通りです。

IAM ポリシーと実行ロールの作成
Amazon SESの設定
Lambda関数の実装
API GatewayでAPIの作成
フロントエンドの実装
CORSの有効化

IAMポリシーと実行ロールの作成

まずはじめに、LambdaでAmazon SESのAPIを利用してメール送信を実行できるようにするため、IAMポリシーと実行ロールの作成を行います。

AWSのコンソールにサインインし下記の通りに進み、設定を行います。
IAM → ポリシー → ポリシーの作成

ポリシーエディタ:ビジュアル
サービスを選択:SES v2
アクション許可:書き込み→SendEmail
リソース:すべて

「次へ」ボタンを押し、任意のポリシー名を入力します。今回は「test-ses-lambda」というポリシー名で作成しました。オプションで必要な設定があれば適宜追加してください。
以上でポリシーの作成は完了です。

続いてLambdaに割り当てる実行ロールを作成し、先ほど作成したIAMポリシー「test-ses-lambda」をアタッチします。こちらもIAMコンソールから設定します。
IAM → ロール → ロールを作成

信頼されたエンティティタイプ:AWSのサービス
ユースケース:Lambda

「次へ」ボタンを押し、許可ポリシーで「test-ses-lambda」を選択します。
追加でLambda関数の実行ログをCloudWatchで確認できるようにするため、AWSLambdaBasicExecutionRoleという名前のポリシーも選択します。AWSLambdaBasicExecutionRoleはログをCloudWatchにアップロードすることを許可するポリシーになります。
選択したら「次へ」ボタンを押し、任意のロール名を設定します。
「許可ポリシーの概要」で「test-ses-lambda」と「AWSLambdaBasicExecutionRole」が追加されていることを確認し、「ロールを作成」ボタンを押します。これでIAMポリシーと実行ロールの作成は完了です。

Amazon SESの設定

続いて、SESの設定を行います。
SESでメール送信を行うためにはIDを設定する必要があり、IDはドメインもしくはEメールアドレスで作成することが可能です。簡単な方法はEメールアドレスでIDを作成し検証することですが、EメールアドレスIDでは送信ドメイン認証(SPFおよびDKIMの設定)ができずDMARC認証を機能させることができません。そのため、メールサービスによっては送信したメールが受信拒否されることや迷惑メールに振り分けられることになる可能性があります。

ドメインを所有していないので今回はEメールアドレスIDでの検証を行いますが、実際にSESを利用する際はドメインIDを検証するようにしましょう。(今回はGmailを使って検証したため、送信メールは迷惑メールに振り分けられました。)
SESのドメインIDとEメールアドレスIDの違いやDKIMの設定についてはこちらOpen in new tab、DMARC認証についてはこちらOpen in new tabをご確認ください。

下記の通り設定していきます。
Amazon SES → ID → IDの作成

SESはデフォルトでサンドボックス環境で動作するようになっており、使用についていくつかの制限があります。サンドボックス環境ではメールの送信先は検証済みのメールアドレスとドメインもしくはAmazon SESメールボックスシミュレーターに限定されているため、送信用と受信用の2つのメールアドレスを設定することが望ましいです(送信と受信を同一のメールアドレスで行うこともできます)。
その他のサンドボックス環境での制限や本番稼働への移行方法はこちらOpen in new tabをご覧ください。

IDタイプ:Eメールアドレス
Eメールアドレス:使用するEメールアドレス

「IDを作成」ボタンを押すと、入力したメールアドレスに確認メールが届くので、メール本文のURLをクリックしメールアドレスを認証します。

設定: ID画面で追加したメールアドレスのIDステータスが「検証済み」になっていたらSESの設定は完了です。(受信用と送信用のメールアドレスを分ける場合は別のメールアドレスでもう一度この作業を行ってください。)

Lambda関数の実装

Lambdaにアクセスし「関数の作成」ボタンを押します。
下記の通りに設定します。

オプション:一から作成
関数名:任意
ランタイム:Node.js 20.x
アーキテクチャ:x86_64
デフォルトの実行ロールの変更→既存のロールを使用する→先ほど作成したロールを選択

今回はNode.jsのバージョン20を使用します。「関数を作成」ボタンを押し、下記のコードを実装します。


import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const ses = new SESClient({ region: "ap-northeast-1" });

export const handler = async (event) => {
  console.log(event);
  const { email, message } = event;  // お問い合わせ内容からメールアドレスとメッセージを取得

  const command = new SendEmailCommand({
      Destination: {
        ToAddresses: [email],
      },
      Message: {
        Body: {
          Text: { Data: `お問い合わせありがとうございます。\n\nメッセージ: ${message}` },
        },
  
        Subject: { Data: "お問い合わせを受け付けました。" },
      },
      Source: "ここにSESで検証した送信用のメールアドレスを入力",
  });

  try {
    let response = await ses.send(command);
    return response;
  }
  catch (error) {
    // error handling.
    console.log(error);
  }
};

こちらのコードはNode.jsのバージョン18以降の書き方になります。regionは適宜ご自身のものに変更し、SourceはSESで検証した送信用のメールアドレスを入力してください。
この関数ではお問い合わせフォームから送信されたメールアドレスとお問い合わせ内容を受け取り、受け取ったメールアドレスに対してメールを送信しています。

こちらの関数が想定通りに動くかLambdaでテストを行います。
テストタブから「新しいイベントを作成」もしくは「保存されたイベントを編集」を選択し、イベントJSONに下記を設定します。emailはSESで検証済みの受信用のメールアドレス、messageは任意の値を設定してください。

{"email":"〇〇@test.com","message":"〇〇についてお問い合わせします"}

テストを保存し実行します。ログでStatus: Succeededと出ればLambda関数の処理は成功です。
同時にメールが受信されているか確認してください。
※送信ドメイン認証をしていないので今回使用したGmailでは迷惑メールとして受信されます。

API GatewayでAPIの作成

Lambda関数が正しく実行されることが確認できたので、次はAPIの作成を行います。
作成したLambda関数の設定画面から「トリガーを追加」ボタンを押し、下記の通り設定します。

ソースを選択:API Gateway
インテント:新規APIを作成
APIタイプ:REST API
セキュリティ:オープン

「追加の設定」からAPI名やデプロイされるステージは任意の値を設定し、「追加」ボタンを押します。
LambdaにAPI Gatewayのトリガーが追加され、APIエンドポイントが発行されます。

フロントエンド側の実装

続いて、フロントエンド側でお問い合わせフォームを作成し、先ほど作成したAPIを経由してLambdaにデータを送信する処理を実装していきます。
例として、今回はAstroの新規プロジェクトでお問い合わせフォームを作成します。
下記コマンドを実行し、Astroプロジェクトを作成します。今回はTypeScriptで記述していきます。

npm create astro@latest

Astroのバージョン4.16で作成されました。
まずはルートディレクトリに.envファイルを作成し、先ほど作成したAPIのエンドポイントを記載します。

PUBLIC_API_GATEWAY_ENDPOINT=ここにAPIエンドポイントのURLを記載

src/pages/index.astroファイルを下記に修正します。

<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>お問い合わせフォーム</title>
  </head>
  <body>
    <h1>お問い合わせ</h1>
    <form id="contactForm">
      <label for="email">メールアドレス</label>
      <input id="email" type="email" name="email" placeholder="メールアドレス" required />
      <label for="message">お問い合わせ内容</label>
      <textarea id="message" name="message" placeholder="お問い合わせ内容" required></textarea>
      <button type="submit">送信</button>
    </form>
  </body>
</html>

<script>
  const API_GATEWAY_ENDPOINT = import.meta.env.PUBLIC_API_GATEWAY_ENDPOINT; // API Gatewayのエンドポイントを指定
  
  document.addEventListener('DOMContentLoaded', () => {
    const form = document.getElementById('contactForm') as HTMLFormElement;
    form?.addEventListener('submit', async(e: Event) => {
      e.preventDefault();

      const email = (form.querySelector('input[name="email"]') as HTMLInputElement).value;
      const message = (form.querySelector('textarea[name="message"]') as HTMLTextAreaElement).value;

      const data = { email, message }; // データをオブジェクトとしてまとめる

      // fetchでPOSTリクエストを送信後、画面遷移させて結果を表示
      try {
        const response = await fetch(API_GATEWAY_ENDPOINT, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(data)
        });

        if (!response.ok) {
          throw new Error(`HTTPエラー: ${response.status}`);
        }

        window.location.assign('/thank-you'); // 送信後にサンクスページに遷移
      } catch (error) {
        console.error('フォーム送信中にエラーが発生しました:', error);
        alert('メッセージの送信に失敗しました。もう一度お試しください。');
      }
    })
  })
</script>

<style>
  body {
    background-color: #f3f4f6;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin: 0 auto;
    padding: 2rem;
    max-width: 1280px;
    color: #0f1014;
  }
  #contactForm {
    display: flex;
    flex-direction: column;
    max-width: 400px;
    width: 100%;
    margin: auto;
  }

  label {
    font-size: 16px;
    font-weight: bold;
    margin-bottom: 5px;
  }

  input, textarea {
    margin-bottom: 10px;
    padding: 10px;
    font-size: 16px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }

  button {
    padding: 10px;
    font-size: 16px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  button:hover {
    background-color: #0056b3;
  }
</style>

また、お問い合わせを受け付けたことを表示するためのページであるthank-you.astroをsrc/pages配下に新規作成し下記の通り記述します。

<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>お問い合わせを受け付けました</title>
  </head>
  <body>
    <div class="box">
      <h2>お問い合わせを受け付けました</h2>
      <p>ご連絡ありがとうございます。<br>お返事までしばらくお待ちください。</p>
    </div>
  </body>
</html>

<style>
  body {
    background-color: #f3f4f6;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin: 0 auto;
    padding: 2rem;
    max-width: 1280px;
    color: #0f1014;
  }
  .box {
    background-color: white; 
    border-radius: 0.5rem; 
    box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); 
    padding: 2rem; 
    max-width: 400px; 
    width: 100%; 
    text-align: center; 
    margin: auto;
  }
  h2 {
    font-size: 1.5rem; 
    font-weight: bold; 
    color: #2d3748; 
    margin-bottom: 1rem; 
  }
  p {
    color: #4a5568; 
  }
</style>

フォーム送信時にfetch APIのPOSTメソッドで、環境変数で設定したエンドポイントに対して入力されたデータを送信します。リクエストが成功した場合は上記のthank-you.astroページに遷移し、お問い合わせが正しく送信されたことを表示します。

なお今回は触れていませんが、バリデーション処理、やサニタイズ処理、CSRF(クロスサイトリクエストフォージェリ)対策などのセキュリティ面を考慮した実装をする必要があります。
下記コマンドでローカル環境を立ち上げ、お問い合わせが正しく送信されるか確認します。

npm run dev

メールアドレスにはSESで検証済みの受信用のメールアドレス、お問い合わせ内容は任意の値を入力して送信します。
しかし、APIエンドポイントに対してlocalhost:4321という異なるオリジンからリクエストを行ったため、CORSポリシーによりリクエストがブロックされてしまいます。この問題を解決するためにAPI GatewayとLambda側でCORSを有効化しAccess-Control-Allow-Originヘッダーを正しく設定する必要があります。

CORSの有効化

下記のようなクロスオリジンHTTPリクエストである場合、CORSを有効にする必要があります。

  • 別のドメイン (例: example.com から amazondomains.com へ)
  • 別のサブドメイン (例: example.com から petstore.example.com へ)
  • 別のポート (例: example.com から example.com:10777 へ)
  • 別のプロトコル (例: https://example.com から http://example.com へ)

今回はAPI Gatewayのエンドポイント(https://〇〇〇.execute-api.ap-northeast-1.amazonaws.com/default/testLambdaSES)に対してlocalhost:4321という別のドメインからリクエストを行うためCORSを有効にする必要があります。
また、CORSを有効にするための対応は条件により異なります。そのため下記の2点を確認する必要があります。

①HTTPリクエストがシンプルリクエストであるか
②シンプルリクエストではない場合、API Gatewayの統合タイプはプロキシ統合と非プロキシ統合どちらであるか

①まず、HTTPリクエストがシンプルなリクエストであるか確認する必要があります。以下の条件がすべて当てはまる場合、シンプルなHTTPリクエストであり、それ以外は全てシンプルではないリクエストになります。

  • GET、HEAD、および POST のリクエストのみを許可する API リソースに対して発行されます。
  • それが POST メソッドリクエストの場合、Origin ヘッダーを含める必要があります。
  • リクエストのペイロードコンテンツタイプが text/plain、multipart/form-data、または application/x-www-form-urlencoded の場合。
  • リクエストにカスタムヘッダーが含まれていません。
  • シンプルなリクエストに関するMozilla CORSのドキュメントOpen in new tabに一覧表示されている追加要件。

今回はリクエストのペイロードコンテンツタイプがapplication/jsonであるため、シンプルではないリクエストということになります。

②さらに、シンプルリクエストではない場合はAPI Gatewayの統合タイプに応じてCORSサポートを有効にする必要があります。
API Gatewayにはプロキシ統合と非プロキシ統合という主要な2つの統合タイプがあり、これらはリクエストを処理する方法に影響します。今回はLambdaプロキシ統合であり、Lambdaプロキシ統合ではAPI GatewayがリクエストをそのままLambdaへ転送し、Lambdaからのレスポンスもそのままクライアントに返されます。また、Lambda関数の出力形式は下記の通りである必要があります。

{
    "isBase64Encoded": true|false,
    "statusCode": httpStatusCode,
    "headers": { "headerName": "headerValue", ... },
    "multiValueHeaders": { "headerName": ["headerValue", "headerValue2", ...], ... },
    "body": "..."
}

Lambdaプロキシ統合の詳細についてはこちらOpen in new tabをご覧ください。

上記の2点が条件である場合、LambdaがAccess-Control-Allow-Originヘッダー、Access-Control-Allow-Methodsヘッダー、Access-Control-Allow-Headersヘッダーを返す必要があります。
これらを踏まえてLambda関数を下記の通りに修正しデプロイします。

import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const ses = new SESClient({ region: "ap-northeast-1" });

export const handler = async (event) => {
  const { email, message } = JSON.parse(event.body)

  const command = new SendEmailCommand({
      Destination: {
        ToAddresses: [email],
      },
      Message: {
        Body: {
          Text: { Data: `お問い合わせありがとうございます。\n\nメッセージ: ${message}` },
        },
  
        Subject: { Data: "お問い合わせを受け付けました。" },
      },
      Source: "onishi.kaoru.3325@members.co.jp",
  });

  try {
    let response = await ses.send(command);

    // CORSヘッダーを含むレスポンスを返す
    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*', // オリジンを指定
        'Access-Control-Allow-Methods': 'POST, OPTIONS', // 許可するHTTPメソッド
        'Access-Control-Allow-Headers': 'Content-Type', // 許可するヘッダー
      },
      body: JSON.stringify({
        message: 'お問い合わせが送信されました。',
        response: response, // SESからのレスポンス
      }),
    };
  }
  catch (error) {
    // エラーレスポンスを返す
    return {
      statusCode: 500,
      headers: {
        'Access-Control-Allow-Origin': '*', // オリジンを指定
        'Access-Control-Allow-Methods': 'POST, OPTIONS', // 許可するHTTPメソッド
        'Access-Control-Allow-Headers': 'Content-Type', // 許可するヘッダー
      },
      body: JSON.stringify({
        message: 'エラーが発生しました。',
        error: error.message,
      }),
    };
  }
};

JSON文字列として受け取ったデータをJSON.parse()でオブジェクト形式に変換しています。
さらに、上記のLambda関数の出力形式に沿ってレスポンスを修正し、Access-Control-Allow-Originヘッダー、Access-Control-Allow-Methodsヘッダー、Access-Control-Allow-Headersヘッダーをレスポンスに追加します。

ここまで設定し再度お問い合わせを送信しても、リクエストはまだCORSポリシーによりブロックされてしまいます。

実は、シンプルリクエストではない場合において、実際のHTTPリクエストを送信する前にブラウザが自動的にOPTIONSメソッドによるプリフライトリクエストをサーバー側に送信し、サーバーがそのリクエストを許可するかどうかを確認しています。そのためAPI GatewayでCORSを有効にし、プリフライトリクエストが正しく処理されるようにする必要があります。
API Gatewayのコンソール画面から正しいリソースを選択し、「CORSを有効にする」ボタンを押します。

入力値は特に変更せずにそのまま保存すると、OPTIONSというメソッドが追加されます。

最後にLambdaでOPTIONSメソッドのリクエストを処理する実装を追加しデプロイします。


import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const ses = new SESClient({ region: "ap-northeast-1" });

export const handler = async (event) => {
  // OPTIONSメソッドのリクエストを処理
  if (event.httpMethod === 'OPTIONS') {
    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*', // オリジンを指定
        'Access-Control-Allow-Methods': 'POST, OPTIONS', // 許可するHTTPメソッド
        'Access-Control-Allow-Headers': 'Content-Type', // 許可するヘッダー
      },
      body: JSON.stringify({ message: 'CORS preflight response' }),
    };
  }

  const { email, message } = JSON.parse(event.body)

  const command = new SendEmailCommand({
      Destination: {
        ToAddresses: [email],
      },
      Message: {
        Body: {
          Text: { Data: `お問い合わせありがとうございます。\n\nメッセージ: ${message}` },
        },
  
        Subject: { Data: "お問い合わせを受け付けました。" },
      },
      Source: "onishi.kaoru.3325@members.co.jp",
  });

  try {
    let response = await ses.send(command);

    // CORSヘッダーを含むレスポンスを返す
    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*', // オリジンを指定
        'Access-Control-Allow-Methods': 'POST, OPTIONS', // 許可するHTTPメソッド
        'Access-Control-Allow-Headers': 'Content-Type', // 許可するヘッダー
      },
      body: JSON.stringify({
        message: 'お問い合わせが送信されました。',
        response: response, // SESからのレスポンス
      }),
    };
  }
  catch (error) {
    // エラーレスポンスを返す
    return {
      statusCode: 500,
      headers: {
        'Access-Control-Allow-Origin': '*', // オリジンを指定
        'Access-Control-Allow-Methods': 'POST, OPTIONS', // 許可するHTTPメソッド
        'Access-Control-Allow-Headers': 'Content-Type', // 許可するヘッダー
      },
      body: JSON.stringify({
        message: 'エラーが発生しました。',
        error: error.message,
      }),
    };
  }
};

お問い合わせフォーム画面に戻り、お問い合わせを送信します。
「お問い合わせを受け付けました」ページに遷移し、メールが問題なく受信できていれば完了です。

おわりに

以上が、Amazon SES、AWS Lambda、Amazon API Gatewayを活用したお問い合わせフォームのメール自動返信機能を実装するための一連のプロセスです。この記事がSESの導入やLambda・API Gatewayの実装に役立つことを願っています。

参考

この記事を書いた人

大西薫
大西薫
2022年メンバーズ入社。メンバーズルーツカンパニー所属。 現在はJamstack案件を中心に業務を行っています。
ページトップへ戻る