WordPressでお問い合わせフォームを実装する|プラグインなしでreCAPTCHAも導入

WordPressでお問い合わせフォームを「Contact Form 7」などのプラグインを使わずに実装してみます。

シンプルに考えた場合、問い合わせたい人が入力するものは「名前」と「メールアドレス」と「内容」だけでいいはず。

なので、取得するデータも3つだけ。

たったこれだけを取得するだけですから、プラグインを使わずにjavascriptとPHPで実装してみました。

これで、全てのページに「Contact Form 7」のcssとjsファイルが読み込まれてしまうことを回避できます。また、「reCAPTCHA」の評価対象もお問い合わせフォームを実装したページのみになります。

結構簡単でしたので、備忘録として残します。

なお、当方はその道のプロではありませんのでご注意ください。

目次
    スポンサーリンク

    SMTPの設定

    まずは、SMTPの設定を行います。

    昨今、「なりすましメール」や「フィッシングメール」が問題化してきているため、メールを取り巻く環境が一段と厳しくなっています。特にGmailは厳しい・・・

    問い合わせ者が送信ボタンを押すと、問い合わせ者と運営サイドにメールを自動送信しますので最低でもSMTPの設定はしておきます。

    回答は別のメールソフトを使って送信する場合でも、そもそも運営サイドにお問い合わせがあったことを知らせるメールが来ないと意味ないですから。

    SMTPは、メールをより確実に、より安全に届けてくれます。

    さらに、SPF、DKIM、DMARCも設定しておくと完璧です。

    詳しくは、別記事で。

    お問い合わせフォームの実装

    次のようにシンプルに実装します。

    1. 入力欄は「お名前」「メールアドレス」「お問い合わせ内容」のみ。
    2. お問い合わせ内容の確認ページは実装しない。
      ➡画面遷移させない
    3. 送信ボタンを押すと1秒間ローディング画面を表示させる。
      ➡これがないと何度も送信ボタンを押されてしまう
    4. 完了ページも使わない。
      ➡画面遷移させずに送信に成功・エラーでメッセージをモーダル表示
    5. 自動返信メールを運営サイドと問い合わせ者に送る。
      ➡WordPress の wp_mail() を使ってメール送信
    6. セキュリティ対策
      ➡Googleの「reCAPTCHA v3」を導入してスパムボットの自動送信を防ぐ
      ➡入力値のサニタイズ処理(WordPress関数)でHTMLタグなどの不正挿入を防ぐ
      ➡メールヘッダインジェクション対策でメールの乗っ取りや不正送信を防ぐ
      ➡送信回数制限をかけ、30秒以内の再送信をブロックする
    7. 必須項目に未入力がないかチェックをする。
      ➡未入力箇所があれば今のブラウザではこの時点で送信できないが、JSで補完的に入れておく
      ➡未入力箇所があればalert表示で警告
    8. 問い合わせ者のIPアドレスを取得する。
      ➡直接的には個人を特定できないが、個人情報に準ずる扱いとしプライバシーポリシーに取得と目的を表示させること
      ➡運営サイドへのメールにこの情報を入れる

    完成のイメージです。(送信ボタンを押しても何もなりません)

    スポンサーリンク

    reCAPTCHA v3の導入

    reCAPTCHAを使用するために、「サイトキー」と「シークレットキー」を用意。

    さらにGoogleCloudにまだ移行していなかったので、重い腰を上げて移行しました。

    無料のまま使いたいので「課金システム」は無効状態です。

    なので、無料分を超過するとその月はサービスが使えなくなりますが、私の小規模サイトであればまず超えることはないでしょう。

    reCAPTCHAは10,000件の評価までは無料で使えますが、それを超えて使いたい場合は有料になります。

    それを越えても無料のものを使いたい場合は他のCaptchaサービスを利用したほうがいいので、これ以降の内容を読んでも意味がありません。最初からreCAPTHAを導入しない方がいいです。

    hCaptcha」「Friendly Captcha」「Cloudflare Turnstile」などを探して導入してください。

    まずはGoogleのreCAPTCHAのスクリプトタグを読み込む必要があるので、次のコードをカスタムHTMLブロックで入れます。

    ただし、カスタムHTMLブロックで入れるのはWordPress非推奨ですので、マネしない方がいいです。

    推奨されているのは、wp_enqueue_scriptsフックを使用してfunctions.phpに書き込んで読み込ませる方法です。

    <script src="https://www.google.com/recaptcha/api.js?render=あなたのサイトキー"></script>

    Font Awesomeを読み込む

    次にFont Awesomeを読み込みます。

    2つのアイコン(上半身の人・手紙)を使いたいので読み込みます。別になくても支障ありません。

    デザイン上取り入れているだけです。次のコードをカスタムHTMLブロックで入れます(非推奨)。

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">

    html

    その道のプロではないので、適当に作っています。

    プレースホルダーのテキストやモーダルウィンドウのメッセージなど、自分好みです。

    アイコンを使うので、class="inputIcon"と<i>タグを入れています。

    表示させたい場所に、カスタムHTMLブロックで入れます。

    <div class="form-box">
      <form class="mailform">
        <div class="inputIcon">
          <input type="text" name="nameval" id="nameval" class="input_box" placeholder="お名前" required>
          <i class="fa-solid fa-user fa-fw" aria-hidden="true"></i>
        </div>
        <div class="space20"></div>
        <div class="inputIcon">
          <input type="email" name="email" id="mailval" class="input_box" placeholder="メールアドレス" required>
          <i class="fa-solid fa-envelope fa-fw" aria-hidden="true"></i>
        </div>
        <div class="space20"></div>
        <textarea name="message" id="message" class="input_box" placeholder="お問い合わせの内容" required></textarea>
        <div class="space20"></div>
        <label for="check" class="check">
            <input id="check" type="checkbox" name="check" required>入力内容を確認しました
        </label>
        <button type="submit" id="submit" class="sousin_button" ontouchstart="">送信</button>
      </form>
    </div>
    <div id="loading-wrapper">
      <div id="loading-text">送信中</div>
      <div id="loading-content"></div>
    </div>
    <div class="popup-success">
      <div class="popup-inner"> 
        <span class="close">&times;</span>
        <div class="comment1">受け付けました!</div>
        <div class="comment2">お返事までしばらくお待ちください。</div>
      </div>
    </div>
    <div class="popup-error">
      <div class="popup-inner"> 
        <span class="close">&times;</span>
        <div class="comment1">送信に失敗しました!</div>
        <div class="comment2">時間をおいて再度お試しください。</div>
      </div>
    </div>

    css

    これまた、その道のプロではないので適当に作っています。

    色やサイズなどのスタイルも、ハッキリ言って自分好みです。

    該当ページだけなので、<style>タグで囲んでカスタムHTMLブロックで入れました。

    本当は、子テーマのstyle.cssに追加するとか、wp_enqueue_scriptsフックを使用して処理するのがベストなんでしょうが、なぜか面倒くさいと思ってしまいます。

    ここら辺が、その道のプロではないです。

    p {
      margin: 0;
    }
    .space20 {
      margin-top: 20px;
    }
    .form-box {
      width: 95%;
      max-width: 600px;
      margin: 0 auto;
      display: block;
    }
    .mailform {
      display: flex;
      flex-flow: column;
    }
    input[type='text'],
    input[type='email'],
    textarea.input_box {
      font-size: 17px;
      box-sizing: border-box;
      border: 1px solid #0082ED;
      border-radius: 6px;
      background: #f7fcfe;
      font-weight: 400;
      font-family: "Noto Sans JP", sans-serif;
      color: #160c28;
    }
    input[type='text'].input_box {
      width: 70%;
      max-width: 250px;
      height: 40px;
      padding: 0 10px;
    }
    .inputIcon input[type="text"].input_box {
      padding-left: 40px;
    }
    input[type='email'] {
      width: 100%;
      max-width: 350px;
      height: 40px;
      padding: 0 10px;
    }
    .inputIcon input[type="email"] {
      padding-left: 40px;
    }
    textarea.input_box {
      resize: none;
      width: 100%;
      height: 200px;
      padding: 10px;
    }
    input[type='text']::placeholder,
    input[type='email']::placeholder,
    textarea.input_box::placeholder {
      font-size: 15px;
      font-family: "Noto Sans JP", sans-serif;
      color: #71686c;
    }
    input[type='text']:focus,
    input[type='email']:focus,
    textarea.input_box:focus {
      outline: 0;
      background: #fff;
      border: 1px solid #63d62d;
      box-shadow: 0 0 10px 4px rgb(99 214 45 / .5)
    }
    label.check {
      margin: 0 auto;
      font-size: 16px;
      font-weight: 400;
      font-family: "Noto Sans JP", sans-serif;
      cursor: pointer;
      -webkit-user-select: none;
      user-select: none;
    }
    /* 送信ボタン */
    .sousin_button {
      display: flex;
      width: 96px;
      height: 40px;
      margin: 20px auto;
      padding: 0;
      justify-content: center;
      align-items: center;
      font-size: 17px;
      font-weight: 600;
      font-family: "Noto Sans JP", sans-serif;
      text-shadow: 1px 1px 1px rgb(77 77 77 / .7);
      border-radius: 5px;
      border: 0;
      color: #ffffff;
      background: #45B173;
      cursor: pointer;
      box-shadow: 0 4px 0 #19934e;
      user-select: none;
    }
    .sousin_button:active {
      transform: translate(0, 3px);
      background: #45B173;
      box-shadow: 0 0 0 #19934e;
    }
    .inputIcon {
      position: relative;
      display: flex;
      align-items: center;
    }
    .inputIcon i {
      position: absolute;
      color: #0082ED;
      font-size: 26px;
      transition: color 0.3s ease;
    }
    .inputIcon .input_box:focus + i,
    .inputIcon .input_box:focus ~ i {
      color: #98d98e;
    }
    /* 送信中 */
    #loading-wrapper {
      background: rgb(0 0 0 / .6);
      position: fixed;
      width: 100%;
      height: 100%;
      left: 0;
      top: 0;
      z-index: 999;
      display: none;
    }
    #loading-text {
      display: block;
      position: absolute;
      top: 50%;
      left: 50%;
      color: white;
      width: 100px;
      height: 30px;
      margin: -7px 0 0 -45px;
      text-align: center;
      font-family: 'PT Sans Narrow', sans-serif;
      font-size: 16px;
    }
    #loading-content {
      display: block;
      position: relative;
      left: 50%;
      top: 50%;
      width: 130px;
      height: 130px;
      margin: -65px 0 0 -65px;
      border: 3px solid #F00;
    }
    #loading-content:after {
      content: "";
      position: absolute;
      border: 3px solid #0F0;
      left: 15px;
      right: 15px;
      top: 15px;
      bottom: 15px;
    }
    #loading-content:before {
      content: "";
      position: absolute;
      border: 3px solid #00F;
      left: 5px;
      right: 5px;
      top: 5px;
      bottom: 5px;
    }
    #loading-content {
      border: 3px solid transparent;
      border-top-color: #4D658D;
      border-bottom-color: #4D658D;
      border-radius: 50%;
      -webkit-animation: loader 2s linear infinite;
      -moz-animation: loader 2s linear infinite;
      -o-animation: loader 2s linear infinite;
      animation: loader 2s linear infinite;
    }
    
    #loading-content:before {
      border: 3px solid transparent;
      border-top-color: #D4CC6A;
      border-bottom-color: #D4CC6A;
      border-radius: 50%;
      -webkit-animation: loader 3s linear infinite;
        -moz-animation: loader 2s linear infinite;
      -o-animation: loader 2s linear infinite;
      animation: loader 3s linear infinite;
    }
    #loading-content:after {
      border: 3px solid transparent;
      border-top-color: #84417C;
      border-bottom-color: #84417C;
      border-radius: 50%;
      -webkit-animation: loader 1.5s linear infinite;
      animation: loader 1.5s linear infinite;
        -moz-animation: loader 2s linear infinite;
      -o-animation: loader 2s linear infinite;
    }
    @-webkit-keyframes loaders {
      0% {
        -webkit-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    @keyframes loader {
      0% {
        -webkit-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    /* モーダルウィンドウ */
    .popup-success, .popup-error {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgb(0 0 0 / .5);
      justify-content: center;
      align-items: center;
      z-index: 99;
      overflow: hidden;
    }
    .popup-inner {
      position: relative;
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      text-align: center;
      width: 90%;
      max-width: 400px;
      height: 50%;
      background: white;
      padding: 10px 0;
      border: 3px solid #997526;
      border-radius: 20px;
    }
    .popup-inner .close {
      position: absolute;
      display: flex;
      align-items: center;
      justify-content: center;
      top: 5px;
      right: 5px;
      height: 30px;
      width: 30px;
      font-size: 18px;
      font-weight: 600;
      background: #ffec47;
      border: 1px solid #006e54;
      border-radius: 50%;
      color: #00a381;
      cursor: pointer;
      z-index: 999;
    }
    .comment1 {
      font-size: 23px;
      font-weight: 600;
      color: #264B99;
      font-family: "Noto Sans JP", sans-serif;
      margin: 10px 0;
    }
    .comment2 {
      font-size: 16px;
      font-weight: 400;
      color: #160c28;
      font-family: "Noto Sans JP", sans-serif;
      margin-top: 10px;
    }

    javascript

    以下のコードで実装しました。recaptchaのサイトキー、PHPファイルのアクセスURLは隠しています。

    なお、サイトキーは公開されても問題ないので、フロントエンド側(HTML・javascript)に置いています。

    1秒間のローディング画面を表示させて、成功したらモーダルウィンドウ(.popup-success)を表示。スパム判定されたらアラート表示。送信に失敗したら失敗のモーダルウィンドウ(.popup-error)を表示。

    該当ページだけなので、これもまた面倒くさくて<script>タグで囲んでカスタムHTMLブロックでページの最後に入れました(WordPress非推奨)。

    document.querySelector(".mailform").addEventListener("submit", function(event) {
      event.preventDefault();
    
      const loadingWrapper = document.getElementById("loading-wrapper");
      const popupSuccess = document.querySelector(".popup-success");
      const popupError = document.querySelector(".popup-error");
    
      loadingWrapper.style.display = "flex";
    
      setTimeout(() => {
        const formData = new FormData(event.target);
    
        // 必須チェック
        const name = formData.get("nameval");
        const email = formData.get("email");
        const message = formData.get("message");
    
        if (!name || !email || !message) {
          loadingWrapper.style.display = "none";
          alert("全ての項目を入力してください。");
          return;
        }
    
        grecaptcha.ready(function() {
          grecaptcha.execute('recaptchaのサイトキー', { action: 'submit' }).then(function(token) {
            formData.append("recaptcha", token);
    
            fetch("PHPファイルのアクセスURL", {
              method: "POST",
              body: formData
            })
            .then(response => response.json())
            .then(data => {
              loadingWrapper.style.display = "none";
              if (data.status === "success") {
                popupSuccess.style.display = "flex";
              } else if (data.message === "スパム判定されました。") {
                alert(data.message);
              } else {
                popupError.style.display = "flex";
              }
            })
            .catch(error => {
              loadingWrapper.style.display = "none";
              popupError.style.display = "flex";
            });
          });
        });
      }, 1000);
    });
    
    // モーダルウィンドウを閉じる処理
    document.querySelectorAll('.popup-success .close, .popup-error .close').forEach(el => {
      el.addEventListener('click', () => {
        el.closest('.popup-success, .popup-error').style.display = 'none';
      });
    });
    document.addEventListener('click', (e) => {
      if (e.target.classList.contains('popup-success') || e.target.classList.contains('popup-error')) {
        e.target.style.display = 'none';
      }
    });
    
    // Enterキーでも送信可能
    document.querySelectorAll('.mailform input, .mailform textarea').forEach(input => {
      input.addEventListener('keypress', function(e) {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          document.querySelector(".mailform").dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
        }
      });
    });

    PHPファイル

    メールを送るためのPHPファイルを作成します。コードは下記のとおり。

    reCAPTCHA のシークレットキーなどは隠しています(×××のところ)。

    このファイルをテーマ直下に置いたのではなく、サーバールート直下に作成しているディレクトリ「PHP」に置いています。

    メールが運営サイドと問い合わせ者に送れられたら 'success'をjsに返し、スパム判定だったらその旨を返し、メール送信に失敗したら'error'を返します。

    問い合わせ者のIPアドレスを取得して運営サイドへ知らせるようにしています。取得しなくても問題ありませんが、スパムや悪質な問い合わせへの対応のために一応取得しておきます。

    なお、取得する情報については、プライバシーポリシーに取得と目的の明示が必須となります。

    <?php
    // WordPress の環境をロード
    require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
    session_start();
    
    // ヘッダー設定
    header("Access-Control-Allow-Origin: https://××××××.com/");
    header('Content-Type: application/json; charset=UTF-8');
    header("Access-Control-Allow-Headers: Content-Type");
    date_default_timezone_set('Asia/Tokyo');
    
    // reCAPTCHA のシークレットキー
    define('RECAPTCHA_SECRET_KEY', '××××××');
    
    // 送信元のメールアドレス
    $admin_email = '××××××@××××××.com';
    $from_email = '××××××@××××××.com';
    
    // IPアドレス取得
    function getUserIP() {
        if (!empty($_SERVER['HTTP_CLIENT_IP'])) return $_SERVER['HTTP_CLIENT_IP'];
        if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
        return $_SERVER['REMOTE_ADDR'];
    }
    $ip_address = getUserIP();
    
    // メールヘッダインジェクション対策
    function clean_input($data) {
        return preg_replace('/[\r\n]+/', '', trim($data));
    }
    
    // フォームデータ取得&サニタイズ
    $name = isset($_POST['nameval']) ? sanitize_text_field(clean_input($_POST['nameval'])) : '';
    $email = isset($_POST['email']) ? sanitize_email(clean_input($_POST['email'])) : '';
    $message = isset($_POST['message']) ? sanitize_textarea_field($_POST['message']) : '';
    $recaptcha_response = isset($_POST['recaptcha']) ? $_POST['recaptcha'] : '';
    
    // 送信回数制限(30秒以内の連続送信をブロック)
    if (!isset($_SESSION['last_send'])) {
        $_SESSION['last_send'] = time();
    } else {
        if (time() - $_SESSION['last_send'] < 30) {
            echo json_encode(['status' => 'error', 'message' => '短時間に複数の送信はできません。']);
            exit;
        }
        $_SESSION['last_send'] = time();
    }
    
    // reCAPTCHA 検証
    $recaptcha_data = [
        'secret' => RECAPTCHA_SECRET_KEY,
        'response' => $recaptcha_response,
        'remoteip' => $ip_address
    ];
    
    $options = [
        'http' => [
            'method' => 'POST',
            'header' => 'Content-type: application/x-www-form-urlencoded',
            'content' => http_build_query($recaptcha_data)
        ]
    ];
    $context = stream_context_create($options);
    $recaptcha_result = json_decode(file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context), true);
    
    // スパム判定
    if (!$recaptcha_result['success']) {
        echo json_encode(['status' => 'error', 'message' => 'スパム判定されました。']);
        exit;
    }
    
    // 運営サイドへの自動返信メール
    $admin_subject = "お問い合わせがありました";
    $admin_message = <<<EOM
    $name 様より以下の内容でお問い合わせがありました。
    
    名前: $name
    メール: $email
    IPアドレス: $ip_address
    
    お問い合わせ内容:
    $message
    EOM;
    
    $headers = [
        "From: $from_email",
        "Reply-To: $email",
        "Content-Type: text/plain; charset=UTF-8"
    ];
    
    // 問い合わせ者への自動返信メール
    $user_subject = "お問い合わせを受け付けました";
    $user_message = <<<EOM
    ※このメールはシステムからの自動返信です
    
    $name 様
    
    この度は、㈱××××へお問い合わせいただきありがとうございます。
    お問い合わせの件につきましては、近日中にお返事いたしますので今しばらくお待ちください。
    
    ------- お問い合わせ内容 -------
    $message
    --------------------------------
    ㈱××××
    TEL:××××
    EOM;
    
    $user_headers = [
        "From: $from_email",
        "Content-Type: text/plain; charset=UTF-8"
    ];
    
    // メール送信
    $admin_mail_sent = wp_mail($admin_email, $admin_subject, $admin_message, $headers);
    $user_mail_sent = wp_mail($email, $user_subject, $user_message, $user_headers);
    
    // 結果
    if ($admin_mail_sent && $user_mail_sent) {
        echo json_encode(['status' => 'success']);
    } else {
        echo json_encode(['status' => 'error']);
    }
    exit;