PHP Logo PHP セキュアなメールフォームの作り方

セキュアで拡張性の高いお問い合わせフォームを、HTML・PHP・JavaScript を使って一から実装する方法を解説します。CSRF対策やバリデーション、reCAPTCHA 対応、自動返信機能など、実用的なフォームに必要な機能を網羅したサンプルコード付きです。

関連ページ:

更新日:2025年06月07日

作成日:2020年3月27日

全面的に内容を更新しました(2025年05月31日)。

以下では PHPとHTMLを用いた安全で実用的なコンタクトフォームの作り方を解説します。CSRFトークンによる不正送信対策、入力チェック、メール送信処理、自動返信、reCAPTCHA v2/v3対応など、実際の現場で役立つテクニックを多数紹介しています。

HTML5 の検証機能を使ったフォーム

最初のサンプルは、HTML5 の検証機能と PHP を使って入力値を検証してメールを送信する基本的なコンタクトフォームです。

HTML5 のフォームの検証機能を使っているので HTML5 に対応していないブラウザ(caniuse.com)では送信前の検証は機能しませんが、PHP でサーバー側の検証を行います。

項目を入力して送信ボタンをクリックすると、HTML5 のフォームの検証機能を使って入力内容を検証します。問題がなければメールを送信し、送信が成功すれば完了画面を表示し、失敗すればエラーを表示します。デフォルトでは完了画面に入力内容を表示し、自動返信メールを送信します(変更可能)。

以下のサンプルは動作確認用で、実際にメールは送信されません。

上記サンプルは iframe で読み込んでいるため、うまく機能しない場合は以下のリンクから動作サンプルを別ページで確認することができます。

動作サンプルを別ページで確認する

ファイル構成

以下はこの例のお問い合わせページのファイル構成です。

ファイル名やディレクトリ名、フォルダ構成などは必要に応じて変更してください。

├── assets
│   └── css
│       └── contact-style.css
├── contact
│   ├── contact.php   // コンタクトページ(お問い合わせフォーム)
│   └── complete.php  // 完了ページ(完了画面)
└── includes
    ├── .htaccess
    ├── helpers.php
    ├── index.php
    └── mail_config.php

includes フォルダには、関数を記述した helpers.php と メール送信で使用する情報(メールアドレスなど)を定義した mail_config.php を作成し、セキュリティ用に空の index.php と外部からアクセスできないように.htaccess を配置します。

.htaccess

includes フォルダに外部からアクセスできないように以下を記述します。

Require all denied

Apache 2.2 以前の場合は、以下になります。

deny from all
helpers.php

helpers.php には contact.php や complete.php で HTMLエスケープ処理や入力値の検証などに使用する関数を定義しておきます。

  • h() 関数:htmlspecialchars() を使ったエスケープ処理を行う関数
  • checkInput() 関数:入力値に不正なデータがないか安全性検査を行う関数
  • validate_mail_config() 関数:mail_config.php に定義された定数の存在確認と値の検証を行う関数
<?php
if (!function_exists('h')) {
  /**
   * エスケープ処理を行う関数
   *
   * @param string|array|null  $var チェックする文字列
   */
  function h($var) {
    if (is_array($var)) {
      //$varが配列の場合、h()関数をそれぞれの要素について呼び出す(再帰)
      return array_map('h', $var);
    } else {
      if ($var === null) return ''; // PHP 8.1.x 対策(null を渡すと Deprecated エラー)
      return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
    }
  }
}

if (!function_exists('checkInput')) {
  /**
   * 入力値に不正なデータがないかなどをチェックする関数
   *
   * @param string|array  $var チェックする文字列
   */
  function checkInput($var) {
    if (is_array($var)) {
      return array_map('checkInput', $var);
    } else {
      // NULLバイト(\0)攻撃対策
      if (preg_match('/\0/', $var)) {
        die('不正な入力です。');
      }
      // 文字エンコードのチェック
      if (!mb_check_encoding($var, 'UTF-8')) {
        die('不正な入力です。');
      }
      // 改行、タブ以外の制御文字のチェック([:^cntrl:] は「制御文字以外」を意味するPOSIX文字クラス)
      if (preg_match('/\A[\r\n\t[:^cntrl:]]*\z/u', $var) === 0) {
        die('不正な入力です。制御文字は使用できません。');
      }
      return $var;
    }
  }
}

if (!function_exists('validate_mail_config')) {
  /**
   * mail_config.php に定義された定数を検証する。
   *
   * @param array $required_emails 必須のメールアドレス定数
   * @param array $optional_emails 任意のメールアドレス定数
   * @param array $required_names  必須の名前定数
   * @param array $optional_names  任意の名前定数
   * @param array $required_keys   その他必須の定数(空文字でないことをチェック)
   */
  function validate_mail_config(
    array $required_emails,
    array $optional_emails,
    array $required_names,
    array $optional_names,
    array $required_keys = []
  ) {
    $errors = [];

    // 必須メール定数のチェック
    foreach ($required_emails as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (!filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // オプションメール定数のチェック(定義されていればチェック)
    foreach ($optional_emails as $const) {
      if (defined($const) && !filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // 必須名前定数のチェック(改行禁止)
    foreach ($required_names as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行が含まれています。";
      }
    }

    // オプション名前定数のチェック
    foreach ($optional_names as $const) {
      if (defined($const) && preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行文字が含まれています。";
      }
    }

    // その他の必須定数(空文字でないこと)
    foreach ($required_keys as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (trim(constant($const)) === '') {
        $errors[] = "$const が空です。";
      }
    }

    // エラー表示
    if (!empty($errors)) {
      echo '<h3 style="color:red;">mail_config.php に不正な定義があります:</h3>';
      echo '<ul>';
      foreach ($errors as $error) {
        echo '<li style="color:red;">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</li>';
      }
      echo '</ul>';
      exit('mail_config.php の内容を修正してください。');
    }
  }
}

function_exists()

複数ファイルで helpers.php を読み込む可能性がある場合や関数名が衝突する可能性がある場合は、function_exists() で関数が定義済みかどうかを確認するのが安全です。

index.php(セキュリティ対策用)

includes に配置した index.php は外部からのディレクトリアクセス防止(リスティング防止)のためのインデックスファイルです。

この index.php があることで、/includes/ にアクセスされたとき、サーバーは index.php を読み込みますが、中身が空なので何も表示されず、結果として、ファイルの一覧や中身が見えないようにできます。

<?php
// Silence is golden. あえて何も表示しないのが安全
mail_config.php

メール送信に使用する定数(メールアドレスや制御フラグ)などを定義するファイルです。

宛先(To)と送信元(From)、及び Return-Path は必須です。

3-7行目の記述は、万一このファイルにアクセスされた場合に、アクセスを拒否するためのものです。

<?php
// このファイルは単体でアクセスされた場合には何も表示しない
if (basename($_SERVER['PHP_SELF']) === basename(__FILE__)) {
  // 直接アクセスされたときの処理(例:403 Forbiddenで終了)
  http_response_code(403);
  exit('Forbidden');
}
// メールの宛先(To)のメールアドレス
define('MAIL_TO', 'info@example.com');
// メールの宛先(To)の名前
define('MAIL_TO_NAME', 'Example');
// メールの送信元(From)のメールアドレス
define('MAIL_FROM', 'info@example.com');
// メールの送信元(From)の名前
define('MAIL_FROM_NAME', 'Example');
// Return-Path に指定するメールアドレス(メール送信が失敗したときにエラーメールを受け取るアドレス)
define('MAIL_RETURN_PATH', 'info@example.com');

// 入力されたデータを完了画面に表示するかどうか
define('SHOW_INPUT_VALUES_WITH_RESULT', true);
// 自動返信メールを送信するかどうか
define('AUTO_REPLY_ENABLED', true);
// 自動返信の返信先名前(AUTO_REPLY_ENABLED を true にして自動返信する場合は必須)
define('AUTO_REPLY_NAME', '自動返信 Example');

// *** 以下はオプション(必要に応じて設定)****
// Cc のメールアドレス
define('MAIL_CC', 'cc@examplex.com');
// Cc の名前
define('MAIL_CC_NAME', 'Ccの宛先名');
// Bcc のメールアドレス
define('MAIL_BCC', 'bcc@example.com');
contact-style.css

以下は contact.php 及び complete.php で読み込むスタイルシートです(適宜変更してください)。

body {
  font-family: "Helvetica Neue", Arial, sans-serif;
  margin: 0;
  padding: 40px 20px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.container.contact-page {
  background: #fff;
  padding: 30px 40px;
  max-width: 600px;
  width: 100%;
}

.container.contact-page h1, .container.contact-page h2 {
  margin-bottom: 1rem;
  color: #333;
}

.container.contact-page h1 {
  font-size: 24px;
}

.container.contact-page h2 {
  font-size: 20px;
}

.container.contact-page p {
  font-size: 14px;
  margin-bottom: 30px;
  color: #666;
}

.contact-form > div {
  margin-bottom: 20px;
}

.contact-form label {
  display: block;
  font-weight: bold;
  margin-bottom: 8px;
  color: #333;
}

.contact-form input[type="text"],
.contact-form input[type="email"],
.contact-form input[type="tel"],
.contact-form textarea {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.3s, box-shadow 0.3s;
}

.contact-form input:focus,
.contact-form textarea:focus {
  border-color: #007bff;
  box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2);
  outline: none;
}

.contact-form textarea {
  resize: vertical;
  min-height: 120px;
}

.form-button {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 12px 24px;
  font-size: 16px;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.form-button:hover {
  background-color: #0056b3;
}

.container.contact-page .error-php, .container.contact-page .error-js {
  display: block;
  color: #d9534f;
  font-size: 13px;
  margin-top: 4px;
}

/* 完了ページ用スタイル */
table.result {
  width: 100%;
  border-collapse: collapse;
  margin: 20px 0;
  font-size: 14px;
}

table.result th,
table.result td {
  padding: 12px 14px;
  border: 1px solid #ddd;
  text-align: left;
  vertical-align: top;
}

table.result th {
  background-color: #f0f0f0;
  font-weight: bold;
  width: 30%;
  color: #333;
}

table.result td {
  background-color: #fafafa;
  color: #444;
}

/* 画面幅が小さくなった場合は、縦に並べる */
@media screen and (max-width: 640px) {
  table.result td, table.result th{
    display: block;
    width: 100%;
  }
  table.result td {
    border-top: none;
    border-bottom: none;
  }
}

p.success {
  background-color: #e6f4ea;
  color: #2e7d32;
  padding: 10px 14px;
  border-radius: 6px;
  font-size: 14px;
}

p.fail {
  background-color: #fcebea;
  color: #c9302c;
  padding: 10px 14px;
  border-radius: 6px;
  font-size: 14px;
}

contact.php

以下は contact.php の PHP 部分の抜粋です。

このコードはお問い合わせフォーム(HTML 部分の form 要素)から送られた情報を検証し、メールとして送信する処理です。

<?php
session_start(); // セッション開始(CSRFトークンに必要)

// クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
header("X-Frame-Options: SAMEORIGIN");  // 古いブラウザ(IE11など)にも対応
header("Content-Security-Policy: frame-ancestors 'self';"); // 新しいブラウザ向けの設定

//エスケープ処理やデータチェックを行う関数のファイルの読み込み
require '../includes/helpers.php';

// 送信先メールアドレス等を定義したファイル mail_config.php の読み込み
require '../includes/mail_config.php';

// メール送信に使用する mail_config.php で定義されている定数の存在チェックと値の検証
validate_mail_config(
  // 必須のメールアドレス定数
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  // オプションのメールアドレス定数
  ['MAIL_CC', 'MAIL_BCC'],
  // 必須の名前定数
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  // オプションの名前定数
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME']
);

// CSRFトークンの生成(GET時など最初の表示)
if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

//POST データの安全性をチェック
$_POST = checkInput($_POST);

// 安全な $_POST から変数に代入
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

// POST リクエストで且つフォームの送信ボタンが押された場合の処理
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  // CSRFトークンのチェック
  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  //エラーメッセージを保存する配列の初期化
  $error = array();

  // 名前のバリデーション
  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
    //制御文字でないことと文字数をチェック
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    // 改行文字が含まれていたらエラー(ヘッダーインジェクション対策)
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  // メールアドレスのバリデーション
  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    // 改行文字が含まれていたらエラー(ヘッダーインジェクション対策)
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  // 電話番号のバリデーション(任意)ハイフンを一旦除去してから桁数をチェック
  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  // 件名のバリデーション
  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
    //制御文字でないことと文字数をチェック
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  // 内容のバリデーション(タブ・改行は許容)
  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
    //制御文字(タブ、復帰、改行を除く)でないことと文字数をチェック
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  // バリデーションエラーがなければ送信処理を実行
  if (empty($error)) {

    // mbstring 設定
    mb_language('uni');
    mb_internal_encoding('UTF-8');

    // 宛先
    $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";

    // Return-Path に指定するメールアドレス
    $returnMail = MAIL_RETURN_PATH;

    // ヘッダー
    $header  = "MIME-Version: 1.0\n";
    $header .= "Content-Type: text/plain; charset=UTF-8\n";
    $header .= "Content-Transfer-Encoding: 8bit\n";
    $header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
    $header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
    // Cc の設定(名前とアドレス両方が定義されている場合のみ)
    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . " <" . MAIL_CC . ">\n";
    }
    // Bcc の設定(アドレスが定義されている場合のみ)
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $header .= "Bcc: <" . MAIL_BCC . ">\n";
    }

    //メール本文の組み立て
    $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
    $mail_body .=  "お名前: " . h($name) . "\n";
    $mail_body .=  "Email: " . h($email) . "\n";
    $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
    $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

    // メール送信(結果を変数 $result に代入)
    if (ini_get('safe_mode')) {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
    } else {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
    }

    //メールが送信された場合の処理
    if ($result) {

      // メール送信成功時にセッションIDを再生成(セッション固定攻撃対策)
      session_regenerate_id(true);

      //自動返信メール
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {

        // mbstring 設定(管理者宛てと共通設定にする)
        mb_language('uni');
        mb_internal_encoding('UTF-8');

        // ヘッダー
        $reply_header  = "MIME-Version: 1.0\n";
        $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
        $reply_header .= "Content-Transfer-Encoding: 8bit\n";
        $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
        $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";

        // 件名
        $reply_subject = "【{$name}様】お問い合わせありがとうございます";
        // お問い合わせ日時(本文内で使用)
        $week = ['日', '月', '火', '水', '木', '金', '土'];
        $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
        // 本文
        $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
        // メール送信
        if (ini_get('safe_mode')) {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header);
        } else {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header, '-f' . $returnMail);
        }

        // 自動返信メールの送信結果っをセッションに保持
        $_SESSION['reply_result'] = $reply_result;
      }

      // 入力内容をセッションに保持
      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      // トークンは使い捨てにする(再送信防止)
      unset($_SESSION['csrf_token']);

      //空の配列を代入し、すべてのPOST変数を消去
      $_POST = array();

      //完了画面へリダイレクト
      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    // 最後に、トークンがなければ再生成(フォームの再表示などで)
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>

以下はコードの説明です。

セッションの開始

session_start(); // セッションを開始(CSRF対策用トークンの保存に必要)

セッションを開始することで、後で使用する CSRF トークンの保存が可能になります。

クリックジャッキング対策ヘッダの出力

// クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
header("X-Frame-Options: SAMEORIGIN");  // 古いブラウザ(IE11など)にも対応
header("Content-Security-Policy: frame-ancestors 'self';"); // 新しいブラウザ向けの設定

クリックジャッキング対策用に X-Frame-Options と Content-Security-Policy ヘッダフィールドを出力し、他ドメインのサイトからの frame 要素や iframe 要素による読み込みを制限します。

X-Frame-Options は古い仕様であり、Content-Security-Policy の frame-ancestors ディレクティブに置き換わりつつありますが、現行の主要ブラウザでは今も X-Frame-Options は有効に機能します。

【注意点】このファイルでは X-Frame-Options や Content-Security-Policy を個別に指定していますが、.htaccess ですでに同じヘッダーが適切に設定されている場合は、上記の header() は不要です。

初期設定の読み込み

require '../includes/helpers.php'; // 入力チェックやエスケープ用の関数群
require '../includes/mail_config.php'; // メール送信先や送信元を定義
  • helpers.php ではバリデーションやサニタイズに必要な関数が定義されています。
  • mail_config.php ではメール送信に必要な設定値(宛先や送信者など)を定数として管理しています。

メール設定のバリデーション

validate_mail_config(
  // 必須のメールアドレス定数
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  // オプションのメールアドレス定数
  ['MAIL_CC', 'MAIL_BCC'],
  // 必須の名前定数
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  // オプションの名前定数
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME']
);

helpers.php で定義した関数 validate_mail_config() を使って、メール送信に必要な定数(宛先・送信者・Return-Pathなど)が mail_config.php で定義されているか、正しい形式かをチェックします。

Cc/Bcc や自動返信用の返信名などはオプションですが、設定されていれば形式チェックが行われます。

CSRFトークンの生成・確認

// CSRFトークンの生成(GET時など最初の表示)
if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];
  • フォームからの不正アクセス防止のため、CSRF トークンを生成してセッションに保存します。
  • 生成した CSRF トークンは HTML で type="hidden" を設定した input 要素に埋め込みます。
  • フォーム送信時にはこのトークンを確認し、不一致なら不正とみなして処理を中止します。

参考: CSRF(クロス サイト リクエスト フォージェリ)

CSRF とは、「ユーザーが意図しないリクエストを悪意ある第三者が強制的に送信させる攻撃」です。

POST で副作用のある処理をする場合は、必ず CSRF 対策をする必要があります(CSRF攻撃)。

CSRF 対策としては「トークン」を使います。以下がおおまかな原理です。

  1. フォーム表示時にランダムなトークンを生成し、セッションに保存
  2. フォーム内に hiddenフィールドとしてトークンを埋め込む
  3. フォーム送信時、POSTされたトークンとセッションのトークンを比較
  4. 一致すれば正当なリクエスト、一致しなければ不正なリクエスト(CSRF)とみなして拒否

POSTデータの初期処理と変数への格納

//POST データの安全性をチェック
$_POST = checkInput($_POST);

// 安全な $_POST から変数に代入
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

helpers.php で定義した関数 checkInput() を使ってすべての POST データの安全性をチェック後、POST データの各値から trim() で余計な空白を削除して変数へ代入します。

$_POST['name'] などの値が未定義または null の場合、trim() に直接渡すとエラーになる可能性があるため、 NULL 合体演算子(??)を使って デフォルトで空文字列に変換しています。

フォームが送信されたかどうかを判定

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit']))

現在のリクエストが POST メソッド であり、なおかつ contact-form-submit というフィールドが存在する場合にのみ、入力バリデーションやメールの送信処理を行うようにします(ページが表示されたときや GETでアクセスされたときは処理しません)。

この例の場合、HTML の <button name="contact-form-submit" type="submit">送信</button> がクリックされてフォームが送信されると、name 属性に指定した contact-form-submit という名前のキーが $_POST に追加され、isset($_POST['contact-form-submit']) が true になります。

CSRFトークンの検証

// CSRFトークンの検証
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  die('CSRFトークンが存在しません。');
}
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
  die('不正なCSRFトークンです。');
};

タイミング攻撃対策として(安全に比較するため)、hash_equals() を使って値を検証します。

hash_equals() は、文字列を比較する際に処理時間が一定になるよう設計されています。これにより、比較処理の時間差から秘密の値を推測する「タイミング攻撃」を防ぐことができます。

通常の === や == は途中で比較を止めてしまうため、安全なトークンチェックには hash_equals() が推奨されます。

入力バリデーション

if ($name === '') {
  $error['name'] = '*お名前は必須項目です。';
  //制御文字でないことと文字数をチェック
} elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
  $error['name'] = '*お名前は30文字以内でお願いします。';
} elseif (preg_match("/[\r\n]/", $name)) {
  // 改行文字が含まれていたらエラー(ヘッダーインジェクション対策)
  $error['name'] = '*お名前に改行文字は使用できません。';
}

HTML5 のフォーム検証機能により入力値はチェックされますが、HTML5 の検証機能に対応していないブラウザなどもあるので、必ず PHP によるサーバーサイド検証を併用する必要があります。

  • 各入力項目に対して、必須チェック・文字数・形式・改行の有無などを確認します。
  • 改行文字のチェックはヘッダーインジェクション対策です。
  • エラーがあれば $error 配列に追加され、フォーム側で参照してメッセージ表示できます。

HTML5 の検証が機能していれば、このエラーは表示されることはないと思いますが、PHP 側の検証機能を確認するには、form 要素に novalidate 属性を指定します。

入力バリデーションの判定

エラーメッセージを保存する配列 $error が空であれば、バリデーションエラーがないのでメールの送信処理を実行します。

if (empty($error)) { //送信処理を実行 } 

メールの組み立てと送信処理

// mbstring 設定
mb_language('uni');
mb_internal_encoding('UTF-8');
$mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";
$returnMail = MAIL_RETURN_PATH;

// ヘッダー
$header  = "MIME-Version: 1.0\n";
$header .= "Content-Type: text/plain; charset=UTF-8\n";
$header .= "Content-Transfer-Encoding: 8bit\n";
$header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
$header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
...

//メール本文の組み立て
$mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
$mail_body .=  "お名前: " . h($name) . "\n";
...

// メール送信(結果を変数 $result に代入)
if (ini_get('safe_mode')) {
  $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
} else {
  $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
}

ヘッダー情報を組み立て、入力値からメール本文を組み立てます。

  • mb_language('uni') を使い、UTF-8 を前提にする
  • Content-Type と Content-Transfer-Encoding を必ず明記
  • From や Reply-To の表示名(MIMEヘッダの文字列)は mb_encode_mimeheader() を使う
  • セーフモード対応するために分岐(将来的に削除 ※)

mb_send_mail() でメールを送信する際は、セーフモードにより分岐して送信し、成功すれば完了ページにリダイレクトします。

※ safe_mode のチェックは PHP 5.4 で削除されており、最新の PHP では常に false になります。そのため、ini_get('safe_mode') の使用は将来的に削除を検討してもよいかと思います。

ユーザーのメールアドレスを From ヘッダーに直接指定しない

From に指定されたアドレス(例: user@example.com)が、実際の送信元サーバーと一致しないと、多くのメールサーバーではスパム扱いされる可能性が高くなります。

そのため、送信元(From)はシステムの正当なメールアドレスを使い、返信先(Reply-To)にユーザーのアドレスを設定します。

以下の場合、MAIL_FROM_NAME にはサイト管理者やサイトの名前が、MAIL_FROM にはシステムの正当な(送信元サーバーと一致する)メールアドレスが mail_config.php で定義されているものとします。

返信先(Reply-To)に入力されたユーザーの名前($name)とアドレス($email)を設定します。

$header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
$header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";

こうすることで From に正規のアドレスを使って認証を通し、Reply-To を使って管理者が返信するときにユーザーに返信できる状態を保てます。

セッション固定攻撃(Session Fixation)を防ぐ

セッション固定攻撃は、攻撃者があらかじめ決めたセッションIDをユーザーに使わせて乗っ取る攻撃です。session_regenerate_id() でメール送信直後にセッションIDを変えれば、そのリスクを減らせます。

フォームの送信完了(メール送信成功)後にセッションIDを変えることで、攻撃者が送信前のIDを使っても意味がなくなります。

 //メールが送信された場合の処理
if ($result) {
  // メール送信成功時にセッションIDを再生成(セッション固定攻撃対策)
  session_regenerate_id(true);
  ...
}
    

自動返信メール

if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {

  // mbstring 設定(管理者宛てと共通設定にする)
  mb_language('uni');
  mb_internal_encoding('UTF-8');
  // ヘッダー
  $reply_header  = "MIME-Version: 1.0\n";
  $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
  $reply_header .= "Content-Transfer-Encoding: 8bit\n";
  $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
  $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";
...
}

mail_config.php で AUTO_REPLY_ENABLED が true で、且つ AUTO_REPLY_NAME が定義されていれば、自動返信メールを送信します。

セッションと POST データの後処理

if ($result) {
  session_regenerate_id(true);

  ...

  // 入力内容をセッションに保持
  $_SESSION['form_data'] = [
    'name'    => $name,
    ...
  ];

  // トークンは使い捨てにする(再送信防止)
  unset($_SESSION['csrf_token']);

  //空の配列を代入し、すべてのPOST変数を消去
  $_POST = array();

  //完了画面へリダイレクト
  $url = 'complete.php';
  header('Location:' . $url);
  exit;
} else {
  $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
}
// 最後に、トークンがなければ再生成(フォームの再表示などで)
if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];
  • メール送信成功後は、入力内容をセッションに保持し、完了ページで使用できるようにします。
  • CSRF トークンを破棄し、POST データも初期化して再送信防止します。
  • エラー時や再表示時にはトークンを再生成してフォームを再表示可能にします。
HTML

以下は contact.php の HTML 部分です。

form 要素には、method 属性に POST を指定し、action 属性に自分自身(contact.php)を指定しています。自分自身に送信する場合は action 属性自体を省略することもできますが、明示的に書く方が確実です。

PHP 側で生成した CSRF トークンを type="hidden" を設定した input 要素に埋め込みます(15行目)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>
<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form method="post" action="contact.php" class="contact-form">
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          id="name"
          name="name"
          placeholder="氏名"
          required
          maxlength="30"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          placeholder="Email アドレス"
          required
          maxlength="254"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          pattern="0\d{1,4}-?\d{1,4}-?\d{3,4}"
          title="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          id="subject"
          name="subject"
          placeholder="件名"
          required
          maxlength="50"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          id="body"
          name="body"
          placeholder="お問い合わせ内容"
          rows="5"
          required
          maxlength="300"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <!-- 送信エラー表示(メール送信失敗時) -->
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
</body>
</html>

各入力フィールドとエラー表示

<label for="name">お名前(必須)
  <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
</label>
<input type="text" name="name" ... value="<?php echo h($name); ?>">

入力値は POST 後も保持されるように value に再出力し、エラーがある場合は $error['name'] を表示します。その際に helpers.php で定義した h() 関数を使ってエスケープします(XSS 対策)。

HTML5 の検証が機能していない場合に不適切な値が入力された場合などサーバー(PHP)側の検証を通過しない場合は、送信処理は行わずフォームの入力項目の label 要素の部分(error-php クラスを指定した span 要素)にエラーを出力しています。

HTML5 のフォームの検証機能

<input
  type="text"
  id="name"
  name="name"
  placeholder="氏名"
  required
  maxlength="30"
  value="<?php echo h($name); ?>">

input 要素には入力する内容によって検証属性(required や maxlength、pattern 等)や type 属性を指定して入力時の検証をしています(HTML5 のフォームの検証機能)。

電話番号の例のように、title 属性にバリデーションヒントを記載することもできます。

<input
  type="tel"
  id="tel"
  name="tel"
  placeholder="電話番号(例:090-1234-5678 または 09012345678)"
  pattern="0\d{1,4}-?\d{1,4}-?\d{3,4}"
  title="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
  value="<?php echo h($tel); ?>">

送信エラー表示(メール送信失敗時)

<?php if (isset($error['send'])): ?>
  <div class="error-php"><?php echo h($error['send']); ?></div>
<?php endif; ?>

メール送信に失敗した場合のエラーを表示します。$error['send'] はメール送信に失敗した際に、PHP 側でセットされます。

クラス属性

この例では、スタイル設定用のクラス属性を指定していますが、必要に応じて変更・削除してください。

PHP の検証機能の確認

PHP の検証機能の確認は、form 要素に novalidate 属性を指定して HMTL5 の検証を無効にして確認することができます。

<form method="post" action="contact.php" novalidate>
contact.php のコード

以下は contact.php のコードの全文です。

<?php
session_start(); // セッション開始(CSRFトークンに必要)

// クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
header("X-Frame-Options: SAMEORIGIN");  // 古いブラウザ(IE11など)にも対応
header("Content-Security-Policy: frame-ancestors 'self';"); // 新しいブラウザ向けの設定

//エスケープ処理やデータチェックを行う関数のファイルの読み込み
require '../includes/helpers.php';

// 送信先メールアドレス等を定義したファイル mail_config.php の読み込み
require '../includes/mail_config.php';

// メール送信に使用する mail_config.php で定義されている定数の存在チェックと値の検証
validate_mail_config(
  // 必須のメールアドレス定数
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  // オプションのメールアドレス定数
  ['MAIL_CC', 'MAIL_BCC'],
  // 必須の名前定数
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  // オプションの名前定数
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME']
);

// CSRFトークンの生成(GET時など最初の表示)
if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

//POST データの安全性をチェック
$_POST = checkInput($_POST);

// 安全な $_POST から変数に代入
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

// POST リクエストで且つフォームの送信ボタンが押された場合の処理
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  // CSRFトークンのチェック
  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  //エラーメッセージを保存する配列の初期化
  $error = array();

  // 名前のバリデーション
  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
    //制御文字でないことと文字数をチェック
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    // 改行文字が含まれていたらエラー(ヘッダーインジェクション対策)
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  // メールアドレスのバリデーション
  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    // 改行文字が含まれていたらエラー(ヘッダーインジェクション対策)
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  // 電話番号のバリデーション(任意)ハイフンを一旦除去してから桁数をチェック
  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  // 件名のバリデーション
  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
    //制御文字でないことと文字数をチェック
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  // 内容のバリデーション(タブ・改行は許容)
  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
    //制御文字(タブ、復帰、改行を除く)でないことと文字数をチェック
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  // バリデーションエラーがなければ送信処理を実行
  if (empty($error)) {

    // mbstring 設定
    mb_language('uni');
    mb_internal_encoding('UTF-8');

    // 宛先
    $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";

    // Return-Path に指定するメールアドレス
    $returnMail = MAIL_RETURN_PATH;

    // ヘッダー
    $header  = "MIME-Version: 1.0\n";
    $header .= "Content-Type: text/plain; charset=UTF-8\n";
    $header .= "Content-Transfer-Encoding: 8bit\n";
    $header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
    $header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
    // Cc の設定(名前とアドレス両方が定義されている場合のみ)
    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . " <" . MAIL_CC . ">\n";
    }
    // Bcc の設定(アドレスが定義されている場合のみ)
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $header .= "Bcc: <" . MAIL_BCC . ">\n";
    }

    //メール本文の組み立て
    $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
    $mail_body .=  "お名前: " . h($name) . "\n";
    $mail_body .=  "Email: " . h($email) . "\n";
    $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
    $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

    // メール送信(結果を変数 $result に代入)
    if (ini_get('safe_mode')) {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
    } else {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
    }

    //メールが送信された場合の処理
    if ($result) {

      // メール送信成功時にセッションIDを再生成(セッション固定攻撃対策)
      session_regenerate_id(true);

      //自動返信メール
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {

        // mbstring 設定(管理者宛てと共通設定にする)
        mb_language('uni');
        mb_internal_encoding('UTF-8');

        // ヘッダー
        $reply_header  = "MIME-Version: 1.0\n";
        $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
        $reply_header .= "Content-Transfer-Encoding: 8bit\n";
        $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
        $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";

        // 件名
        $reply_subject = "【{$name}様】お問い合わせありがとうございます";
        // お問い合わせ日時(本文内で使用)
        $week = ['日', '月', '火', '水', '木', '金', '土'];
        $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
        // 本文
        $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
        // メール送信
        if (ini_get('safe_mode')) {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header);
        } else {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header, '-f' . $returnMail);
        }

        // 自動返信メールの送信結果っをセッションに保持
        $_SESSION['reply_result'] = $reply_result;
      }

      // 入力内容をセッションに保持
      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      // トークンは使い捨てにする(再送信防止)
      unset($_SESSION['csrf_token']);

      //空の配列を代入し、すべてのPOST変数を消去
      $_POST = array();

      //完了画面へリダイレクト
      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    // 最後に、トークンがなければ再生成(フォームの再表示などで)
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>
<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form method="post" action="contact.php" class="contact-form">
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          id="name"
          name="name"
          placeholder="氏名"
          required
          maxlength="30"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          placeholder="Email アドレス"
          required
          maxlength="254"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          pattern="0\d{1,4}-?\d{1,4}-?\d{3,4}"
          title="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          id="subject"
          name="subject"
          placeholder="件名"
          required
          maxlength="50"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          id="body"
          name="body"
          placeholder="お問い合わせ内容"
          rows="5"
          required
          maxlength="300"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <!-- 送信エラー表示(メール送信失敗時) -->
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
</body>
</html>

complete.php

以下はお問い合わせフォームの送信完了画面(complete.php)です。

フォームのデータを保持するために、session_start() でセッションを開始します。

ブラウザバックやF5によるキャッシュ再表示を防ぐため、完了画面には header() でキャッシュコントロールヘッダーを出力します。Pragma と Expires は HTTP/1.0 のブラウザ向け対策です(古い環境対応)。

また、不正アクセス対策としてセッションにフォームデータがない場合はリダイレクトします(この結果、完了画面で再読込すると contact.php へリダイレクトされます)。

続いて関数や設定が定義されているファイル helpers.php と mail_config.php を読み込みます。

$_SESSION['form_data'] に格納されていた各フォームデータと $_SESSION['reply_result'] に代入されていた自動返信メールの送信結果をそれぞれの変数に取り出します。?? '' は「null 合体演算子」で、対応する値が存在しない場合は空文字にします。

表示後はセッション変数を全て削除し、ブラウザ側のセッションクッキーを削除します。そしてセッション自体を破棄します(セッションの完全終了)。

SHOW_INPUT_VALUES_WITH_RESULT が true に設定されていてれば、送信内容を表形式で表示します。各データの表示では、h() 関数を使って HTML エスケープし、XSS(クロスサイトスクリプティング)を防止します。

お問い合わせ本文には改行(\n)が含まれることがあるため、nl2br() を使って、改行を <br> に変換し、HTML上でも整形して表示しています。

また、ブラウザの「戻る」ボタンを押してもフォームが再送信されないように、JavaScript で履歴を上書きします。

<?php
session_start(); //セッションを開始
// クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
header("X-Frame-Options: SAMEORIGIN");  // 古いブラウザ(IE11など)にも対応
header("Content-Security-Policy: frame-ancestors 'self';"); // 新しいブラウザ向けの設定

// キャッシュ抑制ヘッダー
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Pragma: no-cache");
header("Expires: Thu, 01 Jan 1970 00:00:00 GMT");

// CSRF・不正アクセス対策:セッションにフォームデータがない場合はリダイレクト
if (!isset($_SESSION['form_data'])) {
  header('Location: contact.php');
  exit;
}

// 必要なファイルの読み込み
require '../includes/helpers.php';
require '../includes/mail_config.php';

// フォームに入力された値(セッションから取得)
$name    = $_SESSION['form_data']['name'] ?? '';
$email   = $_SESSION['form_data']['email'] ?? '';
$tel     = $_SESSION['form_data']['tel'] ?? '';
$subject = $_SESSION['form_data']['subject'] ?? '';
$body    = $_SESSION['form_data']['body'] ?? '';

// 自動返信メールの送信結果(セッションから取得)
$auto_reply_result = $_SESSION['reply_result'] ?? false;
// 入力されたデータを完了画面に表示するかどうか
$show_input_values_with_result = (defined('SHOW_INPUT_VALUES_WITH_RESULT') && SHOW_INPUT_VALUES_WITH_RESULT) ? true : false;

// セッション変数の全削除
$_SESSION = [];

// セッションクッキーを削除(ブラウザに残っているセッションクッキー自体を無効化)
if (ini_get("session.use_cookies")) {
  // セッション用クッキーに使っている情報(path、domain、secure、httponly など)を取得
  $params = session_get_cookie_params();
  // クッキーの値(第2引数)を空にする(path、domain、secure、httponly は維持)
  setcookie(session_name(), '', time() - 42000,
    $params["path"], $params["domain"],
    $params["secure"], $params["httponly"]
  );
}

// セッション自体の破棄
session_destroy();
?>

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>送信完了 | お問い合わせ</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>
<body>
  <div class="container contact-page">
    <h1 aria-label="送信完了メッセージ">送信が完了しました。</h1>
    <?php if ($show_input_values_with_result ): ?>
      <h2>送信内容:</h2>
      <table class="result" summary="送信内容の確認">
        <tr>
          <th scope="row">お名前:</th>
          <td><?php echo h($name); ?></td>
        </tr>
        <tr>
          <th scope="row">メール:</th>
          <td><?php echo h($email); ?></td>
        </tr>
        <tr>
          <th scope="row">電話番号:</th>
          <td><?php echo h($tel); ?></td>
        </tr>
        <tr>
          <th scope="row">件名:</th>
          <td><?php echo h($subject); ?></td>
        </tr>
        <tr>
          <th scope="row">内容:</th>
          <td><?php echo nl2br(h($body)); ?></td>
        </tr>
      </table>
    <?php endif; ?>
    <p>ありがとうございました。</p>
    <?php if ($auto_reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$auto_reply_result): ?>
      <p class="fail" role="alert">確認の自動返信メールが送信できませんでした。</p>
    <?php endif; ?>
  </div>
  <!-- 戻るボタン対策 -->
  <script>
    // History API対応ブラウザかを確認
    if (window.history.replaceState) {
      // 状態とタイトルは変更せず、現在のURLで置き換える
      window.history.replaceState(null, null, window.location.href);
    }
  </script>
</body>
</html>

キャッシュコントロールヘッダー

以下は上記で指定しているキャッシュコントロールヘッダーの概要です。

  1. Cache-Control
    • HTTP/1.1 以降の主要ブラウザに対応。
    • 最も重要かつ汎用性が高いため、通常先に記述しておく。
    • 以下のような複数のキャッシュ制御指示を含められる。
      • no-store:ブラウザや中間キャッシュに一切保存させない
      • no-cache:キャッシュされたデータの使用を許可しない
      • must-revalidate:応答が常に最新であるべきことを要求
      • max-age=0:キャッシュの有効期限を即時に設定
  2. Pragma
    • HTTP/1.0 用の互換性を考慮(互換性確保のため残しておく)。
    • Cache-Control より後に記述しても問題なし。
  3. Expires
    • こちらも HTTP/1.0 向け。
    • Cache-Control が優先されるブラウザではあまり使用されませんが、古い環境対応のため明示。

使い方

※このフォームは PHP の mb_send_mail() 関数を使用しています。そのため、動作には sendmail または適切なメール送信環境が必要です。

【動作環境の前提】

  • sendmail または postfix 等のMTAがインストールされており、PHP がそれを通じてメール送信できる状態であること(ローカル環境や一部の共有サーバーでは送信が制限されている場合があります)
  • UTF-8 でのメール送信に対応するよう、mbstring が有効であること

PHPMailer を利用すると、ローカル環境でもメール送信が可能になります。

全てのファイルを作成(コピー)して、mail_config.php に実際のメールアドレスや名前を設定します。そして必要に応じて以下を設定します(デフォルトは true)。

  • 入力データを完了画面に表示するかどうか:SHOW_INPUT_VALUES_WITH_RESULT
  • 自動返信メールを送信するかどうか:AUTO_REPLY_ENABLED

その他のファイルは特に変更する必要はありませんが、必要に応じて変更します。

JavaScript で検証

HTML5 の検証機能を使った場合、表示されるエラーやその見栄えがブラウザごとに異なり、細かい条件(例:重複チェック)は実装できないなど、環境やブラウザによってサポートに差があります。

そのため、HTML5 の検証を無効にして独自の検証を行いたい場合は、form 要素に novalidate 属性を追加することで、ブラウザの標準バリデーションを無効にできます。こうすることで、エラー表示の制御をすべて JavaScript または PHP 側でカスタマイズ可能になります。

ただし、PHP による検証はサーバーにデータを送信した後に実行されるため、通信環境によっては検証結果の表示に時間がかかり、ユーザーの操作性(UX)が低下する可能性があります。

一方、JavaScript を使ったクライアントサイドの検証であれば、送信前に即座にエラーを検出・表示できるため、ユーザーにとってより快適な入力体験を提供できます。

JavaScript 側の検証では、基本的に PHP 側と同等のルールを実装し、JavaScript の検証を通過すれば PHP の検証も通過するように設計するのが理想的です(逆も然り)。

ただし、JavaScript が無効になっている環境ではクライアント側の検証が機能しないため、セキュリティ上、必ず PHP によるサーバーサイド検証を併用する必要があります

以下のリンクから JavaScript の検証を使ったサンプルを確認できます。

動作サンプルを別ページで確認する

以下はこの例のファイル構成です。assets に js フォルダを追加して検証用の JavaScript を配置します。

├── assets
│   ├── css
│   │   └── contact-style.css
│   └── js
│       └── form-validation.js  // 検証用 JavaScript を追加
├── contact
│   ├── contact.php
│   └── complete.php
└── includes
    ├── .htaccess
    ├── helpers.php
    ├── index.php
    └── mail_config.php

検証用 JavaScript

以下が検証用の JavaScript です。

この JavaScript スクリプトは、HTML フォームに対するリアルタイムおよび送信時のバリデーションを提供します。主に以下の機能を実装しています。

  • 必須入力チェック(required)
  • パターン(正規表現)チェック(pattern)
  • 入力値の一致チェック(equal-to)
  • 最小文字数・最大文字数チェック(minlength / maxlength)
  • 文字数カウント表示機能(showCount)
  • エラーメッセージの動的追加・削除
  • バリデーションエラーがある場合は送信を中止し、最初のエラー位置にスクロール

メールアドレスや電話番号の形式チェック、必須入力チェック、文字数制限など、一般的な検証項目に対応し、この例では使用していないチェックボックスやラジオボタンなどにも対応しています。

// バリデーションスクリプト
document.addEventListener("DOMContentLoaded", () => {
  // バリデーション対象のフォーム要素を取得
  const validationForm = document.querySelector(".js-form-validation");

  // フォーム送信済みフラグ(エラーメッセージの重複表示を防ぐために使用)
  let hasSubmittedOnce = false;

  // エラー表示に使うクラス名
  const errorClassName = "error-js";

  // フォームが存在しない場合は処理を中断
  if (!validationForm) return;

  // 各種バリデーション対象の要素を取得
  const requiredElems = document.querySelectorAll(".required");
  const patternElems = document.querySelectorAll(".pattern");
  const equalToElems = document.querySelectorAll(".equal-to");
  const minlengthElems = document.querySelectorAll(".minlength");
  const maxlengthElems = document.querySelectorAll(".maxlength");
  const showCountElems = document.querySelectorAll(".showCount");

  /**
   * エラーメッセージを表示
   */
  const addError = (elem, className, defaultMessage) => {
    if (hasSubmittedOnce) {
      // 既存のエラーを除去(重複防止)
      removeError(elem, className);

      // data-error-xxx 属性があれば使い、なければデフォルトメッセージを表示
      let errorMessage =
        elem.getAttribute(`data-error-${className}`) || defaultMessage;

      // エラーメッセージ用の要素を作成して追加
      const errorSpan = document.createElement("span");
      errorSpan.classList.add(errorClassName, className);
      errorSpan.setAttribute("aria-live", "polite"); // 音声読み上げ対応
      errorSpan.textContent = errorMessage;
      elem.parentNode.appendChild(errorSpan);
    }
  };

  /**
   * エラーメッセージを削除
   */
  const removeError = (elem, className) => {
    const errorSpan = elem.parentElement.querySelector(
      `.${errorClassName}.${className}`
    );
    if (errorSpan) errorSpan.remove();
  };

  /**
   * マルチバイト対応の文字数カウント
   */
  const getValueLength = (value) =>
    (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;

  /**
   * 必須項目のバリデーション
   */
  const isValueMissing = (elem) => {
    const className = "required";

    if ((elem.type === "radio" || elem.type === "checkbox") && elem.name) {
      // ラジオ・チェックボックスは同じnameのグループで判定
      const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
      const checked = [...group].some((el) => el.checked);
      if (!checked) {
        addError(elem, `${className}`, "選択は必須です");
        return true;
      }
      group.forEach((el) => removeError(el, `${className}`));
      return false;
    }

    // その他の要素
    if (!elem.value.trim()) {
      addError(
        elem,
        className,
        elem.tagName === "SELECT" ? "選択は必須です" : "入力は必須です"
      );
      return true;
    }

    removeError(elem, className);
    return false;
  };

  /**
   * パターン(正規表現)バリデーション
   */
  const isPatternMismatch = (elem) => {
    const className = "pattern";
    const patternType = elem.getAttribute("data-pattern");
    let pattern;
    let value = elem.value;

    // 電話番号(ハイフンを除去した値を検証に使用:10〜11桁の日本の電話番号)
    if (patternType === "tel") {
      value = value.replace(/-/g, "");
      pattern = /^0\d{9,10}$/;
    } else if (patternType === "email") {
      // Email アドレス
      pattern =
        /^([a-z0-9\+_\-]+)(\.[a-z0-9\+_\-]+)*@([a-z0-9\-]+\.)+[a-z]{2,6}$/iu;
    } else {
      // data-pattern 属性にパターンが指定された場合
      try {
        pattern = new RegExp(`^${patternType}$`);
      } catch {
        return false;
      }
    }

    if (value && !pattern.test(value)) {
      addError(elem, className, "入力された値が正しくないようです");
      return true;
    }

    removeError(elem, className);
    return false;
  };

  /**
   * 他の入力と一致しているか(確認用)
   */
  const isNotEqualTo = (elem) => {
    const className = "equal-to";
    const target = document.getElementById(elem.getAttribute("data-equal-to"));
    if (target && elem.value && target.value && elem.value !== target.value) {
      addError(elem, className, "入力された値が一致しません");
      return true;
    }
    removeError(elem, className);
    return false;
  };

  /**
   * 最小文字数バリデーション
   */
  const isTooShort = (elem) => {
    const className = "minlength";
    const minlength = parseInt(elem.getAttribute("data-minlength"), 10);
    const valueLength = getValueLength(elem.value);
    if (elem.value && valueLength < minlength) {
      addError(elem, className, `${minlength}文字以上で入力ください`);
      return true;
    }
    removeError(elem, className);
    return false;
  };

  /**
   * 最大文字数バリデーション
   */
  const isTooLong = (elem) => {
    const className = "maxlength";
    const maxlength = parseInt(elem.getAttribute("data-maxlength"), 10);
    const valueLength = getValueLength(elem.value);
    if (elem.value && valueLength > maxlength) {
      addError(elem, className, `${maxlength}文字以内で入力ください`);
      return true;
    }
    removeError(elem, className);
    return false;
  };

  /**
   * リアルタイムバリデーションイベントの設定
   */
  const attachValidation = () => {
    requiredElems.forEach((elem) => {
      const eventType =
        elem.type === "radio" || elem.type === "checkbox" ? "change" : "input";

      if (elem.type === "radio" || elem.type === "checkbox") {
        // グループ全体で1回だけイベントを登録(同じnameに複数ついてもOK)
        const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
        // 最初の要素だけにイベントを付ける(重複防止)
        if (group.length && group[0] === elem) {
          group.forEach((el) => {
            el.addEventListener(eventType, () => {
              group.forEach((target) => isValueMissing(target));
            });
          });
        }
      } else {
        elem.addEventListener(eventType, () => isValueMissing(elem));
      }
    });

    patternElems.forEach((elem) =>
      elem.addEventListener("input", () => isPatternMismatch(elem))
    );

    equalToElems.forEach((elem) => {
      elem.addEventListener("input", () => isNotEqualTo(elem));
      const target = document.getElementById(
        elem.getAttribute("data-equal-to")
      );
      if (target) target.addEventListener("input", () => isNotEqualTo(elem));
    });

    minlengthElems.forEach((elem) =>
      elem.addEventListener("input", () => isTooShort(elem))
    );

    maxlengthElems.forEach((elem) =>
      elem.addEventListener("input", () => isTooLong(elem))
    );
  };

  /**
   * 入力文字数カウンター表示
   */
  const attachCounter = () => {
    showCountElems.forEach((elem) => {
      const max = parseInt(elem.getAttribute("data-maxlength"), 10);
      if (!isNaN(max)) {
        const countElem = document.createElement("p");
        countElem.classList.add("countSpanWrapper");
        countElem.innerHTML = `<span class="countSpan">0</span>/${max}`;
        elem.parentNode.appendChild(countElem);

        elem.addEventListener("input", () => {
          const countSpan = countElem.querySelector(".countSpan");
          const count = getValueLength(elem.value);
          countSpan.textContent = count;
          countSpan.classList.toggle("overMaxCount", count > max);
          countSpan.style.color = count > max ? "red" : "";
        });
      }
    });
  };

  /**
   * 全項目のバリデーションを実行
   */
  const validateAll = () => {
    let hasError = false;
    requiredElems.forEach((elem) => {
      if (isValueMissing(elem)) hasError = true;
    });
    patternElems.forEach((elem) => {
      if (isPatternMismatch(elem)) hasError = true;
    });
    equalToElems.forEach((elem) => {
      if (isNotEqualTo(elem)) hasError = true;
    });
    minlengthElems.forEach((elem) => {
      if (isTooShort(elem)) hasError = true;
    });
    maxlengthElems.forEach((elem) => {
      if (isTooLong(elem)) hasError = true;
    });
    return hasError;
  };

  /**
   * フォーム送信イベント
   */
  validationForm.addEventListener("submit", (e) => {
    hasSubmittedOnce = true; // 以降エラー表示を有効にする
    const hasError = validateAll();
    if (hasError) {
      e.preventDefault(); // 送信を止める
      const errorElem = document.querySelector(`.${errorClassName}`);
      if (errorElem) {
        window.scrollTo({
          top: errorElem.offsetTop - 40,
          behavior: "smooth",
        });
      }
    }
  });

  // バリデーションとカウンターを初期化
  attachValidation();
  attachCounter();
});
  • 各種 .required, .pattern などのクラスを元に、要素ごとに検証関数を割り当てます。
  • 初回送信フラグ hasSubmittedOnce により、エラーメッセージの表示タイミングを制御。
  • 全項目バリデーションに失敗すると送信を中止し、最初のエラー箇所まで自動スクロール。

使い方

  1. HTMLの <form> 要素に class="js-form-validation"novalidate 属性を指定します。
  2. 入力項目に検証用のクラスや属性(下記参照)を追加します。
  3. このスクリプトを HTML 末尾または DOMContentLoaded 後に読み込んでください。
<form class="js-form-validation" novalidate>
  <input type="text" class="required minlength maxlength" data-minlength="5" data-maxlength="20">
  ...
</form>
対応バリデーション一覧
クラス / 属性 内容
.required 空入力の禁止 必須入力欄やラジオボタンなど
.pattern + data-pattern 正規表現で形式をチェック data-pattern="email"、data-pattern="tel"など
.equal-to + data-equal-to="inputID" 入力値の一致をチェック(確認用) メールアドレスやパスワード確認などに
.minlength + data-minlength 最小文字数制限 data-minlength="5"
.maxlength + data-maxlength 最大文字数制限 data-maxlength="20"
.showCount + data-maxlength 文字数カウントを表示 入力欄の下に 現在の文字数 / 上限 を表示

エラーメッセージの制御

  • デフォルトのメッセージは自動表示されます。
  • カスタムメッセージを使いたい場合は、次のように data-error-◯◯ 属性を使用してください
<input type="text" class="required" data-error-required="この項目は必須です">

対応する入力形式の例

<input type="text" class="pattern" data-pattern="email">
<input type="text" class="pattern" data-pattern="tel">
<!-- パターンをカスタム設定(例:郵便番号)-->
<input type="text" class="pattern" data-pattern="^\d{3}-\d{4}$">

入力チェックのタイミング

  • ユーザーが文字を入力するたびに、リアルタイムでバリデーションが行われます(input イベント)。
  • ラジオボタンやチェックボックスは change イベントで検証されます。
  • フォーム送信時には全項目のチェックが再実行され、エラーがあると送信をブロックします。

アクセシビリティ対応

エラーメッセージには aria-live="polite" が設定されており、スクリーンリーダーにも対応しています。

検証用スクリプト 動作確認ササンプル

以下は検証用スクリプト(form-validation.js)の動作確認サンプルです。「クリックで検証」ボタンをクリックすると入力値を検証します。エラーがなければフォームはっクリアされます(iframe で表示しているのでスクロールしません)。

動作サンプルを別ページで確認する

<div class="content">
  <h3>検証用 JavaScript 動作確認サンプル</h3>
  <form name="myForm" class="js-form-validation" novalidate>
    <div>
      <label for="name">名前 </label><br>
      <input class="required pattern" data-error-required="名前は必須です" data-pattern="^.{2,20}$" data-error-pattern="2文字以上20文字以内で入力ください" type="text" name="name" id="name">
    </div>
    <div>
      <label for="tel">電話番号 </label><br>
      <input class="pattern" data-pattern="tel" data-error-pattern="適切な桁数及び半角数字でご入力ください" type="tel" name="tel" id="tel">
    </div>
    <div>
      <label for="email1">メールアドレス </label><br>
      <input class="required pattern" data-pattern="email" data-error-required="メールアドレスは必須です" data-error-pattern="メールアドレスには@やドメインが必要です" type="email" id="email1" name="email1" size="30">
    </div>
    <div>
      <label for="email2">メールアドレス 再入力(確認用)</label><br>
      <input class="required equal-to" data-equal-to="email1" data-error-equal-to="メールアドレスが一致しません" type="email" id="email2" name="email2" size="30">
    </div>
    <div>
      <p>色を選択してください(必須)</p>
      <input class="required" data-error-required="いずれかの色を選択してください" type="radio" name="color" value="blue" id="blue">
      <label for="blue"> 青 </label>
      <input type="radio" name="color" value="red" id="red">
      <label for="red"> 赤 </label>
      <input type="radio" name="color" value="green" id="green">
      <label for="green"> 緑 </label>
    </div>
    <div>
      <p>連絡方法を選択してください(必須:複数選択可)</p>
      <input class="required" data-error-required="連絡方法の選択は必須です" type="checkbox" name="contact[]" id="byEmail" value="Email">
      <label for="byEmail"> メール</label>
      <input type="checkbox" name="contact[]" id="byTel" value="Telephone">
      <label for="byTel"> 電話</label>
      <input type="checkbox" name="contact[]" id="byMail" value="Mail">
      <label for="byMail"> 郵便 </label>
    </div>
    <div>
      <select class="required" data-error-required="季節が選択されていません" name="season" id="season">
        <option value="">季節を選択してください(必須)</option>
        <option value="spring">春</option>
        <option value="summer">夏</option>
        <option value="autumn">秋</option>
        <option value="winter">冬</option>
      </select>
    </div>
    <div>
      <label for="subject">件名</label><br>
      <input type="text" class="required maxlength" data-maxlength="30" data-error-required="件名は必須です" id="subject" name="subject">
    </div>
    <div>
      <label for="inquiry">お問い合わせ内容</label><br>
      <textarea class="required maxlength showCount" data-maxlength="100" data-error-required="お問い合わせ内容は必須です" name="inquiry" id="inquiry" rows="5" cols="50"></textarea>
    </div>
    <button name="send">クリックで検証</button>
  </form>
</div>
<!-- 検証用スクリプト form-validation.js の読み込み -->
<script src="../assets/js/form-validation.js"></script>

HTML

以下は contact.php の HTML 部分です。

form 要素に js-form-validation クラスと novalidate 属性を指定します。

基本的な構造は同じですが、確認用のメールアドレスの入力欄を追加しています。

そして、入力項目に検証用のクラス(required や maxlength など)や属性(data-maxlength、data-error-◯◯ 属性 など)を追加します。

body の閉じタグの直前で検証用の JavaScript(form-validation.js)を読み込みます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>
<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <!-- form 要素に js-form-validation クラス と novalidate を追加-->
    <form class="js-form-validation contact-form" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <!-- 入力項目に検証用のクラス(required や maxlength など)や属性(data-maxlength など)を追加-->
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <!-- 送信エラー表示(メール送信失敗時) -->
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <!--  検証用の JavaScript の読み込み(または script タグに検証用スクリプトを記述) -->
  <script src="../assets/js/form-validation.js"></script>
</body>
</html>

以下は名前の入力欄の例です。required クラスは必須項目であることを表し、maxlength クラスは最大文字数制限があることを意味します。最大文字数は data-maxlength 属性に指定します。

data-error-◯◯ 属性を使ってエラーメッセージをカスタマイズできます。

以下では data-error-required 属性を指定して、必須(required)のエラーの場合のメッセージをカスタマイズしています(省略した場合は、デフォルトのメッセージ「選択は必須です」と表示されます)。

data-error-maxlength を指定すれば、制限文字数オーバーのエラーをカスタマイズできます。

<input
  type="text"
  class="required maxlength"
  data-maxlength="30"
  id="name"
  name="name"
  placeholder="氏名"
  data-error-required="お名前は必須です。"
  value="<?php echo h($name); ?>">

PHP

以下は PHP を含む contct.php の全文です。

PHP では確認用メールアドレスの取得($email_check:19行目)とメールアドレスが一致しているかの検証(57-63行目)を追加しただけです。その他は前述の contact.php と同じです。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME']
);

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
// 確認用メールアドレス ★★追加★★
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  // 確認用メールアドレスのバリデーション ★★追加★★
  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {
    mb_language('uni');
    mb_internal_encoding('UTF-8');
    $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";
    $returnMail = MAIL_RETURN_PATH;

    $header  = "MIME-Version: 1.0\n";
    $header .= "Content-Type: text/plain; charset=UTF-8\n";
    $header .= "Content-Transfer-Encoding: 8bit\n";
    $header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
    $header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . " <" . MAIL_CC . ">\n";
    }
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $header .= "Bcc: <" . MAIL_BCC . ">\n";
    }
    $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
    $mail_body .=  "お名前: " . h($name) . "\n";
    $mail_body .=  "Email: " . h($email) . "\n";
    $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
    $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

    if (ini_get('safe_mode')) {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
    } else {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
    }

    if ($result) {
      // メール送信成功時にセッションIDを再生成(セッション固定攻撃対策)
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        mb_language('uni');
        mb_internal_encoding('UTF-8');

        $reply_header  = "MIME-Version: 1.0\n";
        $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
        $reply_header .= "Content-Transfer-Encoding: 8bit\n";
        $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
        $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";

        $reply_subject = 'お問い合わせ自動返信メール';
        $week = ['日', '月', '火', '水', '木', '金', '土'];
        $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
        $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
        if (ini_get('safe_mode')) {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header);
        } else {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header, '-f' . $returnMail);
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>
<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <!-- form 要素に js-form-validation クラス と novalidate を追加-->
    <form class="js-form-validation contact-form" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <!-- 入力項目に検証用のクラス(required や maxlength など)や属性(data-maxlength など)を追加-->
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <!--  検証用の JavaScript の読み込み(または script タグに検証用スクリプトを記述) -->
  <script src="../assets/js/form-validation.js"></script>
</body>
</html>

complete.php

complete.php は先のコードと同じなので省略します。

reCAPTCHA v2 の実装

Google が提供するキャプチャ認証システムの reCAPTCHA v2 を実装する例です。

以下のリンクから reCAPTCHA v2 を使ったサンプルで動作を確認できます。

動作サンプルを別ページで確認する

以下はこの例のファイル構成です。

reCAPTCHA v2 の実装に使用する JavaScript ファイル(recaptcha-v2-handler.js)を追加し、helpers.php に reCAPTCHA を検証する PHP 関数を追加するなどいくつかのファイルを変更します。

├── assets
│   ├── css
│   │   └── contact-style.css
│   └── js
│       ├── form-validation.js
│       └── recaptcha-v2-handler.js  // reCAPTCHA V2 ウィジェットを表示・検証する JavaScript
├── contact
│   ├── contact.php  // JavaScript や PHP 関数の読み込み、HTML などを追加
│   └── complete.php
└── includes
    ├── .htaccess
    ├── helpers.php  // reCAPTCHA を検証する PHP 関数 verify_recaptcha_v2() を追加
    ├── index.php
    └── mail_config.php  //サイトキーとシークレットキーの定義を追加

Google reCAPTCHA を利用するにはサイトを登録して、サイトキーとシークレットキーを取得しておく必要があります(関連:reCAPTCHA にサイトを登録)。

mail_config.php

reCAPTCHA v2 のサイトキーとシークレットキーの定義を mail_config.php に追加します。

<?php
if (basename($_SERVER['PHP_SELF']) === basename(__FILE__)) {
  http_response_code(403);
  exit('Forbidden');
}

define('MAIL_TO', 'info@example.com');
define('MAIL_TO_NAME', 'Example');
define('MAIL_FROM', 'info@example.com');
define('MAIL_FROM_NAME', 'Example');
define('MAIL_RETURN_PATH', 'info@example.com');

define('SHOW_INPUT_VALUES_WITH_RESULT', true);
define('AUTO_REPLY_ENABLED', true);
define('AUTO_REPLY_NAME', '自動返信 Example');

define('MAIL_CC', 'cc@examplex.com');
define('MAIL_CC_NAME', 'Ccの宛先名');
define('MAIL_BCC', 'bcc@example.com');

// reCAPTCHA v2 サイトキー(追加)
define('V2_SITEKEY', 'xxxxxxxxxxxxxxxxxxxxxxx');
// reCAPTCHA v2 シークレットキー(追加)
define('V2_SECRETKEY', 'xxxxxxxxxxxxxxxxxxxxxxx');

JavaScript

assets/js フォルダに recaptcha-v2-handler.js というファイルを作成して以下を記述します。

この JavaScript は、reCAPTCHA v2 を Web フォームに組み込んでウィジェットを表示し、ユーザーがチェックボックスを正しく操作したことを検証するためのものです。

  • 対象要素やエラーメッセージ用のクラスセレクタを変数化し(環境に合わせてこの部分を変更)。
  • reCAPTCHA ウィジェットを描画する関数(onloadCallback)とそのコールバック関数(verifyCallback と expiredCallback)の定義
  • フォーム送信時のチェック(DOMContentLoaded)
(function () {
  // reCAPTCHA V2 描画先(reCAPTCHA ウィジェット)となる要素の id 属性の値
  const targetElemId = "recaptcha-v2";
  // エラーメッセージ要素(以下の処理内で生成されます)に指定する id 属性の値
  const recaptchaErrorElemId = "recaptcha-v2-error";
  // エラーメッセージ要素に指定する class 属性の値(検証スクリプト form-validation.js でエラー表示に使うクラス)
  const errorMessageClass = "error-js";
  // 対象のフォーム要素のクラス(セレクタ)
  const targetFormClass = ".rcv2";
  // エラーメッセージ
  const errorMessage = "reCAPTCHA のチェックを入れてください。";

  // ユーザーが reCAPTCHA に正しく回答したときに呼び出されるコールバック関数
  const verifyCallback = function () {
    // reCAPTCHA 描画先の要素を取得
    const recaptchaElem = document.getElementById(targetElemId);
    // reCAPTCHA ウィジェットに verified クラスを付与
    if (recaptchaElem) {
      recaptchaElem.classList.add("verified");
    }
    // エラーメッセージ要素が存在する場合は削除(再チェック時の重複表示防止)
    const recaptchaError = document.getElementById(recaptchaErrorElemId);
    if (recaptchaError) {
      recaptchaError.remove();
    }
  };

  // reCAPTCHA のトークンの有効期限が切れたときに呼び出される関数
  const expiredCallback = function () {
    // reCAPTCHA 描画先の要素を取得
    const recaptchaElem = document.getElementById(targetElemId);
    if (recaptchaElem) {
      // "verified" クラスを削除(期限切れ扱い)
      recaptchaElem.classList.remove("verified");
    }
  };

  // Google API によって呼び出される reCAPTCHA ウィジェットを描画する関数(グローバルに公開)
  window.onloadCallback = function () {
    // グローバル変数からサイトキー取得
    const siteKey = window.recaptchaV2SiteKey;
    // サイトキーが定義されていなければ中止
    if (!siteKey) {
      console.warn("reCAPTCHA site key が定義されていません。描画を中止します。");
      return;
    }
    const recaptchaElem = document.getElementById(targetElemId);
    // reCAPTCHA 描画先の要素が存在しなければ中止
    if (!recaptchaElem) {
      console.warn(`ID "${targetElemId}" の要素が見つかりません。reCAPTCHA 描画をスキップします。`);
      return;
    }
    // id が targetElemId("recaptcha-v2")の要素に reCAPTCHA ウィジェットを描画
    grecaptcha.render(targetElemId, {
      "sitekey": siteKey, // サイトキー
      "callback": verifyCallback, // reCAPTCHA 成功時に呼び出される関数
      "expired-callback": expiredCallback, // トークンの有効期限切れ時に呼び出される関数
    });
  };

  // フォーム送信前に reCAPTCHA の検証を行う処理
  document.addEventListener("DOMContentLoaded", () => {
    // targetFormClass で指定したクラスを持つフォームを取得
    const validationForm = document.querySelector(targetFormClass);

    // reCAPTCHA 描画先の要素を取得
    const recaptchaElem = document.getElementById(targetElemId);

    if (validationForm) {
      // フォーム送信時に reCAPTCHA の状態を検証
      validationForm.addEventListener("submit", (e) => {
        // 既存のエラーメッセージ要素を取得
        const errorElem = document.getElementById(recaptchaErrorElemId);

        // すでにエラーメッセージが存在すれば削除(同じエラーが重複して表示されるのを防ぐ)
        if (errorElem) {
          errorElem.remove();
        }

        // "verified" クラスがない場合、未チェックと見なし送信を中止
        if (!recaptchaElem.classList.contains("verified")) {
          // エラーメッセージ要素を生成
          const recaptchaErrorElem = document.createElement("p");
          recaptchaErrorElem.classList.add(errorMessageClass); // エラースタイル用クラス
          recaptchaErrorElem.id = recaptchaErrorElemId; // id 属性
          recaptchaErrorElem.textContent = errorMessage; // エラーメッセージ
          // エラーメッセージ要素を reCAPTCHA の直後に追加
          recaptchaElem.after(recaptchaErrorElem);
          // フォーム送信を中止
          e.preventDefault();
        }
      });
    }else{
      console.warn("警告:対象のフォームが存在しません(reCAPTCHA V2 は正しく機能しません)。");
    }
  });
})();

reCAPTCHA ウィジェットを描画する関数 onloadCallback は reCAPTCHA の API スクリプトの読み込みで、以下のように onload パラメータで指定するため、グローバルスコープで定義する必要があります。

上記は即時実行関数(IIFE)にしているので、スコープはローカルに閉じ込められるため、onloadCallback の定義は window.onloadCallback のように window オブジェクトに明示的に代入する必要があります。

<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>

また、onloadCallback に指定するサイトキー(siteKey)は、別途 contact.php で以下のように window.recaptchaV2SiteKey を使うことで PHP 側から JS に値を渡します(次項参照)。

 <script>
  window.recaptchaV2SiteKey = "<?php echo $v2_site_key; ?>";
</script>

HTML

以下は reCAPTCHA v2 を実装する contact.php の一部抜粋です。

JavaScript で変数 targetFormClass に定義したクラス名 rcv2 を form 要素に指定します(5行目)。

そして reCAPTCHA ウィジェットを描画するための要素(プレースホルダー)を用意し、変数 targetElemId 定義した id 属性の値 recaptcha-v2 を指定します(21行目)。

また、サーバーサイドのバリデーション結果を反映するための処理を追加します(23-25行目)。バリデーションに失敗したときに、PHP のエラーメッセージ $error['recaptcha_v2'] が表示されます。

<body>
<div class="container contact-page">
  <h2>お問い合わせフォーム rc2</h2>
  <p>以下のフォームからお問い合わせください。</p>
  <form class="js-form-validation contact-form rcv2" method="post" action="contact.php" novalidate>
    <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
    <div>
      <label for="name">お名前(必須)
        <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
      </label>
      <input type="text" class="required maxlength" data-maxlength="30" id="name" name="name" placeholder="氏名" data-error-required="お名前は必須です。" value="<?php echo h($name); ?>">
    </div>
    ・・・中略・・・
    <div>
      <label for="body">お問い合わせ内容(必須)
        <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
      </label>
      <textarea class="required maxlength showCount" data-maxlength="1000" id="body" name="body" placeholder="お問い合わせ内容(1000文字まで)をお書きください" data-error-required="お問い合わせ内容は必須です。" rows="5"><?php echo h($body); ?></textarea>
    </div>
    <!-- reCAPTCHA ウィジェット を表示する要素 -->
    <div id="recaptcha-v2"></div>
    <!-- reCAPTCHA v2 バリデーションエラー表示 -->
    <?php if (isset($error['recaptcha_v2'])): ?>
      <p><span class="error-php"><?php echo h($error['recaptcha_v2']); ?></span></p>
    <?php endif; ?>
    <button name="submitted" type="submit" class="form-button">送信</button>
  </form>
  <?php if (isset($error['send'])): ?>
    <div class="error-php"><?php echo h($error['send']); ?></div>
  <?php endif; ?>
</div>
<script src="../assets/js/form-validation.js"></script>

<!-- reCAPTCHA サイトキーを JavaScript に渡す -->
<script>
  window.recaptchaV2SiteKey = "<?php echo $v2_site_key; ?>";
</script>
<!-- recaptcha-v2-handler.js の読み込み -->
<script src="../assets/js/recaptcha-v2-handler.js"></script>
<!-- reCAPTCHA API スクリプトの読み込み(onloadCallback を呼び出す)-->
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>
</body>

サイトキーと JavaScript の読み込み

recaptcha-v2-handler.js の中で window.recaptchaV2SiteKey を参照しているため、まず、サイトキーの値を PHP 側から JS に渡します(35-37行目)。

続いて、onloadCallback 関数を定義している recaptcha-v2-handler.js を読み込み、最後に reCAPTCHA の API スクリプト recaptcha/api.js を非同期で読み込み、準備ができたら onloadCallback 関数を呼び出すよう指定します(?onload=onloadCallback&render=explicit)。

PHP

includes/helpers.php に以下の関数 verify_recaptcha_v2() の定義を追加します。

この関数は、Google reCAPTCHA v2 の検証をサーバー側で行うための関数です。ユーザーから送信された reCAPTCHA のトークンが正当かどうかを Google の検証 API を通じて確認し、結果を返します。

以下が処理の流れです。

  1. トークン未送信チェック $response_token が空であれば、すぐにエラーを返す
  2. cURL を使用した Google API へのリクエスト
    • Google の検証 API に対し、$secret・$response・$remoteip を POST 送信。
  3. 通信エラーチェック
    • 通信失敗(タイムアウトなど)が発生した場合、cURL のエラー番号とメッセージを返す。
    • HTTP ステータスコードが 2xx 以外の場合、HTTP エラーとして扱う。
  4. レスポンスの解析
    • JSON デコードが成功すれば success フラグと hostname を確認。
    • success が true かつ hostname がサーバーと一致すれば、検証成功。
    • それ以外は失敗とし、Google から返された error-codes をメッセージに含める。
  5. 結果を返却
    • 成否(真偽値)とメッセージを含む配列を返す。
if (!function_exists('verify_recaptcha_v2')) {
  /**
   * Google reCAPTCHA v2 を検証する関数
   *
   * @param string $secret         reCAPTCHA v2 のシークレットキー
   * @param string $response_token ユーザーから送られたトークン( $_POST['g-recaptcha-response'] )
   * @param string $remote_ip      クライアントのIPアドレス(通常は $_SERVER['REMOTE_ADDR'])
   * @return array                 ['status' => true/false, 'message' => string]
   */
  function verify_recaptcha_v2($secret, $response_token, $remote_ip) {
    // 検証結果の初期化
    $result = [
      'status' => false,  // 初期値は失敗(false)に設定
      'message' => ''  // 検証失敗時のエラーメッセージ(成功時は空文字)
    ];

    // reCAPTCHA トークンの存在チェック
    if ($response_token === '') {
      $result['message'] = 'reCAPTCHAトークンが送信されていません。';
      return $result;
    }

    // Google の検証 API へリクエスト送信(cURL)
    $ch = curl_init(); // cURL セッションを初期化
    curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");  // API の UR
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // curl_exec の結果を文字列として返す
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 接続タイムアウト(秒)
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);  // 実行タイムアウト(秒)
    curl_setopt($ch, CURLOPT_POST, true); // POST メソッドを使う
    // reCAPTCHA API パラメータを POST データに指定
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
      'secret' => $secret, // シークレットキー
      'response' => $response_token, // reCAPTCHA トークン
      'remoteip' => $remote_ip, // IPアドレス送信パラメータ
    ]));

    // cURL セッションのレスポンス
    $response = curl_exec($ch);
    //  通信・レスポンスのエラーハンドリング
    if ($response === false) {
      // cURL レベルの失敗(ネットワーク、タイムアウトなど)
      $result['message'] = "cURL エラー番号: " . curl_errno($ch) . " エラーメッセージ: " . curl_error($ch);
    } else {
      // ステータスコードをチェック
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($httpCode < 200 || $httpCode >= 400) {
        // 200 未満または400以上はエラーとみなす
        $result['message'] = "HTTP エラーまたは予期しないステータス: $httpCode";
      } else {
        // JSON 形式のレスポンスをデコード
        $rc_result = json_decode($response);
        // JSON 解析 OK
        if (json_last_error() === JSON_ERROR_NONE) {
          // 検証が成功しており、リクエスト元のホスト名(hostname)も一致していれば合格
          if ($rc_result->success && $rc_result->hostname === $_SERVER['SERVER_NAME']) {
            $result['status'] = true;
          } else {
            // エラーコードが提供されていれば確認
            if (isset($rc_result->{'error-codes'}) && is_array($rc_result->{'error-codes'})) {
              $result['message'] .= '(エラーコード: ' . implode(', ', $rc_result->{'error-codes'}) . ')';
            }
          }
        } else {
          $result['message'] = "JSONの解析に失敗しました: " . json_last_error_msg();
        }
      }
    }
    // cURL セッション終了
    curl_close($ch);
    return $result; // 検証結果を返す
  }
}
呼び出し側

呼び出し側(contact.php)では validate_mail_config() を使ってサイトキーとシークレットキーが mail_config.php で定義されていることをチェックし(14行目)、変数に代入します。

そして verify_recaptcha_v2() を呼び出し、戻り値の status を確認し、エラーがあれば $error['recaptcha_v2'] にメッセージを設定して、エラーログを出力します(必要に応じて)。検証が成功であれば処理を続行します。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// reCAPTCHA V2 サイトキーとシークレットキーの定義チェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['V2_SITEKEY', 'V2_SECRETKEY']
);

// reCAPTCHA V2 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v2_site_key   = V2_SITEKEY;
$v2_secret_key = V2_SECRETKEY;

・・・中略・・・
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {
  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  // reCAPTCHA v2 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_result = verify_recaptcha_v2(
    $v2_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_SERVER['REMOTE_ADDR']
  );

  // reCAPTCHA v2 検証が失敗した場合
  if (!$recaptcha_result['status']) {
    $error['recaptcha_v2'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }
  ・・・以下省略・・・
}
?>

$_POST['g-recaptcha-response']

$_POST['g-recaptcha-response'] は Google reCAPTCHA v2 によってユーザーが認証を完了したとき、HTML フォームの送信時に自動的に含まれるトークン値です。

このトークンは、Google が発行する一時的な認証トークンであり、有効期限つきです。トークンそのものを解釈する必要はなく、Google の API に送って検証してもらいます。関数 verify_recaptcha_v2 ではトークンをシークレットキーと共にリクエストして検証結果を取得しています 。

contact.php

以下は contact.php の全体のコードです。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// reCAPTCHA V2 サイトキーとシークレットキーの定義チェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['V2_SITEKEY', 'V2_SECRETKEY']
);

// reCAPTCHA V2 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v2_site_key   = V2_SITEKEY;
$v2_secret_key = V2_SECRETKEY;

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  // reCAPTCHA v2 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_result = verify_recaptcha_v2(
    $v2_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_SERVER['REMOTE_ADDR']
  );

  // reCAPTCHA v2 検証が失敗した場合
  if (!$recaptcha_result['status']) {
    $error['recaptcha_v2'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {
    mb_language('uni');
    mb_internal_encoding('UTF-8');
    $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";
    $returnMail = MAIL_RETURN_PATH;

    $header  = "MIME-Version: 1.0\n";
    $header .= "Content-Type: text/plain; charset=UTF-8\n";
    $header .= "Content-Transfer-Encoding: 8bit\n";
    $header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
    $header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . " <" . MAIL_CC . ">\n";
    }
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $header .= "Bcc: <" . MAIL_BCC . ">\n";
    }
    $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
    $mail_body .=  "お名前: " . h($name) . "\n";
    $mail_body .=  "Email: " . h($email) . "\n";
    $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
    $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

    if (ini_get('safe_mode')) {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
    } else {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
    }

    if ($result) {
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        mb_language('uni');
        mb_internal_encoding('UTF-8');

        $reply_header  = "MIME-Version: 1.0\n";
        $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
        $reply_header .= "Content-Transfer-Encoding: 8bit\n";
        $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
        $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";

        $reply_subject = 'お問い合わせ自動返信メール';
        $week = ['日', '月', '火', '水', '木', '金', '土'];
        $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
        $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
        if (ini_get('safe_mode')) {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header);
        } else {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header, '-f' . $returnMail);
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム rc2</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form class="js-form-validation contact-form rcv2" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <!-- reCAPTCHA v2 ウィジェットを表示する要素 -->
      <div id="recaptcha-v2"></div>
      <!-- reCAPTCHA v2 バリデーションエラー表示 -->
      <?php if (isset($error['recaptcha_v2'])): ?>
        <p><span class="error-php"><?php echo h($error['recaptcha_v2']); ?></span></p>
      <?php endif; ?>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <script src="../assets/js/form-validation.js"></script>

  <!-- reCAPTCHA サイトキーを JavaScript に渡す -->
  <script>
    window.recaptchaV2SiteKey = "<?php echo $v2_site_key; ?>";
  </script>

  <!-- recaptcha-v2-handler.js の読み込み -->
  <script src="../assets/js/recaptcha-v2-handler.js"></script>

  <!-- Google reCAPTCHA API スクリプトの読み込み(onloadCallback を呼び出す)-->
  <script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>
</body>

</html>

complete.php

complete.php は 最初の例 と同じなので省略します。

helpers.php

以下は reCAPTCHA を検証する関数を追加した helpers.php の全体のコードです。

<?php
if (!function_exists('h')) {
  /**
   * エスケープ処理を行う関数
   *
   * @param string|array|null  $var チェックする文字列
   */
  function h($var) {
    if (is_array($var)) {
      //$varが配列の場合、h()関数をそれぞれの要素について呼び出す(再帰)
      return array_map('h', $var);
    } else {
      if ($var === null) return ''; // PHP 8.1.x 対策(null を渡すと Deprecated エラー)
      return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
    }
  }
}

if (!function_exists('checkInput')) {
  /**
   * 入力値に不正なデータがないかなどをチェックする関数
   *
   * @param string|array  $var チェックする文字列
   */
  function checkInput($var) {
    if (is_array($var)) {
      return array_map('checkInput', $var);
    } else {
      // NULLバイト(\0)攻撃対策
      if (preg_match('/\0/', $var)) {
        die('不正な入力です。');
      }
      // 文字エンコードのチェック
      if (!mb_check_encoding($var, 'UTF-8')) {
        die('不正な入力です。');
      }
      // 改行、タブ以外の制御文字のチェック([:^cntrl:] は「制御文字以外」を意味するPOSIX文字クラス)
      if (preg_match('/\A[\r\n\t[:^cntrl:]]*\z/u', $var) === 0) {
        die('不正な入力です。制御文字は使用できません。');
      }
      return $var;
    }
  }
}

if (!function_exists('validate_mail_config')) {
  /**
   * mail_config.php に定義された定数を検証する。
   *
   * @param array $required_emails 必須のメールアドレス定数
   * @param array $optional_emails 任意のメールアドレス定数
   * @param array $required_names  必須の名前定数
   * @param array $optional_names  任意の名前定数
   * @param array $required_keys   その他必須の定数(空文字でないことをチェック)
   */
  function validate_mail_config(
    array $required_emails,
    array $optional_emails,
    array $required_names,
    array $optional_names,
    array $required_keys = []
  ) {
    $errors = [];

    // 必須メール定数のチェック
    foreach ($required_emails as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (!filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // オプションメール定数のチェック(定義されていればチェック)
    foreach ($optional_emails as $const) {
      if (defined($const) && !filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // 必須名前定数のチェック(改行禁止)
    foreach ($required_names as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行が含まれています。";
      }
    }

    // オプション名前定数のチェック
    foreach ($optional_names as $const) {
      if (defined($const) && preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行文字が含まれています。";
      }
    }

    // その他の必須定数(空文字でないこと)
    foreach ($required_keys as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (trim(constant($const)) === '') {
        $errors[] = "$const が空です。";
      }
    }

    // エラー表示
    if (!empty($errors)) {
      echo '<h3 style="color:red;">mail_config.php に不正な定義があります:</h3>';
      echo '<ul>';
      foreach ($errors as $error) {
        echo '<li style="color:red;">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</li>';
      }
      echo '</ul>';
      exit('mail_config.php の内容を修正してください。');
    }
  }
}

if (!function_exists('verify_recaptcha_v2')) {
  /**
   * Google reCAPTCHA v2 を検証する関数
   *
   * @param string $secret         reCAPTCHA v2 のシークレットキー
   * @param string $response_token ユーザーから送られたトークン( $_POST['g-recaptcha-response'] )
   * @param string $remote_ip      クライアントのIPアドレス(通常は $_SERVER['REMOTE_ADDR'])
   * @return array                 ['status' => true/false, 'message' => string]
   */
  function verify_recaptcha_v2($secret, $response_token, $remote_ip) {
    // 検証結果の初期化
    $result = [
      'status' => false,  // 初期値は失敗(false)に設定
      'message' => ''  // 検証失敗時のエラーメッセージ(成功時は空文字)
    ];

    // reCAPTCHA トークンの存在チェック
    if ($response_token === '') {
      $result['message'] = 'reCAPTCHAトークンが送信されていません。';
      return $result;
    }

    // Google の検証 API へリクエスト送信(cURL)
    $ch = curl_init(); // cURL セッションを初期化
    curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");  // API の UR
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // curl_exec の結果を文字列として返す
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 接続タイムアウト(秒)
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);  // 実行タイムアウト(秒)
    curl_setopt($ch, CURLOPT_POST, true); // POST メソッドを使う
    // reCAPTCHA API パラメータを POST データに指定
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
      'secret' => $secret, // シークレットキー
      'response' => $response_token, // reCAPTCHA トークン
      'remoteip' => $remote_ip, // IPアドレス送信パラメータ
    ]));

    // cURL セッションのレスポンス
    $response = curl_exec($ch);
    //  通信・レスポンスのエラーハンドリング
    if ($response === false) {
      // cURL レベルの失敗(ネットワーク、タイムアウトなど)
      $result['message'] = "cURL エラー番号: " . curl_errno($ch) . " エラーメッセージ: " . curl_error($ch);
    } else {
      // ステータスコードをチェック
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($httpCode < 200 || $httpCode >= 400) {
        // 200 未満または400以上はエラーとみなす
        $result['message'] = "HTTP エラーまたは予期しないステータス: $httpCode";
      } else {
        // JSON 形式のレスポンスをデコード
        $rc_result = json_decode($response);
        // JSON 解析 OK
        if (json_last_error() === JSON_ERROR_NONE) {
          // 検証が成功しており、リクエスト元のホスト名(hostname)も一致していれば合格
          if ($rc_result->success && $rc_result->hostname === $_SERVER['SERVER_NAME']) {
            $result['status'] = true;
          } else {
            // エラーコードが提供されていれば確認
            if (isset($rc_result->{'error-codes'}) && is_array($rc_result->{'error-codes'})) {
              $result['message'] .= '(エラーコード: ' . implode(', ', $rc_result->{'error-codes'}) . ')';
            }
          }
        } else {
          $result['message'] = "JSONの解析に失敗しました: " . json_last_error_msg();
        }
      }
    }
    // cURL セッション終了
    curl_close($ch);
    return $result; // 検証結果を返す
  }
}

v2 と v3 の違い

reCAPTCHA v2 はユーザーに「私はロボットではありません」と操作させて判定するのに対し、v3 はユーザーの行動からスコアを算出し、サイト側がリスクに応じて対処を決める仕組みです。

reCAPTCHA v2と v3
項目 reCAPTCHA v2 reCAPTCHA v3
ユーザー操作 「私はロボットではありません」チェックや画像選択あり 完全自動、ユーザー操作なし
判定方式 インタラクションベース(人間かロボットかを直接確認) スコア(0.0〜1.0)に基づくリスク評価
UX 操作が必要、場合によって面倒 フォーム送信前に自動で検証
バッジ表示 通常は表示されない ページ右下にバッジが常時表示(デフォルト)
バッジの非表示 表示されないため不要 CSSで非表示可だが、Google規約に従い代替表示が必要
API 実行タイミング ユーザー操作時(チェックや画像認証) grecaptcha.execute() を手動で呼び出し
結果の形式 チェック済みトークン スコア付きトークン(0.0 に近いほどロボットと判定)

reCAPTCHA v3 の実装

以下のリンクから reCAPTCHA v3 を使ったサンプルで動作を確認できます。

動作サンプルを別ページで確認する

以下はこの例のファイル構成です。

reCAPTCHA v3 の実装に使用する JavaScript ファイル(recaptcha-v3-handler.js)を追加し、helpers.php に reCAPTCHA を検証する PHP 関数を追加するなどいくつかのファイルを変更します。

├── assets
│   ├── css
│   │   └── contact-style.css
│   └── js
│       ├── form-validation.js
│       └── recaptcha-v3-handler.js  // reCAPTCHA V3 のトークンを送信する JavaScript
├── contact
│   ├── contact.php  // JavaScript や PHP 関数の読み込み、HTML などを追加
│   └── complete.php
└── includes
    ├── .htaccess
    ├── helpers.php  // reCAPTCHA を検証する PHP 関数 verify_recaptcha_v3() を追加
    ├── index.php
    └── mail_config.php  //サイトキーとシークレットキーの定義を追加

Google reCAPTCHA を利用するにはサイトを登録して、サイトキーとシークレットキーを取得しておく必要があります(関連:reCAPTCHA にサイトを登録)。

mail_config.php

reCAPTCHA v3 のサイトキーとシークレットキーの定義を mail_config.php に追加します。

また、動作確認用に reCAPTCHA v3 のスコアなどの判定結果を完了画面に表示するかどうかのフラグを定義し、デフォルトでは false に設定します(本番環境では結果を表示しないように false にします)。

<?php
if (basename($_SERVER['PHP_SELF']) === basename(__FILE__)) {
  http_response_code(403);
  exit('Forbidden');
}

define('MAIL_TO', 'info@example.com');
define('MAIL_TO_NAME', 'Example');
define('MAIL_FROM', 'info@example.com');
define('MAIL_FROM_NAME', 'Example');
define('MAIL_RETURN_PATH', 'info@example.com');

define('SHOW_INPUT_VALUES_WITH_RESULT', true);
define('AUTO_REPLY_ENABLED', true);
define('AUTO_REPLY_NAME', '自動返信 Example');

define('MAIL_CC', 'cc@examplex.com');
define('MAIL_CC_NAME', 'Ccの宛先名');
define('MAIL_BCC', 'bcc@example.com');

// reCAPTCHA v3 サイトキー(追加)
define('V3_SITEKEY', 'xxxxxxxxxxxxxxxxxxxxxxx');
// reCAPTCHA v3 シークレットキー(追加)
define('V3_SECRETKEY', 'xxxxxxxxxxxxxxxxxxxxxxx');

// 動作確認用の reCAPTCHA v3 の判定結果を完了画面に表示するかどうか(追加)
// 本番環境では必ず false に設定します
define('SHOW_RECAPTCHA_V3_RESULT', false);

JavaScript

assets/js フォルダに recaptcha-v3-handler.js というファイルを作成して以下を記述します。

この JavaScript コードは、Google reCAPTCHA v3 をフォーム送信時に組み込む実装です。

フォーム送信前(検証の後)に reCAPTCHA v3 のトークンを取得し、フォームに埋め込んだうえでサーバーに送信します。バリデーションのスクリプトにより生成される .error-js クラスの要素が存在しなければ(エラーがなければ)クライアント側バリデーションに通ったとみなして送信を許可します。

  • 対象フォームやアクション名、エラーメッセージ用のクラスセレクタを変数化
  • サイトキー(window.recaptchaV3SiteKey)はグローバル変数としてHTML側で埋め込んでおく前提
  • サイトキーが未定義の場合やフォームが存在しない場合は早期リターンしてエラーを回避
  • submit イベントで送信を一旦止め、reCAPTCHA のトークンを非同期で取得
  • 入力値の検証でエラーがなければ、g-recaptcha-response と action をフォームに追加して送信
  • ネットワーク障害や reCAPTCHA エラー時にユーザーに警告表示
// ページ全体の DOM 構造が構築された後に reCAPTCHA 初期化処理を開始
document.addEventListener("DOMContentLoaded", () => {
  // 対象のフォーム要素のクラス(セレクタ)
  const targetFormClass = ".rcv3";
  //アクション名
  const action_name = "contact";
  // エラーメッセージ要素のクラス(セレクタ)
  const errorMessageClass = ".error-js";

  // グローバル変数からサイトキー取得
  const siteKey = window.recaptchaV3SiteKey;
  // サイトキーが定義されていなければ中止
  if (!siteKey) {
    console.warn("reCAPTCHA サイトキーが定義されていないため機能しません。");
    return;
  }

  // targetFormClass で指定したクラスを持つ form 要素を取得
  const validationForm = document.querySelector(targetFormClass);

  // フォームが存在しない場合はコンソールに警告を出力し、エラーを回避して終了
  if (!validationForm) {
    console.warn("警告:対象のフォームが存在しないため、reCAPTCHA v3 が正しく機能しません。");
    return;
  }

  //フォーム要素に submit イベントハンドラを設定
  validationForm.addEventListener("submit", (e) => {
    //デフォルトの動作(送信)を停止
    e.preventDefault();
    //トークンを取得
    grecaptcha.ready(function () {
      grecaptcha
        .execute(siteKey, {
          action: action_name,
        })
        .then(function (token) {
          // エラーメッセージ要素をすべて取得
          const errorElems = document.querySelectorAll(errorMessageClass);
          // エラーメッセージ要素が存在しなければ(数が0であれば)
          if (errorElems.length === 0) {
            // トークンを hidden input に追加(フォームに埋め込む)
            const token_input = document.createElement("input"); //input 要素を生成
            token_input.type = "hidden";
            token_input.name = "g-recaptcha-response";
            token_input.value = token; //トークンを値に設定
            validationForm.appendChild(token_input);
            const action_input = document.createElement("input"); //input 要素を生成
            action_input.type = "hidden";
            action_input.name = "action";
            action_input.value = action_name; //アクション名を値に設定
            validationForm.appendChild(action_input);
            validationForm.submit(); //フォームを送信
          }
        })
        .catch(function (error) {
          alert("reCAPTCHAの認証に失敗しました。再度お試しください。");
        });
    });
  });
});

上記の場合、送信ボタンをクリックした際にトークンを取得するので、トークンの有効期限は送信ボタンをクリックしてから2分間になります。

サイトキー(siteKey)は、別途 contact.php で以下のように PHP 側から JS に値を渡します。

<!-- reCAPTCHA サイトキーを JavaScript に渡す -->
<script>
  window.recaptchaV3SiteKey = "<?php echo $v3_site_key; ?>";
</script>

HTML

以下は reCAPTCHA v3 を実装する contact.php の一部抜粋です。

JavaScript で変数 targetFormClass に定義したクラス名 rcv3 を form 要素に指定します(5行目)。

サーバーサイドのバリデーション結果を反映するための処理を追加します(17-19行目)。バリデーションに失敗したときに、PHP のエラーメッセージ $error['recaptcha_v3'] が表示されます。

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム rcv3</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form class="js-form-validation contact-form rcv3" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>

        ・・・中略・・・

      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
      <!-- reCAPTCHA v3 バリデーションエラー表示 -->
      <?php if (isset($error['recaptcha_v3'])): ?>
        <p><span class="error-php"><?php echo h($error['recaptcha_v3']); ?></span></p>
      <?php endif; ?>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <!--  検証用の JavaScript の読み込み -->
  <script src="../assets/js/form-validation.js"></script>

  <!-- reCAPTCHA サイトキーを JavaScript に渡す -->
  <script>
    window.recaptchaV3SiteKey = "<?php echo $v3_site_key; ?>";
  </script>
  <!-- Google reCAPTCHA API スクリプトの読み込み-->
  <script src="https://www.google.com/recaptcha/api.js?render=<?php echo $v3_site_key; ?>"></script>
  <!-- recaptcha-v3-handler.js の読み込み -->
  <script src="../assets/js/recaptcha-v3-handler.js"></script>
</body>

サイトキーと JavaScript の読み込み

recaptcha-v3-handler.js の中で window.recaptchaV3SiteKey を参照しているため、まず、サイトキーの値を PHP 側から JS に渡します(29-31行目)。

続いて、Google reCAPTCHA API 本体スクリプトの読み込みを記述します。このスクリプトが読み込まれると、reCAPTCHA v3 の バッジも自動で画面右下に表示されます

最後に先に作成した recaptcha-v3-handler.js を読み込みます。

PHP

includes/helpers.php に以下の関数 verify_recaptcha_v3() の定義を追加します。

この関数は、クライアントから送られてきた reCAPTCHA v3 のトークン(g-recaptcha-response)を使って、Google の verify API に問い合わせを行い、返されるスコア付きのレスポンスをもとに「人間によるアクセスかどうか」を判定する関数です。スコアが指定したしきい値(threshold)以上であれば「人間」、それ未満であれば「ボットの可能性が高い」と判断します。

  1. トークンとアクション名の存在チェック
    • 入力が空だった場合、即エラーとして status: false を返します。
  2. cURL による Google API への問い合わせ
    • https://www.google.com/recaptcha/api/siteverify に POST リクエストを送信。
    • secret, response, remoteip を POST データとして送信。
  3. 通信エラー・HTTP エラーの検出
    • curl_exec() の失敗や HTTP ステータスコード(200 以外)をチェック。
    • 問題があれば適切なメッセージで失敗を返します。
  4. JSON 解析と検証ロジック
    • レスポンスの JSON を json_decode()。
    • 以下3つを満たす場合に status: true として「成功」と判定:
      • success === true
      • action === $response_action
      • score >= $threshold
  5. 失敗時のメッセージ出力
  6. 結果の返却
    • 判定結果は配列で返され、status が真偽値として重要な判定要素になります。
if (!function_exists('verify_recaptcha_v3')) {
  /**
   * Google reCAPTCHA v3 を検証する関数
   *
   * @param string $secret           reCAPTCHA v3 のシークレットキー
   * @param string $response_token   ユーザーから送られたトークン( $_POST['g-recaptcha-response'] )
   * @param string $response_action  ユーザーから送られたアクション名( $_POST['action'] )
   * @param string $remote_ip        クライアントのIPアドレス(通常は $_SERVER['REMOTE_ADDR'])
   * @param float  $threshold        判定に使用するしきい値の値 (デフォルトは 0.5)
   * @return array                   ['status' => true/false, 'message' => string]
   */
  function verify_recaptcha_v3($secret, $response_token, $response_action, $remote_ip, $threshold = 0.5) {

    // 検証結果の初期化
    $result = [
      'status' => false,  // 初期値は失敗(false)に設定
      'message' => ''  // 検証失敗時のエラーメッセージ(成功時は空文字)
    ];

    // reCAPTCHA トークンの存在チェック
    if ($response_token === '') {
      $result['message'] = 'reCAPTCHAトークンが送信されていません。';
      return $result;
    }

    // reCAPTCHA アクション名の存在チェック
    if ($response_action === '') {
      $result['message'] = 'reCAPTCHAアクション名が送信されていません。';
      return $result;
    }

    // Google の検証 API へリクエスト送信(cURL)
    $ch = curl_init(); // cURL セッションを初期化
    curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");  // API の URL
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // curl_exec の結果を文字列として返す
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 接続タイムアウト(秒)
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);  // 実行タイムアウト(秒)
    curl_setopt($ch, CURLOPT_POST, true); // POST メソッドを使う
    // reCAPTCHA API パラメータを POST データに指定
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
      'secret' => $secret, // シークレットキー
      'response' => $response_token, // reCAPTCHA トークン
      'remoteip' => $remote_ip, // IPアドレス送信パラメータ
    ]));

    // cURL セッションのレスポンス
    $response = curl_exec($ch);
    //  通信・レスポンスのエラーハンドリング
    if ($response === false) {
      // cURL レベルの失敗(ネットワーク、タイムアウトなど)
      $result['message'] = "cURL エラー番号: " . curl_errno($ch) . " エラーメッセージ: " . curl_error($ch);
    } else {
      // ステータスコードをチェック
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($httpCode < 200 || $httpCode >= 400) {
        // 200 未満または400以上はエラーとみなす
        $result['message'] = "HTTP エラーまたは予期しないステータス: $httpCode";
      } else {
        // JSON 形式のレスポンスをデコード
        $rc_result = json_decode($response);
        // JSON 解析 OK
        if (json_last_error() === JSON_ERROR_NONE) {
          // Google の verify API レスポンスを判定
          if (
            isset($rc_result->success, $rc_result->action, $rc_result->score) &&
            $rc_result->success === true &&
            $rc_result->action === $response_action &&
            $rc_result->score >= $threshold
          ) {
            //success が true でアクション名が一致し、スコアが threshold(0.5)以上の場合は合格
            $result['status'] = true;
            $result['success'] = $rc_result->success;
            $result['action'] = $rc_result->action;
            $result['score'] = $rc_result->score;
          } else {
            // 失敗理由を個別に判定して message に格納
            if ($rc_result->success !== true) {
              $result['message'] = 'reCAPTCHAが失敗しました(success=false)。';
            } elseif ($rc_result->action !== $response_action) {
              $result['message'] = "アクション名が一致しません(期待値: {$response_action}, 実際: {$rc_result->action})";
            } elseif ($rc_result->score < $threshold) {
              $result['message'] = "スコアが閾値を下回りました(スコア: {$rc_result->score} / 閾値: {$threshold})";
            }
            // エラーコードが提供されていばメッセージに追加
            if (isset($rc_result->{'error-codes'}) && is_array($rc_result->{'error-codes'})) {
              $result['message'] .= '(エラーコード: ' . implode(', ', $rc_result->{'error-codes'}) . ')';
            }
          }
        } else {
          $result['message'] = "JSONの解析に失敗しました: " . json_last_error_msg();
        }
      }
    }
    // cURL セッション終了
    curl_close($ch);
    return $result; // 検証結果を返す
  }
}

$threshold 判定に使用するしきい値の値

reCAPTCHA v3 では score(0.0〜1.0)を返し、値が高いほど「人間らしい」アクセスとされます。

$threshold の値は「人間と判定する最低スコア」を意味し、省略した場合は 0.5(人間とボットのちょうど中間)を使用します。

$threshold 値 意味・適用例
0.9 ~ 0.8 かなり厳しい。重要なフォーム(アカウント登録・決済など)に適用。
0.7 高セキュリティなケース向け。ボット排除を重視。
0.5(標準) 通常の問い合わせフォーム・コメント投稿な。
0.3 ~ 0.4 ゆるめの判定。誤判定(人間なのにブロック)を減らしたい場合。
0.1 以下 ほぼ常に通る。reCAPTCHA の意味が薄れる(非推奨)。
呼び出し側

呼び出し側(contact.php)では validate_mail_config() を使ってサイトキーとシークレットキーが mail_config.php で定義されていることをチェックし(14行目)、変数に代入します。

サーバー側の送信判定では、$_SERVER['REQUEST_METHOD'] === 'POST' を確認した上で、$_POST['g-recaptcha-response'] が存在し、かつ空でないことを条件にします。これは、reCAPTCHA v3 のトークンがクライアント側 JavaScript によって生成・埋め込まれていることを前提とした送信判定方法です(23行目)。

v3 では grecaptcha.execute() の完了後に JavaScript から form.submit() を呼び出すため、送信ボタン(name="contact-form-submit")の name や value 属性の値は POST に含まれないため、送信ボタンの name 属性による判定ではなく、g-recaptcha-response の有無を基準とします。

トークンの検証には helpers.php に定義した verify_recaptcha_v3() を用い、GoogleのAPIレスポンスを元に status を判定します(34-40)。エラーがある場合は $error['recaptcha_v3'] にメッセージを設定し、ログを出力して不正送信やスパムからの保護を行います(43-47)。

また、検証結果(status, score, action など)をセッションに保持することで、reCAPTCHA v3の検証状況を完了画面で確認・表示できるようにしています(72-76)。検証結果を完了画面に表示するかどうかは、mail_config.php の定数 SHOW_RECAPTCHA_V3_RESULT で設定します。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// reCAPTCHA V3 サイトキーとシークレットキーの定義チェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['V3_SITEKEY', 'V3_SECRETKEY']
);

// reCAPTCHA V3 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v3_site_key   = V3_SITEKEY;
$v3_secret_key = V3_SECRETKEY;
・・・中略・・・

//★トークン($_POST['g-recaptcha-response'])が設定されていて中身が空でなければ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['g-recaptcha-response'])) {
  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }
  $_POST = checkInput($_POST);
  $error = array();

  // reCAPTCHA v3 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_v3_result = verify_recaptcha_v3(
    $v3_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_POST['action'] ?? '',
    $_SERVER['REMOTE_ADDR'],
    0.7  // 閾値(省略時は 0.5)
  );

  // reCAPTCHA v3 検証が失敗した場合
  if (!$recaptcha_v3_result['status']) {
    $error['recaptcha_v3'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_v3_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }
  ・・・中略・・・
  if (empty($error)) {
  ・・・中略・・・

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      // reCAPTCHA 検証結果確認用の値をセッションに保持
      $_SESSION['rcv3_result'] = [
        'success'    => $recaptcha_v3_result['success'],
        'action'   =>  $recaptcha_v3_result['action'],
        'score'     =>  $recaptcha_v3_result['score'],
      ];

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
・・・以下省略・・・

contact.php

以下は contact.php のコード全体です。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// reCAPTCHA V3 サイトキーとシークレットキーの定義チェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['V3_SITEKEY', 'V3_SECRETKEY']
);

// reCAPTCHA V3 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v3_site_key   = V3_SITEKEY;
$v3_secret_key = V3_SECRETKEY;

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

//if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) を以下に変更
//★トークン($_POST['g-recaptcha-response'])が設定されていて中身が空でなければ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['g-recaptcha-response'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  // reCAPTCHA v3 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_v3_result = verify_recaptcha_v3(
    $v3_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_POST['action'] ?? '',
    $_SERVER['REMOTE_ADDR'],
    0.7
  );

  // reCAPTCHA v3 検証が失敗した場合
  if (!$recaptcha_v3_result['status']) {
    $error['recaptcha_v3'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_v3_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {
    mb_language('uni');
    mb_internal_encoding('UTF-8');
    $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . " <" . MAIL_TO . ">";
    $returnMail = MAIL_RETURN_PATH;

    $header  = "MIME-Version: 1.0\n";
    $header .= "Content-Type: text/plain; charset=UTF-8\n";
    $header .= "Content-Transfer-Encoding: 8bit\n";
    $header .= "From: " . mb_encode_mimeheader(MAIL_FROM_NAME) . " <" . MAIL_FROM . ">\n";
    $header .= "Reply-To: " . mb_encode_mimeheader($name) . " <" . $email . ">\n";
    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . " <" . MAIL_CC . ">\n";
    }
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $header .= "Bcc: <" . MAIL_BCC . ">\n";
    }
    $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
    $mail_body .=  "お名前: " . h($name) . "\n";
    $mail_body .=  "Email: " . h($email) . "\n";
    $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
    $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

    if (ini_get('safe_mode')) {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
    } else {
      $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
    }

    if ($result) {
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        mb_language('uni');
        mb_internal_encoding('UTF-8');

        $reply_header  = "MIME-Version: 1.0\n";
        $reply_header .= "Content-Type: text/plain; charset=UTF-8\n";
        $reply_header .= "Content-Transfer-Encoding: 8bit\n";
        $reply_header .= "From: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_FROM . ">\n";
        $reply_header .= "Reply-To: " . mb_encode_mimeheader(AUTO_REPLY_NAME) . " <" . MAIL_TO . ">\n";

        $reply_subject = 'お問い合わせ自動返信メール';
        $week = ['日', '月', '火', '水', '木', '金', '土'];
        $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
        $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
        if (ini_get('safe_mode')) {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header);
        } else {
          $reply_result = mb_send_mail($email, $reply_subject, $reply_body, $reply_header, '-f' . $returnMail);
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      // reCAPTCHA 検証結果確認用の値をセッションに保持
      $_SESSION['rcv3_result'] = [
        'success'    => $recaptcha_v3_result['success'],
        'action'   =>  $recaptcha_v3_result['action'],
        'score'     =>  $recaptcha_v3_result['score'],
      ];

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム rcv3</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form class="js-form-validation contact-form rcv3" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
      <!-- reCAPTCHA v3 バリデーションエラー表示 -->
      <?php if (isset($error['recaptcha_v3'])): ?>
        <p><span class="error-php"><?php echo h($error['recaptcha_v3']); ?></span></p>
      <?php endif; ?>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <!--  検証用の JavaScript の読み込み -->
  <script src="../assets/js/form-validation.js"></script>

  <!-- reCAPTCHA サイトキーを JavaScript に渡す -->
  <script>
    window.recaptchaV3SiteKey = "<?php echo $v3_site_key; ?>";
  </script>

  <!-- Google reCAPTCHA API スクリプトの読み込み-->
  <script src="https://www.google.com/recaptcha/api.js?render=<?php echo $v3_site_key; ?>"></script>

  <!-- recaptcha-v3-handler.js の読み込み -->
  <script src="../assets/js/recaptcha-v3-handler.js"></script>
</body>

</html>

helpers.php

以下は reCAPTCHA V3 を検証する関数を追加した helpers.php のコード全体です。

<?php
if (!function_exists('h')) {
  /**
   * エスケープ処理を行う関数
   *
   * @param string|array|null  $var チェックする文字列
   */
  function h($var) {
    if (is_array($var)) {
      //$varが配列の場合、h()関数をそれぞれの要素について呼び出す(再帰)
      return array_map('h', $var);
    } else {
      if ($var === null) return ''; // PHP 8.1.x 対策(null を渡すと Deprecated エラー)
      return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
    }
  }
}

if (!function_exists('checkInput')) {
  /**
   * 入力値に不正なデータがないかなどをチェックする関数
   *
   * @param string|array  $var チェックする文字列
   */
  function checkInput($var) {
    if (is_array($var)) {
      return array_map('checkInput', $var);
    } else {
      // NULLバイト(\0)攻撃対策
      if (preg_match('/\0/', $var)) {
        die('不正な入力です。');
      }
      // 文字エンコードのチェック
      if (!mb_check_encoding($var, 'UTF-8')) {
        die('不正な入力です。');
      }
      // 改行、タブ以外の制御文字のチェック([:^cntrl:] は「制御文字以外」を意味するPOSIX文字クラス)
      if (preg_match('/\A[\r\n\t[:^cntrl:]]*\z/u', $var) === 0) {
        die('不正な入力です。制御文字は使用できません。');
      }
      return $var;
    }
  }
}

if (!function_exists('validate_mail_config')) {
  /**
   * mail_config.php に定義された定数を検証する。
   *
   * @param array $required_emails 必須のメールアドレス定数
   * @param array $optional_emails 任意のメールアドレス定数
   * @param array $required_names  必須の名前定数
   * @param array $optional_names  任意の名前定数
   * @param array $required_keys   その他必須の定数(空文字でないことをチェック)
   */
  function validate_mail_config(
    array $required_emails,
    array $optional_emails,
    array $required_names,
    array $optional_names,
    array $required_keys = []
  ) {
    $errors = [];

    // 必須メール定数のチェック
    foreach ($required_emails as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (!filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // オプションメール定数のチェック(定義されていればチェック)
    foreach ($optional_emails as $const) {
      if (defined($const) && !filter_var(constant($const), FILTER_VALIDATE_EMAIL)) {
        $errors[] = "$const の形式が不正です。";
      }
    }

    // 必須名前定数のチェック(改行禁止)
    foreach ($required_names as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行が含まれています。";
      }
    }

    // オプション名前定数のチェック
    foreach ($optional_names as $const) {
      if (defined($const) && preg_match("/[\r\n]/", constant($const))) {
        $errors[] = "$const に改行文字が含まれています。";
      }
    }

    // その他の必須定数(空文字でないこと)
    foreach ($required_keys as $const) {
      if (!defined($const)) {
        $errors[] = "$const が定義されていません。";
      } elseif (trim(constant($const)) === '') {
        $errors[] = "$const が空です。";
      }
    }

    // エラー表示
    if (!empty($errors)) {
      echo '<h3 style="color:red;">mail_config.php に不正な定義があります:</h3>';
      echo '<ul>';
      foreach ($errors as $error) {
        echo '<li style="color:red;">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</li>';
      }
      echo '</ul>';
      exit('mail_config.php の内容を修正してください。');
    }
  }
}

if (!function_exists('verify_recaptcha_v2')) {
  /**
   * Google reCAPTCHA v2 を検証する関数
   *
   * @param string $secret         reCAPTCHA v2 のシークレットキー
   * @param string $response_token ユーザーから送られたトークン( $_POST['g-recaptcha-response'] )
   * @param string $remote_ip      クライアントのIPアドレス(通常は $_SERVER['REMOTE_ADDR'])
   * @return array                 ['status' => true/false, 'message' => string]
   */
  function verify_recaptcha_v2($secret, $response_token, $remote_ip) {
    // 検証結果の初期化
    $result = [
      'status' => false,  // 初期値は失敗(false)に設定
      'message' => ''  // 検証失敗時のエラーメッセージ(成功時は空文字)
    ];

    // reCAPTCHA トークンの存在チェック
    if ($response_token === '') {
      $result['message'] = 'reCAPTCHAトークンが送信されていません。';
      return $result;
    }

    // Google の検証 API へリクエスト送信(cURL)
    $ch = curl_init(); // cURL セッションを初期化
    curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");  // API の UR
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // curl_exec の結果を文字列として返す
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 接続タイムアウト(秒)
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);  // 実行タイムアウト(秒)
    curl_setopt($ch, CURLOPT_POST, true); // POST メソッドを使う
    // reCAPTCHA API パラメータを POST データに指定
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
      'secret' => $secret, // シークレットキー
      'response' => $response_token, // reCAPTCHA トークン
      'remoteip' => $remote_ip, // IPアドレス送信パラメータ
    ]));

    // cURL セッションのレスポンス
    $response = curl_exec($ch);
    //  通信・レスポンスのエラーハンドリング
    if ($response === false) {
      // cURL レベルの失敗(ネットワーク、タイムアウトなど)
      $result['message'] = "cURL エラー番号: " . curl_errno($ch) . " エラーメッセージ: " . curl_error($ch);
    } else {
      // ステータスコードをチェック
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($httpCode < 200 || $httpCode >= 400) {
        // 200 未満または400以上はエラーとみなす
        $result['message'] = "HTTP エラーまたは予期しないステータス: $httpCode";
      } else {
        // JSON 形式のレスポンスをデコード
        $rc_result = json_decode($response);
        // JSON 解析 OK
        if (json_last_error() === JSON_ERROR_NONE) {
          // 検証が成功しており、リクエスト元のホスト名(hostname)も一致していれば合格
          if ($rc_result->success && $rc_result->hostname === $_SERVER['SERVER_NAME']) {
            $result['status'] = true;
          } else {
            // エラーコードが提供されていれば確認
            if (isset($rc_result->{'error-codes'}) && is_array($rc_result->{'error-codes'})) {
              $result['message'] .= '(エラーコード: ' . implode(', ', $rc_result->{'error-codes'}) . ')';
            }
          }
        } else {
          $result['message'] = "JSONの解析に失敗しました: " . json_last_error_msg();
        }
      }
    }
    // cURL セッション終了
    curl_close($ch);
    return $result; // 検証結果を返す
  }
}

if (!function_exists('verify_recaptcha_v3')) {
  /**
   * Google reCAPTCHA v3 を検証する関数
   *
   * @param string $secret         reCAPTCHA v3 のシークレットキー
   * @param string $response_token ユーザーから送られたトークン( $_POST['g-recaptcha-response'] )
   * @param string $response_action ユーザーから送られたアクション名( $_POST['action'] )
   * @param string $remote_ip      クライアントのIPアドレス(通常は $_SERVER['REMOTE_ADDR'])
   * @param float  $threshold      判定に使用する閾値の値 (デフォルトは 0.5)
   * @return array                 ['status' => true/false, 'message' => string]
   */
  function verify_recaptcha_v3($secret, $response_token, $response_action, $remote_ip, $threshold = 0.5) {

    // 検証結果の初期化
    $result = [
      'status' => false,  // 初期値は失敗(false)に設定
      'message' => ''  // 検証失敗時のエラーメッセージ(成功時は空文字)
    ];

    // reCAPTCHA トークンの存在チェック
    if ($response_token === '') {
      $result['message'] = 'reCAPTCHAトークンが送信されていません。';
      return $result;
    }

    // reCAPTCHA アクション名の存在チェック
    if ($response_action === '') {
      $result['message'] = 'reCAPTCHAアクション名が送信されていません。';
      return $result;
    }

    // Google の検証 API へリクエスト送信(cURL)
    $ch = curl_init(); // cURL セッションを初期化
    curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");  // API の URL
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // curl_exec の結果を文字列として返す
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 接続タイムアウト(秒)
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);  // 実行タイムアウト(秒)
    curl_setopt($ch, CURLOPT_POST, true); // POST メソッドを使う
    // reCAPTCHA API パラメータを POST データに指定
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
      'secret' => $secret, // シークレットキー
      'response' => $response_token, // reCAPTCHA トークン
      'remoteip' => $remote_ip, // IPアドレス送信パラメータ
    ]));

    // cURL セッションのレスポンス
    $response = curl_exec($ch);
    //  通信・レスポンスのエラーハンドリング
    if ($response === false) {
      // cURL レベルの失敗(ネットワーク、タイムアウトなど)
      $result['message'] = "cURL エラー番号: " . curl_errno($ch) . " エラーメッセージ: " . curl_error($ch);
    } else {
      // ステータスコードをチェック
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($httpCode < 200 || $httpCode >= 400) {
        // 200 未満または400以上はエラーとみなす
        $result['message'] = "HTTP エラーまたは予期しないステータス: $httpCode";
      } else {
        // JSON 形式のレスポンスをデコード
        $rc_result = json_decode($response);
        // JSON 解析 OK
        if (json_last_error() === JSON_ERROR_NONE) {
          // レスポンスを判定
          if (
            isset($rc_result->success, $rc_result->action, $rc_result->score) &&
            $rc_result->success === true &&
            $rc_result->action === $response_action &&
            $rc_result->score >= $threshold
          ) {
            //success が true でアクション名が一致し、スコアが threshold(0.5)以上の場合は合格
            $result['status'] = true;
            $result['success'] = $rc_result->success;
            $result['action'] = $rc_result->action;
            $result['score'] = $rc_result->score;
          } else {
            // 失敗理由を個別に判定して message に格納
            if ($rc_result->success !== true) {
              $result['message'] = 'reCAPTCHAが失敗しました(success=false)。';
            } elseif ($rc_result->action !== $response_action) {
              $result['message'] = "アクション名が一致しません(期待値: {$response_action}, 実際: {$rc_result->action})";
            } elseif ($rc_result->score < $threshold) {
              $result['message'] = "スコアが閾値を下回りました(スコア: {$rc_result->score} / 閾値: {$threshold})";
            }
            // エラーコードが提供されていばメッセージに追加
            if (isset($rc_result->{'error-codes'}) && is_array($rc_result->{'error-codes'})) {
              $result['message'] .= '(エラーコード: ' . implode(', ', $rc_result->{'error-codes'}) . ')';
            }
          }
        } else {
          $result['message'] = "JSONの解析に失敗しました: " . json_last_error_msg();
        }
      }
    }
    // cURL セッション終了
    curl_close($ch);
    return $result; // 検証結果を返す
  }
}

complete.php

完了画面では、mail_config.php で定義されている検証結果を表示するかどうかのフラグ(定数)SHOW_RECAPTCHA_V3_RESULT の値を取得します(25行目)。

そして reCAPTCHA 検証結果をセッションから取得して変数に代入し(35-37)、SHOW_RECAPTCHA_V3_RESULT が true であれば、検証結果を出力します(77-85)。

<?php
session_start(); //セッションを開始
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
// キャッシュ抑制ヘッダー
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Pragma: no-cache");
header("Expires: Thu, 01 Jan 1970 00:00:00 GMT");

// CSRF・不正アクセス対策:セッションにフォームデータがない場合はリダイレクト
if (!isset($_SESSION['form_data'])) {
  header('Location: contact.php');
  exit;
}

// 必要なファイルの読み込み
require '../includes/helpers.php';
require '../includes/mail_config.php';

// 自動返信メールの送信結果(セッションから取得)
$auto_reply_result = $_SESSION['reply_result'] ?? false;
// 入力されたデータを完了画面に表示するかどうか
$show_input_values_with_result = (defined('SHOW_INPUT_VALUES_WITH_RESULT') && SHOW_INPUT_VALUES_WITH_RESULT) ? true : false;
// reCAPTCHA 検証結果を完了画面に表示するかどうか
$show_recaptcha_v3_result = (defined('SHOW_RECAPTCHA_V3_RESULT') && SHOW_RECAPTCHA_V3_RESULT) ? true : false;

// フォームの値(セッションから取得)
$name    = $_SESSION['form_data']['name'] ?? '';
$email   = $_SESSION['form_data']['email'] ?? '';
$tel     = $_SESSION['form_data']['tel'] ?? '';
$subject = $_SESSION['form_data']['subject'] ?? '';
$body    = $_SESSION['form_data']['body'] ?? '';

// reCAPTCHA 検証結果(セッションから取得)
$success  = $_SESSION['rcv3_result']['success'] ?? '';
$action   = $_SESSION['rcv3_result']['action'] ?? '';
$score    = $_SESSION['rcv3_result']['score'] ?? '';

// セッション変数の全削除
$_SESSION = [];

// セッションクッキーを削除(ブラウザに残っているセッションクッキー自体を無効化)
if (ini_get("session.use_cookies")) {
  // セッション用クッキーに使っている情報(path、domain、secure、httponly など)を取得
  $params = session_get_cookie_params();
  // クッキーの値(第2引数)を空にする(path、domain、secure、httponly は維持)
  setcookie(
    session_name(),
    '',
    time() - 42000,
    $params["path"],
    $params["domain"],
    $params["secure"],
    $params["httponly"]
  );
}

// セッション自体の破棄
session_destroy();
?>

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

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>送信完了 | お問い合わせ</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h1 aria-label="送信完了メッセージ">送信が完了しました。</h1>
    <!-- reCAPTCHA の判定結果出力(SHOW_RECAPTCHA_V3_RESULT が true のときのみ表示) -->
    <?php if ($show_recaptcha_v3_result): ?>
      <div class="test">
        <?php
        if ($success) echo 'success: ' . h($success) . '<br>';
        if ($action) echo 'action: ' . h($action) . '<br>';
        if ($score) echo 'score: ' . h($score) . '<br>';
        ?>
      </div>
    <?php endif; ?>

    <?php if ($show_input_values_with_result): ?>
      <h2>送信内容:</h2>
      <table class="table table-bordered table-success result" summary="送信内容の確認">
        <tr>
          <th scope="row">お名前:</th>
          <td><?php echo h($name); ?></td>
        </tr>
        <tr>
          <th scope="row">メール:</th>
          <td><?php echo h($email); ?></td>
        </tr>
        <tr>
          <th scope="row">電話番号:</th>
          <td><?php echo h($tel); ?></td>
        </tr>
        <tr>
          <th scope="row">件名:</th>
          <td><?php echo h($subject); ?></td>
        </tr>
        <tr>
          <th scope="row">内容:</th>
          <td><?php echo nl2br(h($body)); ?></td>
        </tr>
      </table>
    <?php endif; ?>
    <p>ありがとうございました。</p>
    <?php if ($auto_reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$auto_reply_result): ?>
      <p class="fail" role="alert">確認の自動返信メールが送信できませんでした。</p>
    <?php endif; ?>
  </div>
  <!-- 戻るボタン対策 -->
  <script>
    // History API対応ブラウザかを確認
    if (window.history.replaceState) {
      // 状態とタイトルは変更せず、現在のURLで置き換える
      window.history.replaceState(null, null, window.location.href);
    }
  </script>
</body>

</html>

PHPMailer を使う

PHPMailer は、PHP でメールを送信するためのライブラリで、SMTP 認証や HTML メールの送信に対応しています。PHPMailer を使うと MAMP などのローカル環境で外部の SMTP サーバを使ってメールを送信することもできます。

但し、過去にはリモートコード実行(RCE)やメールヘッダーインジェクションなどの重大な脆弱性が報告されています。そのため、PHPMailer を使う場合は、定期的に最新版を確認・適用することが重要です。

何年も放置していると、脆弱性やPHPとの互換性の問題が発生する可能性があります。

Composer で管理することで、バージョンアップの手間を最小限にできます。

以下で使用しているバージョンは v6.10.0(2025-04-24)です。

PHPMailer のインストール

この例では Composer を使って、includes ディレクトリに PHPMailer をインストールします。

├── assets
│   └── css
│       └── contact-style.css
├── contact
│   ├── contact.php
│   └── complete.php
└── includes  // ここにインストール
    ├── .htaccess
    ├── helpers.php
    ├── index.php
    └── mail_config.php

ターミナルで includes ディレクトリに移動して以下のコマンドを実行します(% はプロンプトです)。

% composer require phpmailer/phpmailer

以下のように includes ディレクトリ直下に composer.json と composer.lock というファイル及び vendor ディレクトリが生成されます(composer や phpmailer ディレクトリの中身は省略)。

vendor/autoload.php は、Composer が管理するライブラリのクラスを自動的に読み込むための仕組み(オートローダー)を提供するファイルです。これにより、手動で require を書かなくてもクラスが自動的に読み込まれるようになります。

includes/
  ├── .htaccess
  ├── composer.json
  ├── composer.lock
  ├── helpers.php
  ├── index.php
  ├── mail_config.php
  └── vendor/
      ├── autoload.php (オートローダー)
      ├── composer/
      └── phpmailer/
          └── phpmailer/
              └── src
                  ├── DSNConfigurator.php
                  ├── Exception.php
                  ├── OAuth.php
                  ├── OAuthTokenProvider.php
                  ├── PHPMailer.php
                  ├── POP3.php
                  └── SMTP.php

PHPMailer で SMTP を使う際に必要な情報

PHPMailer を使って SMTP 経由でメール送信を行う場合、mb_send_mail() と異なり、SMTP サーバーに接続するための情報を自分で指定する必要があります。

以下が必要な主な情報です。

項目 説明
SMTP サーバー名(ホスト名) 例:smtp.example.com など。どのサーバーを使うかによって異なります。
SMTP ポート番号 よく使われるポート:587(TLS)、465(SMTPS)、25(通常のSMTP)など。
SMTP 認証ユーザ名 通常はそのメールアカウントの メールアドレス(例:user@example.com)。
SMTP パスワード 対応するメールアカウントのパスワード、または SMTP 認証用のアプリパスワード
暗号化方式 tls または ssl を指定。ポート番号とセット(以下参照)。
暗号化方式とポート番号の対応表
暗号化方式 ポート番号 説明
TLS 587 通常のSMTPで接続後、途中でSTARTTLSコマンドで暗号化へ切り替える。現在は最も推奨される方式。
SSL 465 接続時に即座にSSLで暗号化される「SMTPS」という方式。古くからあるが、今でも一部サービスで使われる。
なし 25 暗号化されない平文通信(現在はほとんど非推奨)。

関連ページ:PHPMailer の使い方

mail_config.php

メール送信用の定数を定義するファイル mail_config.php に PHPMailer で使用する情報を追加します。

// 使用する SMTP サーバー
define('MAIL_HOST', 'mail.xxxxxx.com');

//PHPMailer を使って送信するための E-mail アカウント(SMTP ユーザ名)
define('MAIL_USER', 'xxxxx@xxxxxxx.com');

//パスワード
define('MAIL_PASSWORD', 'xxxxxxxxxx');

contact.php

以下は、JavaScript で検証で使用した contact.php を PHPMailer を使うように書き換えたものです。

use PHPMailer\PHPMailer\PHPMailer; は、PHPMailer\PHPMailer 名前空間にある PHPMailer クラスを現在のスクリプトで PHPMailer という短い名前で使えるようにする宣言です。

同様に、PHPMailer\PHPMailer\Exception を PHPMailer の Exception クラスとして使用できるようにしています。これは名前空間を明示せずにクラスを使えるようにするための use 宣言です。

require_once '../includes/vendor/autoload.php'; は、Composer が生成するオートローダーを読み込み、PHPMailer を含むすべてのライブラリを自動的に利用可能にするための記述です。autoload.php を読み込むことで、use 宣言したクラスを手動で読み込む必要がなくなります。

また、validate_mail_config() を使って PHPMailer で使用する定数が定義されているかをチェックします。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';
// PHPMailer クラスをグローバル名前空間にインポート
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

// Composer のオートローダーの読み込み
require_once '../includes/vendor/autoload.php';

// PHPMailer で使用する定数('MAIL_HOST', 'MAIL_USER', 'MAIL_PASSWORD')のチェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['MAIL_HOST', 'MAIL_USER', 'MAIL_PASSWORD']
);

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {

    // mbstring の日本語設定
    mb_language("Japanese"); // または mb_language("uni");
    mb_internal_encoding("UTF-8");

    // PHPMailer のインスタンスを生成
    $mail = new PHPMailer(true);

    //送信結果の真偽値の初期化
    $result = false;
    $reply_result = false;

    try {
      //サーバ設定
      $mail->isSMTP(); // SMTP を使用
      $mail->Host = MAIL_HOST; // SMTP サーバーを指定
      $mail->SMTPAuth = true; // SMTP authentication を有効に
      $mail->Username = MAIL_USER; // SMTP ユーザ名
      $mail->Password = MAIL_PASSWORD; // SMTP パスワード
      $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS を有効に
      $mail->Port = 587; // TCP ポートを指定(TLS の場合のポート番号)

      // 文字コード設定(日本語用)
      $mail->CharSet  = "UTF-8";  // ← 絵文字なども扱える文字コード
      $mail->Encoding = "base64";  // 日本語などのマルチバイト文字向けエンコード方式

      //差出人(From)アドレス, 差出人名(実際の送信元サーバーのメールアドレス)
      $mail->setFrom(MAIL_FROM, mb_encode_mimeheader(MAIL_FROM_NAME));
      // 返信先(Reply-To)にユーザーのアドレスを設定
      $mail->addReplyTo($email, mb_encode_mimeheader($name));

      //送信先アドレス・宛先名
      $mail->AddAddress(MAIL_TO, mb_encode_mimeheader(MAIL_TO_NAME));
      //Cc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
        $mail->addCC(MAIL_CC, mb_encode_mimeheader(MAIL_CC_NAME));
      }
      //Bcc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_BCC') && MAIL_BCC !== '') {
        $mail->AddBcc(MAIL_BCC);
      }

      $mail->isHTML(false); // メールのフォーマットをテキストに
      //件名 ※ mb_encode_mimeheader は UTF-8 + base64 では不要(PHPMailerが自動対応)
      $mail->Subject = $subject;

      $mail->WordWrap = 70; //70 文字で改行(好みで)
      $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
      $mail_body .=  "お名前: " . h($name) . "\n";
      $mail_body .=  "Email: " . h($email) . "\n";
      $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
      $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

      // 本文 (変換不要。そのまま UTF-8 で送信) テキストメールのみを送る場合は AltBody は不要
      $mail->Body = $mail_body;

      //メール送信の結果(真偽値)を $result に代入
      $result = $mail->send();
    } catch (Exception $e) {
      error_log('メール送信失敗: ' . $mail->ErrorInfo);
      $result = false;
    }

    if ($result) {
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        //自動返信メール
        $autoresponder = new PHPMailer(true);
        try {
          //サーバ設定
          $autoresponder->isSMTP(); // SMTP を使用
          $autoresponder->Host = MAIL_HOST; // SMTP サーバーを指定
          $autoresponder->SMTPAuth = true; // SMTP authentication を有効に
          $autoresponder->Username = MAIL_USER; // SMTP ユーザ名
          $autoresponder->Password = MAIL_PASSWORD; // SMTP パスワード
          $autoresponder->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //TLS を有効に
          $autoresponder->Port = 587; // TCP ポートを指定

          //日本語用
          $autoresponder->CharSet = "UTF-8";
          $autoresponder->Encoding = "base64";

          //差出人アドレス, 差出人名
          $autoresponder->setFrom(MAIL_FROM, mb_encode_mimeheader(AUTO_REPLY_NAME));
          //送信先・宛先
          $autoresponder->AddAddress($email, mb_encode_mimeheader($name));
          $autoresponder->isHTML(false); // メールのフォーマットをテキストに
          //件名
          $autoresponder->Subject = "【{$name}様】お問い合わせありがとうございます"; //件名
          //返信用アドレス(差出人以外に別途指定する場合)
          $autoresponder->addReplyTo(MAIL_USER, mb_encode_mimeheader("お問い合わせ"));
          $autoresponder->WordWrap = 70; //70 文字で改行(好みで)

          $week = ['日', '月', '火', '水', '木', '金', '土'];
          $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
          $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
          // 本文
          $autoresponder->Body = $reply_body;

          //自動送信メールの送信結果(真偽値)を reply_result に代入
          $reply_result = $autoresponder->send();
        } catch (Exception $e) {
          error_log('自動送信メール 送信失敗: ' . $autoresponder->ErrorInfo);
          $reply_result = false;
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <form class="js-form-validation contact-form" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <script src="../assets/js/form-validation.js"></script>
</body>

</html>

SMTP を使用する送信処理

88-207行目が mb_send_mail() の代わりに PHPMailer を使ってメールを送信する処理部分です。

メール送信の処理では new PHPMailer(true) によってインスタンスを生成します。true を渡すことで、エラー時に例外を投げるモード(例外モード)が有効になります。その後、SMTP サーバーの設定や送信者・宛先・件名・本文などを設定し、send() メソッドで送信処理を実行します。

文字コード設定

上記では、以下のようにモダンな環境用(PC/スマホ/Web メーラー)に UTF-8 で設定しています。

$mail->CharSet  = 'UTF-8';
$mail->Encoding = 'base64';

絵文字を含むメール本文を正しく表示させるには、上記のように文字コードを UTF-8 に、エンコーディングを base64 に設定します。

また、件名(Subject)では、PHPMailer が自動対応するので、mb_encode_mimeheader() での変換は不要です。本文(Body)も、そのまま UTF-8 で送信すればよく、mb_convert_encoding() は不要です。

以下はキャリアメールや古いクライアント用に ISO-2022-JP を使う場合の設定例です。絵文字には対応していません(文字化けします)。

また、ISO-2022-JP + 7bit の設定では、件名と本文にはエンコーディング処理が必要になります。

$mail->CharSet  = 'ISO-2022-JP';
$mail->Encoding = '7bit';
// 日本語の件名は MIME ヘッダのエンコードが必須
$mail->Subject  = mb_encode_mimeheader($subject, 'ISO-2022-JP');
// 本文を明示的に ISO-2022-JP に変換してから渡す必要がある
$mail->Body     = mb_convert_encoding($body, 'ISO-2022-JP', 'UTF-8');

complete.php

complete.php は 最初の例 と同じなので省略します。

reCAPTCHA v2

以下は reCAPTCHA v2 と PHPMailer を使う場合の contact.php のコードです。

mail_config.php で PHPMailer に使用する定数が定義されている必要があります。その他は reCAPTCHA v2 の実装 の例の場合と同じです。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// PHPMailer クラスをグローバル名前空間にインポート
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

// Composer のオートローダーの読み込み
require_once '../includes/vendor/autoload.php';

// reCAPTCHA v2 で使用する定数('V2_SITEKEY', 'V2_SECRETKEY')のチェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['MAIL_HOST', 'MAIL_USER', 'MAIL_PASSWORD','V2_SITEKEY', 'V2_SECRETKEY']
);

// reCAPTCHA V2 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v2_site_key   = V2_SITEKEY;
$v2_secret_key = V2_SECRETKEY;

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['contact-form-submit'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  // reCAPTCHA v2 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_result = verify_recaptcha_v2(
    $v2_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_SERVER['REMOTE_ADDR']
  );

  // reCAPTCHA v2 検証が失敗した場合
  if (!$recaptcha_result['status']) {
    $error['recaptcha_v2'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {

    // mbstring の日本語設定
    mb_language("Japanese"); // または mb_language("uni");
    mb_internal_encoding("UTF-8");

    // PHPMailer のインスタンスを生成
    $mail = new PHPMailer(true);

    //送信結果の真偽値の初期化
    $result = false;
    $reply_result = false;

    try {
      //サーバ設定
      $mail->isSMTP(); // SMTP を使用
      $mail->Host = MAIL_HOST; // SMTP サーバーを指定
      $mail->SMTPAuth = true; // SMTP authentication を有効に
      $mail->Username = MAIL_USER; // SMTP ユーザ名
      $mail->Password = MAIL_PASSWORD; // SMTP パスワード
      $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS を有効に
      $mail->Port = 587; // TCP ポートを指定(TLS の場合のポート番号)

      // 文字コード設定(日本語用)
      $mail->CharSet  = "UTF-8";  // ← 絵文字なども扱える文字コード
      $mail->Encoding = "base64";  // 日本語などのマルチバイト文字向けエンコード方式

      //差出人(From)アドレス, 差出人名(実際の送信元サーバーのメールアドレス)
      $mail->setFrom(MAIL_FROM, mb_encode_mimeheader(MAIL_FROM_NAME));
      // 返信先(Reply-To)にユーザーのアドレスを設定
      $mail->addReplyTo($email, mb_encode_mimeheader($name));

      //送信先アドレス・宛先名
      $mail->AddAddress(MAIL_TO, mb_encode_mimeheader(MAIL_TO_NAME));
      //Cc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
        $mail->addCC(MAIL_CC, mb_encode_mimeheader(MAIL_CC_NAME));
      }
      //Bcc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_BCC') && MAIL_BCC !== '') {
        $mail->AddBcc(MAIL_BCC);
      }

      $mail->isHTML(false); // メールのフォーマットをテキストに
      //件名 ※ mb_encode_mimeheader は UTF-8 + base64 では不要(PHPMailerが自動対応)
      $mail->Subject = $subject;

      $mail->WordWrap = 70; //70 文字で改行(好みで)
      $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
      $mail_body .=  "お名前: " . h($name) . "\n";
      $mail_body .=  "Email: " . h($email) . "\n";
      $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
      $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

      // 本文 (変換不要。そのまま UTF-8 で送信) テキストメールのみを送る場合は AltBody は不要
      $mail->Body = $mail_body;

      //メール送信の結果(真偽値)を $result に代入
      $result = $mail->send();
    } catch (Exception $e) {
      error_log('メール送信失敗: ' . $mail->ErrorInfo);
      $result = false;
    }

    if ($result) {
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        //自動返信メール
        $autoresponder = new PHPMailer(true);
        try {
          //サーバ設定
          $autoresponder->isSMTP(); // SMTP を使用
          $autoresponder->Host = MAIL_HOST; // SMTP サーバーを指定
          $autoresponder->SMTPAuth = true; // SMTP authentication を有効に
          $autoresponder->Username = MAIL_USER; // SMTP ユーザ名
          $autoresponder->Password = MAIL_PASSWORD; // SMTP パスワード
          $autoresponder->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //TLS を有効に
          $autoresponder->Port = 587; // TCP ポートを指定

          //日本語用
          $autoresponder->CharSet = "UTF-8";
          $autoresponder->Encoding = "base64";

          //差出人アドレス, 差出人名
          $autoresponder->setFrom(MAIL_FROM, mb_encode_mimeheader(AUTO_REPLY_NAME));
          //送信先・宛先
          $autoresponder->AddAddress($email, mb_encode_mimeheader($name));
          $autoresponder->isHTML(false); // メールのフォーマットをテキストに
          //件名
          $autoresponder->Subject = "【{$name}様】お問い合わせありがとうございます"; //件名
          //返信用アドレス(差出人以外に別途指定する場合)
          $autoresponder->addReplyTo(MAIL_USER, mb_encode_mimeheader("お問い合わせ"));
          $autoresponder->WordWrap = 70; //70 文字で改行(好みで)

          $week = ['日', '月', '火', '水', '木', '金', '土'];
          $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
          $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
          // 本文
          $autoresponder->Body = $reply_body;

          //自動送信メールの送信結果(真偽値)を reply_result に代入
          $reply_result = $autoresponder->send();
        } catch (Exception $e) {
          error_log('自動送信メール 送信失敗: ' . $autoresponder->ErrorInfo);
          $reply_result = false;
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <!-- rcv2 クラスを追加-->
    <form class="js-form-validation contact-form rcv2" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <!-- reCAPTCHA ウィジェット を表示する要素 -->
      <div id="recaptcha-v2"></div>
      <!-- reCAPTCHA v2 バリデーションエラー表示 -->
      <?php if (isset($error['recaptcha_v2'])): ?>
        <p><span class="error-php"><?php echo h($error['recaptcha_v2']); ?></span></p>
      <?php endif; ?>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
  <script src="../assets/js/form-validation.js"></script>
  <!-- reCAPTCHA サイトキーを JavaScript に渡す -->
  <script>
    window.recaptchaV2SiteKey = "<?php echo $v2_site_key; ?>";
  </script>
  <!-- recaptcha-v2-handler.js の読み込み -->
  <script src="../assets/js/recaptcha-v2-handler.js"></script>
  <!-- reCAPTCHA API スクリプトの読み込み(onloadCallback を呼び出す)-->
  <script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>
</body>

</html>

complete.php

complete.php は 最初の例 と同じです。

reCAPTCHA v3

以下は reCAPTCHA v3 と PHPMailer を使う場合の contact.php のコードです。

mail_config.php で PHPMailer に使用する定数が定義されている必要があります。その他は reCAPTCHA v3 の実装 の例の場合と同じです。

<?php
session_start();
header('X-Frame-Options: SAMEORIGIN'); // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.php';

// PHPMailer クラスをグローバル名前空間にインポート
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

// Composer のオートローダーの読み込み
require_once '../includes/vendor/autoload.php';

// reCAPTCHA v3 で使用する定数('V3_SITEKEY', 'V3_SECRETKEY')のチェックを追加
validate_mail_config(
  ['MAIL_TO', 'MAIL_FROM', 'MAIL_RETURN_PATH'],
  ['MAIL_CC', 'MAIL_BCC'],
  ['MAIL_TO_NAME', 'MAIL_FROM_NAME'],
  ['MAIL_CC_NAME', 'AUTO_REPLY_NAME'],
  ['MAIL_HOST', 'MAIL_USER', 'MAIL_PASSWORD','V3_SITEKEY', 'V3_SECRETKEY']
);

// reCAPTCHA V3 サイトキーとシークレットキーの取得(mail_config.php で定義)
$v3_site_key   = V3_SITEKEY;
$v3_secret_key = V3_SECRETKEY;

$_POST = checkInput($_POST);
$name    = trim($_POST['name'] ?? '');
$email   = trim($_POST['email'] ?? '');
$email_check   = trim($_POST['email_check'] ?? '');
$tel     = trim($_POST['tel'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$body    = trim($_POST['body'] ?? '');

if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

//★トークン($_POST['g-recaptcha-response'])が設定されていて中身が空でなければ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['g-recaptcha-response'])) {

  if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    die('CSRFトークンが存在しません。');
  }
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なCSRFトークンです。');
  }

  $error = array();

  // reCAPTCHA v3 を検証する関数(helpers.php で定義)の呼び出し
  $recaptcha_v3_result = verify_recaptcha_v3(
    $v3_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_POST['action'] ?? '',
    $_SERVER['REMOTE_ADDR'],
    0.7  // 閾値(省略時は 0.5)
  );

  // reCAPTCHA v3 検証が失敗した場合
  if (!$recaptcha_v3_result['status']) {
    $error['recaptcha_v3'] = 'reCAPTCHAの検証に失敗しました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . $recaptcha_v3_result['message']);
  }

  if ($name === '') {
    $error['name'] = '*お名前は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $name)) {
    $error['name'] = '*お名前は30文字以内でお願いします。';
  } elseif (preg_match("/[\r\n]/", $name)) {
    $error['name'] = '*お名前に改行文字は使用できません。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/', $email)) {
    $error['email'] = '*メールアドレスの形式が正しくありません。';
  } elseif (preg_match("/[\r\n]/", $email)) {
    $error['email'] = '*メールアドレスに改行文字は使用できません。';
  }

  if ($email_check == '') {
    $error['email_check'] = '*確認用メールアドレスは必須です。';
  } else {
    if ($email_check !== $email) {
      $error['email_check'] = '*メールアドレスが一致しません。';
    }
  }

  if ($tel !== '' && !preg_match('/\A0\d{9,10}\z/', str_replace('-', '', $tel))) {
    $error['tel'] = '*電話番号は10〜11桁の数字で入力してください(ハイフンあり・なし両対応)。';
  }

  if ($subject === '') {
    $error['subject'] = '*件名は必須項目です。';
  } elseif (!preg_match('/\A[[:^cntrl:]]{1,50}\z/u', $subject)) {
    $error['subject'] = '*件名は50文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (!preg_match('/\A[\r\n\t[:^cntrl:]]{1,1000}\z/u', $body)) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  if (empty($error)) {

    // mbstring の日本語設定
    mb_language("Japanese"); // または mb_language("uni");
    mb_internal_encoding("UTF-8");

    // PHPMailer のインスタンスを生成
    $mail = new PHPMailer(true);

    //送信結果の真偽値の初期化
    $result = false;
    $reply_result = false;

    try {
      //サーバ設定
      $mail->isSMTP(); // SMTP を使用
      $mail->Host = MAIL_HOST; // SMTP サーバーを指定
      $mail->SMTPAuth = true; // SMTP authentication を有効に
      $mail->Username = MAIL_USER; // SMTP ユーザ名
      $mail->Password = MAIL_PASSWORD; // SMTP パスワード
      $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS を有効に
      $mail->Port = 587; // TCP ポートを指定(TLS の場合のポート番号)

      // 文字コード設定(日本語用)
      $mail->CharSet  = "UTF-8";  // ← 絵文字なども扱える文字コード
      $mail->Encoding = "base64";  // 日本語などのマルチバイト文字向けエンコード方式

      //差出人(From)アドレス, 差出人名(実際の送信元サーバーのメールアドレス)
      $mail->setFrom(MAIL_FROM, mb_encode_mimeheader(MAIL_FROM_NAME));
      // 返信先(Reply-To)にユーザーのアドレスを設定
      $mail->addReplyTo($email, mb_encode_mimeheader($name));

      //送信先アドレス・宛先名
      $mail->AddAddress(MAIL_TO, mb_encode_mimeheader(MAIL_TO_NAME));
      //Cc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
        $mail->addCC(MAIL_CC, mb_encode_mimeheader(MAIL_CC_NAME));
      }
      //Bcc アドレス(mail_config.php で定義されていれば設定)
      if (defined('MAIL_BCC') && MAIL_BCC !== '') {
        $mail->AddBcc(MAIL_BCC);
      }

      $mail->isHTML(false); // メールのフォーマットをテキストに
      //件名 ※ mb_encode_mimeheader は UTF-8 + base64 では不要(PHPMailerが自動対応)
      $mail->Subject = $subject;

      $mail->WordWrap = 70; //70 文字で改行(好みで)
      $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
      $mail_body .=  "お名前: " . h($name) . "\n";
      $mail_body .=  "Email: " . h($email) . "\n";
      $mail_body .=  "お電話番号: " . h($tel) . "\n\n";
      $mail_body .=  "<お問い合わせ内容>" . "\n" . h($body);

      // 本文 (変換不要。そのまま UTF-8 で送信) テキストメールのみを送る場合は AltBody は不要
      $mail->Body = $mail_body;

      //メール送信の結果(真偽値)を $result に代入
      $result = $mail->send();
    } catch (Exception $e) {
      error_log('メール送信失敗: ' . $mail->ErrorInfo);
      $result = false;
    }

    if ($result) {
      session_regenerate_id(true);
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        //自動返信メール
        $autoresponder = new PHPMailer(true);
        try {
          //サーバ設定
          $autoresponder->isSMTP(); // SMTP を使用
          $autoresponder->Host = MAIL_HOST; // SMTP サーバーを指定
          $autoresponder->SMTPAuth = true; // SMTP authentication を有効に
          $autoresponder->Username = MAIL_USER; // SMTP ユーザ名
          $autoresponder->Password = MAIL_PASSWORD; // SMTP パスワード
          $autoresponder->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //TLS を有効に
          $autoresponder->Port = 587; // TCP ポートを指定

          //日本語用
          $autoresponder->CharSet = "UTF-8";
          $autoresponder->Encoding = "base64";

          //差出人アドレス, 差出人名
          $autoresponder->setFrom(MAIL_FROM, mb_encode_mimeheader(AUTO_REPLY_NAME));
          //送信先・宛先
          $autoresponder->AddAddress($email, mb_encode_mimeheader($name));
          $autoresponder->isHTML(false); // メールのフォーマットをテキストに
          //件名
          $autoresponder->Subject = "【{$name}様】お問い合わせありがとうございます"; //件名
          //返信用アドレス(差出人以外に別途指定する場合)
          $autoresponder->addReplyTo(MAIL_USER, mb_encode_mimeheader("お問い合わせ"));
          $autoresponder->WordWrap = 70; //70 文字で改行(好みで)

          $week = ['日', '月', '火', '水', '木', '金', '土'];
          $datetime = date("Y年m月d日") . "(" . $week[date('w')] . ")" . date(" H時i分");
          $reply_body = <<<EOT
{$name} 様

この度は、お問い合わせ頂き誠にありがとうございます。

下記の内容でお問い合わせを受け付けました。

お問い合わせ日時:{$datetime}
お名前:{$name}
メールアドレス:{$email}
お電話番号:{$tel}

<お問い合わせ内容>
{$body}
EOT;
          // 本文
          $autoresponder->Body = $reply_body;

          //自動送信メールの送信結果(真偽値)を reply_result に代入
          $reply_result = $autoresponder->send();
        } catch (Exception $e) {
          error_log('自動送信メール 送信失敗: ' . $autoresponder->ErrorInfo);
          $reply_result = false;
        }

        $_SESSION['reply_result'] = $reply_result;
      }

      $_SESSION['form_data'] = [
        'name'    => $name,
        'email'   => $email,
        'tel'     => $tel,
        'subject' => $subject,
        'body'    => $body,
      ];

      unset($_SESSION['csrf_token']);
      $_POST = array();

      // reCAPTCHA 検証結果確認用の値をセッションに保持
      $_SESSION['rcv3_result'] = [
        'success'    => $recaptcha_v3_result['success'],
        'action'   =>  $recaptcha_v3_result['action'],
        'score'     =>  $recaptcha_v3_result['score'],
      ];

      $url = 'complete.php';
      header('Location:' . $url);
      exit;
    } else {
      $error['send'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
    }
    if (empty($_SESSION['csrf_token'])) {
      $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    $csrf_token = $_SESSION['csrf_token'];
  }
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>コンタクトフォーム</title>
  <link href="../assets/css/contact-style.css" rel="stylesheet">
</head>

<body>
  <div class="container contact-page">
    <h2>お問い合わせフォーム</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <!-- rcv3 クラスを追加-->
    <form class="js-form-validation contact-form rcv3" method="post" action="contact.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php if (isset($error['name'])) echo h($error['name']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="name"
          name="name"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($name); ?>">
      </div>
      <div>
        <label for="email">Email(必須)
          <span class="error-php"><?php if (isset($error['email'])) echo h($error['email']); ?></span>
        </label>
        <input
          type="email"
          class="required pattern"
          data-pattern="email"
          data-error-required="Email アドレスは必須です。"
          data-error-pattern="Email の形式が正しくないようですのでご確認ください"
          id="email"
          name="email"
          placeholder="Email アドレス"
          value="<?php echo h($email); ?>">
      </div>
      <div>
        <label for="email_check">Email(確認用 必須)
          <span class="error-php"><?php if (isset($error['email_check'])) echo h($error['email_check']); ?></span>
        </label>
        <input
          type="email"
          class="form-control equal-to required"
          data-equal-to="email"
          data-error-equal-to="メールアドレスが異なります"
          id="email_check"
          name="email_check"
          placeholder="Email アドレス(確認用 必須)"
          value="<?php echo h($email_check); ?>">
      </div>
      <div>
        <label for="tel">電話番号(半角数字)
          <span class="error-php"><?php if (isset($error['tel'])) echo h($error['tel']); ?></span>
        </label>
        <input
          type="tel"
          class="pattern"
          data-pattern="tel"
          data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。"
          id="tel"
          name="tel"
          placeholder="電話番号(例:090-1234-5678 または 09012345678)"
          value="<?php echo h($tel); ?>">
      </div>
      <div>
        <label for="subject">件名(必須)
          <span class="error-php"><?php if (isset($error['subject'])) echo h($error['subject']); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="100"
          id="subject"
          name="subject"
          placeholder="件名"
          data-error-required="件名は必須です。"
          value="<?php echo h($subject); ?>">
      </div>
      <div>
        <label for="body">お問い合わせ内容(必須)
          <span class="error-php"><?php if (isset($error['body'])) echo h($error['body']); ?></span>
        </label>
        <textarea
          class="required maxlength showCount"
          data-maxlength="1000"
          id="body"
          name="body"
          placeholder="お問い合わせ内容(1000文字まで)をお書きください"
          data-error-required="お問い合わせ内容は必須です。"
          rows="5"><?php echo h($body); ?></textarea>
      </div>
      <button name="contact-form-submit" type="submit" class="form-button">送信</button>
       <!-- reCAPTCHA v3 バリデーションエラー表示 -->
      <?php if (isset($error['recaptcha_v3'])): ?>
        <p><span class="error-php"><?php echo h($error['recaptcha_v3']); ?></span></p>
      <?php endif; ?>
    </form>
    <?php if (isset($error['send'])): ?>
      <div class="error-php"><?php echo h($error['send']); ?></div>
    <?php endif; ?>
  </div>
 <!--  検証用の JavaScript の読み込み -->
  <script src="../assets/js/form-validation.js"></script>

  <!-- reCAPTCHA サイトキーを JavaScript に渡す -->
  <script>
    window.recaptchaV3SiteKey = "<?php echo $v3_site_key; ?>";
  </script>

  <!-- Google reCAPTCHA API スクリプトの読み込み-->
  <script src="https://www.google.com/recaptcha/api.js?render=<?php echo $v3_site_key; ?>"></script>

  <!-- recaptcha-v3-handler.js の読み込み -->
  <script src="../assets/js/recaptcha-v3-handler.js"></script>
</body>

</html>

complete.php

complete.php のコードは reCAPTCHA v3 の実装 と同じです。