Help us understand the problem. What is going on with this article?

AWS APIGateway/LambdaとJavascriptで簡易問い合わせサイトをつくる

概要

問い合わせフォーム(javascript) ⇒ APIGateway ⇒ Lambda(Node.js) ⇒ Lambda(Node.js) の流れで簡単な問い合わせサイトを作ります。
contact_form.png
一応レスポンシブにします。
contact_form2.png
バリデーションもあります。
contact_form3.png
contact_form4.png

index.htmlのコーディング

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  <link rel="stylesheet" href="style.css" />
  <title>問い合わせ</title>
</head>

<body>
  <div class="header">
    <a href="#" class="logo">Hoge Hoge Company</a>
    <a class="active" href="#">Home</a>
  </div>

  <main class="main-container">
    <h2>問い合わせフォーム</h2>
    <div class="container">
      <form id="form">
        <div class="row">
          <div class="col-20">
            <label for="name">名前</label>
          </div>
          <div class="col-80">
            <input type="text" id="name" name="name" placeholder="Your name.." />
          </div>
        </div>
        <div id="name-alert" class="alert-message" hidden>入力値が不正です。</div>
        <div class="row">
          <div class="col-20">
            <label for="email">E-Mail</label>
          </div>
          <div class="col-80">
            <input type="email" id="email" name="email" placeholder="Your E-Mail.." />
          </div>
        </div>
        <div id="email-alert" class="alert-message" hidden>入力値が不正です。</div>
        <div class="row">
          <div class="col-20">
            <label for="dept">所属</label>
          </div>
          <div class="col-80">
            <input type="text" id="dept" name="dept" placeholder="Ex: Example Co., Ltd." />
          </div>
        </div>
        <div id="dept-alert" class="alert-message" hidden>入力値が不正です。</div>
        <div class="row">
          <div class="col-20">
            <label for="body">内容</label>
          </div>
          <div class="col-80">
            <textarea id="body" name="body" placeholder="お気軽にお問い合わせください。" style="height:200px"></textarea>
          </div>
        </div>
        <div id="body-alert" class="alert-message" hidden>入力値が不正です。</div>
        <div class="row">
          <button id="submitBtn">送信</button>
        </div>
      </form>
    </div>

    <!-- The Modal -->
    <div id="myModal" class="modal">
      <!-- Modal content -->
      <div id="modal-content">
        <span class="close">&times;</span>
        <p id="result"></p>
        <p id="detail" style="font-size: small;"></p>
      </div>
    </div>
  </main>
  <script src="main.js" defer></script>
</body>
</html>

CSSのコーディング

style.css
* {
  margin: 0;
  padding: 0;
  font-family: "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", "メイリオ",
    Meiryo, "MS Pゴシック", sans-serif;
}

/* Style the header with a grey background and some padding */
.header {
  display: flex;
  flex-flow: row wrap;
  align-items: center;
  justify-content: space-between;
  background-color: #000000;
  padding: 20px 10px;
}

/* Style the header links */
.header a {
  color: #f2f2f2;
  text-align: center;
  padding: 12px;
  text-decoration: none;
  font-size: 18px;
  line-height: 25px;
  border-radius: 4px;
}

/* Style the logo link (notice that we set the same value of line-height and font-size to prevent the header to increase when the font gets bigger */
.header a.logo {
  font-size: 25px;
  font-weight: bold;
}

/* Change the background color on mouse-over */
.header a:hover {
  background-color: #ddd;
  color: black;
}

/* Style the active/current link*/
.header a.active {
  background-color: dodgerblue;
  color: white;
}

/* Add media queries for responsiveness - when the screen is 500px wide or less, stack the links on top of each other */
@media screen and (max-width: 500px) {
  .header {
    justify-content: center;
  }
  .header a {
    padding: 12px;
  }
}

.main-container {
  width: 90%;
  margin: auto;
}
.main-container h2 {
  margin-top: 12px;
}

/* Style inputs, select elements and textareas */
input,
textarea {
  width: 100%;
  padding: 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
  resize: vertical;
}

.alert-red {
  border: #ff4500 2px solid;
}

.alert-green {
  border: #00ff7f 2px solid;
}

.alert-message {
  color: #ff4500;
  font-size: small;
  text-align: end;
  margin-bottom: 10px;
}

/* Style the label to display next to the inputs */
label {
  padding: 12px 12px 12px 0;
  display: inline-block;
}

/* Style the submit button */
#submitBtn {
  background-color: #4caf50;
  color: white;
  padding: 10px 18px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 15px;
  float: right;
}

/* Style the container */
.container {
  border-radius: 5px;
  background-color: #f2f2f2;
  padding: 20px;
}

/* Floating column for labels: 25% width */
.col-20 {
  float: left;
  width: 20%;
  margin-top: 6px;
}

/* Floating column for inputs: 75% width */
.col-80 {
  float: left;
  width: 80%;
  margin-top: 6px;
}

/* Clear floats after the columns */
.row:after {
  content: "";
  display: table;
  clear: both;
}

/* Responsive layout - when the screen is less than 600px wide, make the two columns stack on top of each other instead of next to each other */
@media screen and (max-width: 600px) {
  .col-20,
  .col-80,
  #submitBtn {
    width: 100%;
    margin-top: 0;
  }
}

/* The Modal (background) */
.modal {
  display: none; /* Hidden by default */
  position: fixed; /* Stay in place */
  z-index: 1; /* Sit on top */
  left: 0;
  top: 0;
  width: 100%; /* Full width */
  height: 100%; /* Full height */
  overflow: auto; /* Enable scroll if needed */
  background-color: rgb(0, 0, 0); /* Fallback color */
  background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
}

/* Modal Content/Box */
#modal-content {
  background-color: #fefefe;
  margin: 15% auto; /* 15% from the top and centered */
  padding: 20px;
  width: 80%; /* Could be more or less, depending on screen size */
}

.request-loading {
  border: 1px solid #888;
}

.request-success {
  border: 1px solid #00ff7f;
}

.request-fail {
  border: 1px solid #ff4500;
}

/* The Close Button */
.close {
  color: #aaa;
  float: right;
  font-size: 28px;
  font-weight: bold;
}

.close:hover,
.close:focus {
  color: black;
  text-decoration: none;
  cursor: pointer;
}

javascriptのコーディング

変数URLはAPIGatewayで発行してから実装します。

main.js
const URL = 'https://'; // APIGatewayで作成したURL

const LOADING = '処理中...';
const RESULT_OK = 'リクエストを受け付けました。';
const RESULT_OK_DETAIL =
  "正常に処理が完了すると 'support@hogehoge.com' からメールが配信されます。";
const RESULT_NG = 'リクエストの受付に失敗しました';
const RESULT_NG_DETAIL =
  '大変申し訳ありません。担当者へ直接お問い合わせください。';
const DOMAIN = '@sample.com';
document.getElementById('email').value = DOMAIN;

(() => {
  const modal = document.getElementById('myModal');
  const modalContent = document.getElementById('modal-content');
  const sendBtn = document.getElementById('submitBtn');
  const span = document.getElementsByClassName('close')[0];
  const result = document.getElementById('result');
  const form = document.getElementById('form');
  const nameAlert = document.getElementById('name-alert');
  const emailAlert = document.getElementById('email-alert');
  const deptAlert = document.getElementById('dept-alert');
  const bodyAlert = document.getElementById('body-alert');
  const resultDetail = document.getElementById('detail');

  const inputValueClear = () => {
    form.name.value = '';
    form.name.setAttribute('class', '');
    form.email.value = DOMAIN;
    form.email.setAttribute('class', '');
    form.dept.value = '';
    form.dept.setAttribute('class', '');
    form.body.value = '';
    form.body.setAttribute('class', '');
    result.innerHTML = '';
  };

  // When the user clicks on <span> (x), close the modal
  span.onclick = () => {
    if (result.innerHTML !== LOADING) {
      modal.style.display = 'none';
      if (result.innerHTML === RESULT_OK) inputValueClear();
    }
  };

  // When the user clicks anywhere outside of the modal, close it
  window.onclick = event => {
    if (event.target === modal) {
      if (result.innerHTML !== LOADING) {
        modal.style.display = 'none';
        if (result.innerHTML === RESULT_OK) inputValueClear();
      }
    }
  };

  sendBtn.addEventListener('click', async event => {
    event.preventDefault();
    const name = form.name.value.trim();
    form.name.value = name;
    const email = form.email.value.trim();
    form.email.value = email;
    const dept = form.dept.value.trim();
    form.dept.value = dept;
    const body = form.body.value.trim();
    form.body.value = body;

    // validation check
    if (validation(name, email, dept, body)) return;

    result.innerHTML = LOADING;
    modalContent.setAttribute('class', 'request-loading');
    modal.style.display = 'block';
    const jsonData = JSON.stringify({ name, email, dept, body });

    try {
      const res = await fetch(URL, {
        method: 'POST',
        mode: 'cors',
        cache: 'no-cache',
        body: jsonData,
        headers: {
          'Content-Type': 'application/json; charset=utf-8'
        }
      });
      console.log('Response!!', res.status);
      modalContent.setAttribute('class', 'request-success');
      result.innerHTML = RESULT_OK;
      resultDetail.innerHTML = RESULT_OK_DETAIL;
    } catch (error) {
      console.log(error);
      modalContent.setAttribute('class', 'request-fail');
      result.innerHTML = RESULT_NG;
      resultDetail.innerHTML = RESULT_NG_DETAIL;
    }
  });

  const validation = (name, email, dept, body) => {
    let validationResult = false;
    if (!name.length) {
      form.name.setAttribute('class', 'alert-red');
      nameAlert.hidden = false;
      validationResult = true;
    } else {
      form.name.setAttribute('class', 'alert-green');
      nameAlert.hidden = true;
    }
    if (!email.length || !/^[^@]+@sample.com$/.test(email)) {
      form.email.setAttribute('class', 'alert-red');
      emailAlert.hidden = false;
      validationResult = true;
    } else {
      form.email.setAttribute('class', 'alert-green');
      emailAlert.hidden = true;
    }
    if (!dept.length) {
      form.dept.setAttribute('class', 'alert-red');
      deptAlert.hidden = false;
      validationResult = true;
    } else {
      form.dept.setAttribute('class', 'alert-green');
      deptAlert.hidden = true;
    }
    if (!body.length) {
      form.body.setAttribute('class', 'alert-red');
      bodyAlert.hidden = false;
      validationResult = true;
    } else {
      form.body.setAttribute('class', 'alert-green');
      bodyAlert.hidden = true;
    }
    return validationResult;
  };

  form.name.addEventListener('input', event => {
    const name = event.target.value.trim();
    if (!name) {
      event.target.setAttribute('class', 'alert-red');
      nameAlert.hidden = false;
    } else {
      event.target.setAttribute('class', 'alert-green');
      nameAlert.hidden = true;
    }
  });

  form.email.addEventListener('input', event => {
    const email = event.target.value.trim();
    event.target.value = email;
    if (!email || !/^[^@]+@sample.com$/.test(email)) {
      event.target.setAttribute('class', 'alert-red');
      emailAlert.hidden = false;
    } else {
      event.target.setAttribute('class', 'alert-green');
      emailAlert.hidden = true;
    }
  });

  form.dept.addEventListener('input', event => {
    const dept = event.target.value.trim();
    event.target.value = dept;
    if (!dept) {
      event.target.setAttribute('class', 'alert-red');
      deptAlert.hidden = false;
    } else {
      event.target.setAttribute('class', 'alert-green');
      deptAlert.hidden = true;
    }
  });

  form.body.addEventListener('input', event => {
    const body = event.target.value.trim();
    if (!body) {
      event.target.setAttribute('class', 'alert-red');
      bodyAlert.hidden = false;
    } else {
      event.target.setAttribute('class', 'alert-green');
      bodyAlert.hidden = true;
    }
  });
})();

AWS SES(Simple Email Service)を設定

※ドメインはRoute53で取得済みの前提
※併せてACM(AWS Certificate Manager)で証明書を取得済み
※SESは送信のみの設定です。

  1. AWSへログイン
  2. SESのコンソールへ移動
  3. リージョンはバージニア北部を選択
  4. 左のナビゲーションからDomainsを選択
    image.png
  5. image.png をクリック
  6. ↓のように入力。自分のドメインとDKIM設定をチェックして、image.pngをクリックimage.png
  7. 次のモーダルではCNAMEやTXTが表示され、登録しろと指示が出る。Route53を利用している場合は、このモーダル上でDNSの登録がすべて完了する。
  8. こんな感じになればOK!(※現時点ではサンドボックス上での制限された利用が可能)
    image.png
  9. 左のナビゲーションでEmail Addressesを選択
    image.png
  10. Veryfy a New Email Addressでサンドボックス上で利用できるメールアドレスを登録する。
  11. 登録したメールアドレスをSend a Test Emailで登録したドメインからメールが送られるかテストして正常な動作を確認

最後に起動するLambdaを作成

  1. Lambdaのコンソールページへ移動
  2. image.png をクリック
  3. image.png を選択
  4. image.png 適当な関数名を入力
  5. ランタイムはNode.js 12.xを選択
  6. そのほかはデフォルトのままでOK
  7. image.png をクリック
  8. 環境変数へ配信元のアドレスを設定
    image.png
  9. 実行ロールにはSESの権限を許可
    image.png
  10. 関数コードへ↓のコードを実装
index.js
'use strict'
const SES = require("aws-sdk/clients/ses");
const ses = new SES({ region: "us-east-1" }); // 米国東部(バージニア北部)
const FROM = process.env.FROM; // 環境変数から取得

exports.handler = async (event) => {
    console.log(event);
    const TO = [event.email];
    const params = {
        Destination: {
            ToAddresses: TO
        },
        Message: {
            Body: {
                Text: {
                    Data: [
                        event.dept + ' ' + event.name + '',
                        ' ',
                        'Hoge Hoge Companyです。',
                        'お問い合わせしていただきありがとうございます。',
                        '下記の内容で承りました。',
                        ' ',
                        '[お問い合わせ内容]' + "\n" + event.body,
                    ].join("\n"),
                    Charset: "utf-8"
                }
            },
            Subject: {
                Data: '受付完了:Webからの問い合わせ',
                Charset: "utf-8"
            }
        },
        // From
        Source: FROM
    };

    const result = {statusCode: 200};
    try {
        const response = await ses.sendEmail(params).promise();
        console.log('Response: ', response);
        result.body = 'OK!!!!!';
    } catch (error) {
        console.log('Error: ', error);
        result.statusCode = error.code;
        result.body = error.message;
    }
    return result;
};

process.envでは環境変数で設定したキーと値が利用可能(暗号化も可能)

最後に起動するLambdaのテスト

  1. image.png テストイベントの選択をクリック
  2. ↓のようにテストを作成(※emailキーにはSESで登録したアドレスを記載すること)
    image.png
  3. image.png をクリックしてテスト実行
  4. 成功となればOK。失敗ならログを確認。
    image.png
  5. テストでemailキーに指定したアドレスにメールが配信されていればOK

最初に起動するLambdaを作成

  1. Lambdaのコンソールページへ移動
  2. image.png をクリック
  3. image.png を選択
  4. image.png 適当な関数名を入力
  5. ランタイムはNode.js 12.xを選択
  6. そのほかはデフォルトのままでOK
  7. image.png をクリック
  8. 環境変数へ配信元のアドレスと受付先のアドレスを入力
    image.png
  9. 実行ロールはSESとLambdaの権限を追加
    image.png
  10. 関数コードへ↓のコードを実装
index.js
'use strict'
const SES = require("aws-sdk/clients/ses");
const ses = new SES({ region: "us-east-1" }); // 米国東部(バージニア北部)
const TO = [process.env.TO]; // 環境変数からの値を取得
const FROM = process.env.FROM;

// 次のLambdaを起動するための設定
const Lambda = require("aws-sdk/clients/lambda");
const lambda = new Lambda({ region: "ap-northeast-1" });

exports.handler = async (event) => {
    const name = event.form.name;
    const email = event.form.email;
    const dept = event.form.dept;
    const body = event.form.body;
    const sesParams = {
        Destination: {
            ToAddresses: TO
        },
        Message: {
            Body: {
                Text: {
                    Data: [
                        '[名前] : ' + name,
                        '[メールアドレス] : ' + email,
                        '[所属部署] : ' + dept,
                        '[お問い合わせ] : ' + "\n" + body,
                    ].join("\n"),
                    Charset: "utf-8"
                }
            },
            Subject: {
                Data: 'Webからの問い合わせ',
                Charset: "utf-8"
            }
        },
        // From
        Source: FROM
    };

    // 次のLambdaに送るデータ
    const payload = { name, email, dept, body };
    console.log('payload: ', payload);
    const lambdaParams = {
        FunctionName: "inpuirySendMailForm_2nd", // 最後に起動するLambdaの名称
        InvocationType: "Event",
        Payload: JSON.stringify(payload)
    };

    const result = {statusCode: 200};
    try {
        const response = await ses.sendEmail(sesParams).promise();
        console.log('Response: ', response);
        const callLambda = await lambda.invoke(lambdaParams).promise();
        console.log("Lambda Response: ", callLambda);
        result.body = 'OK!!!!!';
    } catch (error) {
        console.log('Error: ', error);
        result.statusCode = error.code;
        result.body = error.message;
    }
    return result;

};

最初に起動するLambdaのテスト(次のLambdaも起動する)

  1. image.png テストイベントの選択をクリック
  2. ↓のようにテストを作成
    image.png
  3. image.png をクリックしてテスト実行
  4. 成功となればOK。失敗ならログを確認。
    image.png
  5. テストでemailキーに指定したアドレスに受付完了メールが配信され、環境変数でTOに指定した受付先アドレスへ問い合わせ内容が配信されていればOK

APIGatewayの設定

  1. image.pngをクリック
  2. REST API構築を選択
    image.png
  3. API名説明を入力してAPIの作成をクリック(他はデフォルト)
    image.png
  4. アクション⇒リソースの作成を選択⇒リソース名を入力(リソースパスは自動入力)⇒CORSを有効化⇒リソースの作成をクリック
  5. アクション⇒メソッドの作成⇒POSTを選択⇒セットアップで最初に起動するLambda関数名を入力⇒保存をクリック
  6. 統合リクエストをクリック⇒マッピングテンプレートを追加⇒Content-Typeはapplication/json⇒テンプレートには↓を入力⇒保存をクリック
{
    "form": {
        "name":  "$util.escapeJavaScript($input.path('$.name'))",
        "email": "$util.escapeJavaScript($input.path('$.email'))",
        "dept":  "$util.escapeJavaScript($input.path('$.dept'))",
        "body":  "$util.escapeJavaScript($input.path('$.body'))"
    }
}

APIGatewayのテスト

  1. テストを実行する
    image.png
  2. Lambdaが起動してメールが配信されればOK

APIGatewayのステージを作成

今回はv1というステージを作成

APIGatewayのデプロイ

リソース⇒アクション⇒APIのデプロイを選択⇒v1ステージを選択して⇒デプロイをクリック

発行されたURLをjavascriptのURL変数へ割り当て

全体の動作確認

問い合わせフォームへSESで登録したアドレスと必要事項を入力し、送信ボタンをクリックして動作を確認

実用化に向けて

  • LambdaのSDK呼び出しは動作確認ができているバージョンが呼び出されるようにする必要あり。
  • SESの上限緩和申請が必要。
  • APIGatewayでリソースポリシーを指定して不要なアクセスを防ぐ

最後に

かなり大雑把に書きました。
小規模利用なら十分かと思います。
役に立つかな?
計画性なく作ってしまったのでここからブラッシュアップ

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away