PHP コンタクトフォーム(お問い合わせページ)の作り方

安全で使いやすいコンタクトフォームは、Webサイトにとって欠かせない要素のひとつです。以下では、CSRF対策・reCAPTCHA v3・PHPMailerによるメール送信など、実用的かつセキュアなコンタクトフォームをゼロから構築する方法を解説します。入力ページから確認・完了ページまでの3ステップ構成に加え、実践的なセキュリティ対策も含めたフォーム実装になっています。

関連ページ:

更新日:2025年06月07日

作成日:2020年3月30日

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

コンタクトフォームの作成

入力ページ → 確認ページ → 完了ページの順で遷移するコンタクトフォームを作成します。

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

以下がそれぞれのページの概要です。

ページ 説明
入力ページ
contact.php
ユーザーが「名前」「Email」「電話番号」「件名」「問い合わせ内容」を入力するページです。必須項目や値のクライアントサイド側の検証は JavaScript で行います。
確認ページ
confirm.php
入力された値をサーバ側(PHP)で検証して、値に問題がなければ入力内容と「送信ボタン」及び「戻るボタン」を表示します。不備がある場合はエラーを表示して再度入力フォームを表示します。
完了ページ
complete.php
問い合わせの受付が完了したことを知らせるページです。mb_send_mail() 関数を使ってメールを送信して送信結果を表示します。

入力ページ → 確認ページ → 完了ページと複数ページにまたがるフォームでは、POST で受け取ったデータは、ページ遷移のたびに安全に引き継ぐ必要があります。

セッションを使うことで、入力内容をサーバー側で一時的に保持できるため、以下を安全に行えます。

  • 確認画面に表示する
  • 戻るボタンで再表示する
  • 改ざんされていないことを確認する

セッションはサーバー側でデータを保持するため、ユーザーによってデータが改ざんされるリスクがありません。例えば、JavaScript やブラウザ開発ツールから値を書き換えられる心配がなく、より安全です。

また、第三者による不正なフォーム送信(乗っ取り)を防ぐため、CSRFトークンを利用します。

CSRF(クロスサイト・リクエスト・フォージェリ)は、ユーザーがログイン中などの状態を悪用して、意図しないリクエストを送らせる攻撃です。例えば、悪意あるサイトにアクセスした際に、裏で勝手に問い合わせフォームを送信されるようなケースです。

フォーム送信時にトークン(CSRFトークン)を使うことで、「本物のフォームから送られたリクエストかどうか」を判別できます。

ディレクトリ構成とファイル

contact フォルダには、フォームの各ステップ(入力・確認・完了)に対応する contact.php、confirm.php、complete.php を配置します。

assets フォルダには、CSS および JavaScript のリソースを格納します。たとえば、フォーム専用のスタイルやバリデーション用の JavaScript を配置します。

includes フォルダには、PHP の処理ロジックや設定をまとめたファイルを配置します。外部からの直接アクセスを防ぐための .htaccess や index.php も含めて、セキュアに構成します。

├── assets/
│   ├── css/
│   │   └── contact-style.css   # スタイルシート
│   └── js/
│       └── form-validation.js  # フォームのバリデーション処理
├── contact/
│   ├── contact.php   # 入力ページ
│   ├── confirm.php   # 確認ページ
│   └── complete.php  # 完了ページ
└── includes/
    ├── .htaccess        # アクセス制御(フォルダへの直接アクセスを拒否)
    ├── helpers.php      # 共通関数(検証やエスケープ処理の関数群)
    ├── index.php        # ディレクトリリスティング防止用の空ファイル
    └── mail_config.php  # メール設定やフラグを定義(可能であれば公開ディレクトリ意外に配置)
.htaccess

includes フォルダには、PHP の設定ファイルや関数群を格納するため、外部からの直接アクセスを防ぐ必要があります。そのため、.htaccess ファイルを設置してアクセス制限を行います。

Require all denied

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

deny from all

必要であれば、以下のように記述することで、Apache のバージョンにかかわらず( Apache 2.2 以前と 2.4 以降の両方に対応)安全にアクセス制限を適用できます。

<IfModule mod_authz_core.c>
  Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
  deny from all
</IfModule>
helpers.php

このファイルは、フォーム処理やセッション管理、セキュリティチェック、リダイレクトなどに共通して使用されるユーティリティ関数群をまとめたもので、contact.php など他のファイルから読み込まれて使われます。

  • h() 関数:htmlspecialchars() による出力時のエスケープ処理
  • checkInput() 関数:NULLバイト攻撃対策、文字エンコーディング確認、制御文字の除去
  • init_session_value() 関数:$_SESSION[$key] を初期化時に扱いやすくするユーティリティ
  • init_post_value() 関数:$_POST[$key] の取得時に trim() して空文字代替
  • print_error() 関数:$_SESSION['error'] などからキー指定でエラー出力
  • validate_mail_config() 関数:mail_config.php に定義された定数の存在・形式を検証()
  • redirect_to_contact_input() 関数:contact.php へ安全にリダイレクト
  • redirect_to_page() 関数:任意のページへ 303 リダイレクト(環境によって使用)
<?php
if (!function_exists('h')) {
  /**
   * エスケープ処理を行う関数
   *
   * @param string|array|null $var チェックする文字列または配列(nullも可)
   * @return string|array エスケープされた文字列または再帰的に処理された配列
   */
  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 チェックする文字列または配列
   * @return string|array 入力が正しい場合はそのまま返す。不正な場合はスクリプトを終了する
   */
  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('init_session_value')) {
  /**
   * SESSION 値の初期化を行う関数
   * 未定義の場合は空文字列または空の配列を返す
   *
   * @param string  $key セッションキー名($_SESSION 変数のキー)
   * @param boolean $is_array 配列かどうか(デフォルトは false)
   * @return mixed セッション値、または空文字列・空配列
   */
  function init_session_value($key, $is_array = false) {
    if ($is_array) {
      return $_SESSION[$key] ?? [];
    }
    return $_SESSION[$key] ?? '';
  }
}

if (!function_exists('init_post_value')) {
  /**
   * POST 値の初期化を行う関数
   * 未定義の場合は空文字列を返す
   *
   * @param string  $key キー名($_POST 変数のキー)
   * @return string 前後の空白を除去した POST された値、または空文字列
   */
  function init_post_value($key) {
    return trim($_POST[$key] ?? '');
  }
}

if (!function_exists('print_error')) {
  /**
   * エラーメッセージを表示する関数
   *
   * @param array  $errors エラー配列(例: $_SESSION['error'] や $error)
   * @param string $key    対象のフォーム項目のキー
   * @return void          エラーメッセージがあればエスケープして出力
   */
  function print_error(array $errors, string $key): void {
    if (isset($errors[$key])) {
      echo h($errors[$key]);
    }
  }
}

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   その他必須の定数(空文字でないことをチェック)
   * @return void エラーがあれば HTML で表示してスクリプトを終了する。問題がなければ何も返さない。
   */
  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('redirect_to_contact_input')) {
  /**
   * 入力フォーム(contact.php)へリダイレクトする関数。
   *
   * バリデーションエラー処理やCSRFトークンの失敗時など、「入力画面に戻す」際に使用
   * 再送信を防ぐため HTTP 303 ステータスコードを用いて contact.php へリダイレクトを行う。
   *
   * セッションのロックを明示的に解除することで、
   * リダイレクト先でもセッションの読み書きがブロックされないように配慮している。
   *
   * @return void
   */
  function redirect_to_contact_input() {
    // セッションのロックを解除して他のリクエストがセッションにアクセスできるようにする
    session_write_close();
    // HTTPステータスコード303を送信(POST後のリダイレクトで再送信を防ぐため)
    header('HTTP/1.1 303 See Other');
    // Locationヘッダーでリダイレクト先を指定
    header('Location: contact.php');
    // 以降の処理を終了(リダイレクト実行)
    exit;
  }
}


if (!function_exists('redirect_to_page')) {
  /**
   * 指定されたページへ 303 See Other リダイレクトを行う。
   * redirect_to_contact_input() が機能しない環境で使用することを想定
   *
   * - 現在のスクリプトのディレクトリに対して相対的なパスでページにリダイレクトする。
   * - HTTPS またはリバースプロキシ経由の HTTPS 判定にも対応。
   * - セッションロックを早期に解放してから Location ヘッダーでリダイレクトを行う。
   *
   * @param string $filename リダイレクト先のファイル名(デフォルトは 'contact.php')。
   *               先頭のスラッシュは自動的に除去される。
   *
   * @return void この関数はリダイレクト後にスクリプトを終了するため、戻り値はない。
   */
  function redirect_to_page($filename = 'contact.php') {
    // 現在実行中のスクリプトのパスからディレクトリ名を取得(例: /contact → "/")
    $dirname = dirname($_SERVER['SCRIPT_NAME']);
    // ルートディレクトリ ("/") の場合は空文字に置き換える(URL整形のため)
    $dirname = $dirname === DIRECTORY_SEPARATOR ? '' : $dirname;
    // HTTPS接続かどうかを判定($_SERVER['HTTPS']が空または'off'でなければHTTPS)
    // または、リバースプロキシ環境で 'HTTP_X_FORWARDED_PROTO' が 'https' ならHTTPSとみなす
    $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
      (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
    // 使用するスキーム(http または https)を設定
    $scheme = $https ? 'https://' : 'http://';
    // 渡されたファイル名の先頭スラッシュを削除(例: "/contact.php" → "contact.php")
    $filename = ltrim($filename, '/');
    // リダイレクト先URLを構築(例: https://example.com/contact.php)
    $url = $scheme . $_SERVER['SERVER_NAME'] . $dirname . '/' . $filename;
    // HTTPステータスコード303を送信(POST後のリダイレクトで再送信を防ぐため)
    header('HTTP/1.1 303 See Other');
    // セッションのロックを解除して他のリクエストがセッションにアクセスできるようにする
    session_write_close();
    // Locationヘッダーでリダイレクト先を指定
    header('Location: ' . $url);
    // 以降の処理を終了(リダイレクト実行)
    exit;
  }
}
index.php(セキュリティ対策用)

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

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

<?php
// Silence is golden. 慣習的な表現(「何も表示しないのが一番安全」)

推奨対策

以下の対策も合わせて行うとより安全です。

.htaccess によるディレクトリリスティング無効化(Apache 環境)

Options -Indexes

robots.txt にクローラー除外を記述(公開サーバーの場合)

Disallow: /includes/
mail_config.php

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

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

デフォルトでは自動返信メールを送信するようにしていますが、送信しない場合は AUTO_REPLY_ENABLED を false に設定します。

<?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('AUTO_REPLY_ENABLED', true);
// 自動返信の返信先名前(AUTO_REPLY_ENABLED を true にして自動返信する場合は必須)
define('AUTO_REPLY_NAME', '自動返信 Example');

// Cc のメールアドレス(オプション)
define('MAIL_CC', 'carbon-copy@example.com');
// Cc の名前(オプション)
define('MAIL_CC_NAME', 'Ccの宛先名');
// Bcc のメールアドレス(オプション)
define('MAIL_BCC', 'blind-cc@example.com');

3-7行目の記述は、万一 .htaccess が無効化された環境に備えて、mail_config.php が単独で直接呼ばれた場合は処理を中断する(403 Forbiddenで終了する)ための記述です。

if (basename($_SERVER['PHP_SELF']) === basename(__FILE__)) は「今実行中のスクリプトのファイル名」と「このファイルのファイル名」が同じかどうかをチェックしています。

直接アクセスされた場合は、ファイル名が同じなので、アクセス拒否して終了し、他のファイル(例:contact.php)から読み込まれたら、ファイル名が違うので通常処理を続行します。

contact-style.css

以下はこのサンプルで使用しているスタイルシートです(適宜変更してください)。

body {
  font-family: "Helvetica Neue", Arial, sans-serif;
  margin: 0;
  padding: 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;
}

@media screen and (max-width: 480px) {

  .container.contact-page {
    padding: 20px;
  }

  .form-button {
    width: 100%;
  }

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

.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 8px;
  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;
}

form.confirm {
  display: inline-block;
  margin-right: 20px;
}
.confirm-forms {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}

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

/* 表スタイル */
table.confirm-table {
  width: 100%;
  border-collapse: collapse;
  margin: 20px 0;
  font-size: 14px;
}

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

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

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

/* 画面幅が小さくなった場合は、縦に並べる */
@media screen and (max-width: 640px) {
  table.confirm-table td, table.confirm-table th{
    display: block;
    width: 100%;
  }
  table.confirm-table 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;
}

CSRF トークン

CSRFトークンを使用することにより、フォーム送信の安全性を向上することができます。

なぜ CSRF 対策が必要か?

CSRF(クロスサイト・リクエスト・フォージェリ)は、ユーザーがログイン中などの状態を悪用し、意図しないフォーム送信や操作をさせる攻撃です。例えば、悪意のあるサイトを訪問させて、裏で勝手にお問い合わせフォームを送信させるようなケースが典型です。

このような不正なリクエストを防ぐために、フォームにCSRFトークンと呼ばれる秘密のキーを埋め込む方法がよく用いられます。

CSRF トークンには主に2つのタイプがあります。

ワンタイムトークン
各フォームアクセス時に毎回ランダムに生成し、セッションに保存しておき、送信時に検証後に破棄します。これにより、過去のトークンを使った攻撃(リプレイ攻撃)も防げます。
固定トークン
セッション開始時などに一度だけ生成して同一セッション中は使い回す方式です。ログイン中のユーザーに対して一貫性のある保護が必要なケースやトークン再発行のコストを抑えたい場合などに適しています。

どちらの方式でも、フォームが正規のものであることを検証でき、不正な送信を検出して遮断できます。また、トークンの検証には hash_equals() を使うことで、タイミング攻撃(比較時間差からの推測)も防ぐことが推奨されます。

CSRF トークンの運用ポイント
ポイント 内容
トークンの種類
  • ワンタイムトークン:毎回生成して検証後すぐ破棄(再利用不可)
  • 固定トークン:セッション開始時に生成し、同一セッション内で使い回す
セッション利用 どちらの方式でも、トークンはサーバー側セッションに保存して管理し、外部には漏らさない
HTMLエスケープ フォーム埋め込み時は htmlspecialchars() などでエスケープして安全に出力
タイミング攻撃対策 トークン検証は hash_equals() を使用して比較処理の時間差から値を推測されないようにする
POST専用にする トークンは必ず POST 送信で運用し、URLに含まれる GET 送信は避ける

以下は CSRF トークン利用の基本的な流れです。

ワンタイムトークン(使い捨て)

1. フォーム表示時(例:contact.php)

random_bytes()bin2hex() を使ってトークンを生成してセッションに保存

session_start();
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // 毎回生成
$token = $_SESSION['csrf_token'];

2. フォームに埋め込む(hidden)

<form action="confirm.php" method="post">
  <input type="hidden" name="csrf_token" value="<?php echo h($token); ?>">
  <!-- その他のフォーム項目 -->
</form>

3. 受信時にトークンを検証し、その後トークンを破棄(例:confirm.php)

session_start();

if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) ||
  !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
  die('不正なリクエストです(CSRFトークンエラー)。');
}

// ワンタイムのため破棄(トークンを使い捨てにする:再利用防止)
unset($_SESSION['csrf_token']);
  • 特徴: トークンの使い回しができず、常に新規生成するので、セキュリティが高い。
  • 注意点: フォームを再表示する場合はトークンの再生成が必要(例:入力エラー時など)。

固定トークン(セッション期間内有効)

1. 最初のアクセス時に一度だけ生成

session_start();

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

2. フォームに埋め込む(ワンタイムトークンと同じ)

<form action="confirm.php" method="post">
  <input type="hidden" name="csrf_token" value="<?php echo h($token); ?>">
  <!-- その他のフォーム項目 -->
</form>

3. トークンを検証(再利用可)

session_start();

if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) ||
    !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    die('不正なリクエストです(CSRFトークンエラー)。');
}
  • 特徴: 再送信やページ戻り時にトークンが有効なままなのでUXに優しい。
  • リスク: セッション中は同じトークンが使われ続けるため、漏洩時の影響が大きい。

以下の例ではワンタイムトークン方式を使用します。

入力ページ

入力ページは PHP, HTML, JavaScript の3つの要素から構成され、各パートは以下の役割を持ちます。

  • PHP:
    セッションの開始、CSRF対策用の固定トークンの生成、確認ページから戻ったときの入力値の再表示のための初期化などを行います。
  • HTML:
    form 要素を使って入力欄や「確認ページへ」ボタンを設置し、CSRFトークンを隠しフィールドとして埋め込みます。
  • JavaScript:
    フォーム送信前の入力チェック(バリデーション)を行うことで、サーバに無駄なリクエストを送らないようにします。
PHP

以下が入力ページ(contact.php)の PHP の部分です。

<?php
session_start(); // セッション開始
// セッションIDを更新(セッションハイジャック対策)
session_regenerate_id(); //または session_regenerate_id( true );

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

// エスケープ処理やバリデーション、セッション補助関数などを含める
require '../includes/helpers.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']           // オプションの名前定数
);

// セッション変数から値を取得(初回表示時は空、戻ったときは以前の入力値を復元)
$name = init_session_value('name');
$email = init_session_value('email');
$email_check = init_session_value('email_check');
$tel = init_session_value('tel');
$subject = init_session_value('subject');
$body = init_session_value('body');
$error = init_session_value('error', true);

// 毎回新しい CSRF 対策トークンを生成してセッションに保存
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// トークンを変数に代入(フォームの hidden フィールドに出力するため)
$csrf_token = $_SESSION['csrf_token'];
?>

session_start() や header() は HTMLを出力する前(echo や HTML 開始前に)に呼び出します。

session_regenerate_id() はセッションIDを更新し、セッションハイジャック対策になります。

  • true を指定すると古いセッションが削除されるため、環境によってはセッションが消失する可能性があるので注意が必要です(環境や用途に応じて使い分ける)。

X-Frame-Options と Content-Security-Policy ヘッダの出力はクリックジャッキング対策です。これを設定することで、他のドメインの <iframe> 内でこのページを読み込むことを防止します。※ 但し、セキュリティヘッダーは .htaccess でも設定可能なので、.htaccess 側で設定している場合は不要です。

validate_mail_config() と init_session_value() は helpers.php で定義されている独自関数です。

validate_mail_config() はメール送信用の定数がすべて正しく設定されているかを確認します。

  • 未設定または無効な値がある場合は、処理を中断してエラーメッセージを表示します。

init_session_value() はセッション変数の有無をチェックし、存在しなければ空文字または空配列として初期化します。配列の値を扱う場合は、第2引数に true を指定します。

  • これにより、フォームが再表示されたときにも前回の入力内容が保持されます。

最後に、CSRFトークンを random_bytes() で生成し、bin2hex() によって安全な文字列に変換したものをセッションに保存しています。

  • ワンタイムトークン方式でのCSRF対策
HTML

HTML のフォームの要素を使用して、ユーザーが入力できる欄や送信ボタンを表示しています。

フォームの前には、送信エラーがあった場合に完了画面(complete.php)からリダイレクトされた際に表示するエラーメッセージの領域を配置しています。

form 要素には method="post" を指定してデータをセキュアに送信し、action="confirm.php" によって確認ページへ遷移するようにしています。また、JavaScript で検証対象のフォームを識別するために js-form-validation クラスを付与しています。

この例では HTML5 の自動検証は使用せず、JavaScript によるカスタム検証を行うため、novalidate 属性を指定してブラウザによる自動エラーメッセージの表示を無効にしています。

CSRF 対策として、フォーム内の hidden タイプの input 要素に、サーバーサイドで生成されたトークンの値($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>
    <!-- 送信エラーがあった場合 -->
    <?php
    if (!empty($_SESSION['send_error'])) {
      echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['send_error']) . '</div>';
      unset($_SESSION['send_error']);
    }
    ?>
    <!-- novalidate → HTML5のバリデーションは無効にしてJSで制御 -->
    <form class="js-form-validation contact-form" method="post" action="confirm.php" novalidate>
      <!-- 以下の隠し要素にトークンを埋め込む -->
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php print_error($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 print_error($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 print_error($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 print_error($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 print_error($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 print_error($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="confirm" type="submit" class="form-button">確認</button>
    </form>
  </div>
  <!--  検証用の JavaScript の読み込み -->
  <script src="../assets/js/form-validation.js"></script>
</body>

</html>

各入力項目は div 要素で囲まれ、label 要素とフォームコントロール(input や textarea)で構成されています。以下は「お名前」欄の例です。

<div>
  <label for="name">お名前(必須)
    <span class="error-php"><?php print_error($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>
  • label 要素内の span.error-php には、サーバー側で検出されたエラーが表示されます。これは確認ページ(confirm.php)での検証でエラーが発生した場合に、$error['name'] が設定されて表示されるものです。
  • print_error() 関数や h() 関数は、別ファイル(helpers.php)で定義されており、エスケープ処理やエラー出力を行います。

入力欄(input や textarea)の value 属性や内容には、確認ページから戻ってきた際に以前の入力内容が保持されるよう、PHPの変数が h() 関数でエスケープされた状態で出力されます。

JavaScript による検証指定

各 input 要素の class 属性には、JavaScript 検証ロジックに応じたクラス(例:required、pattern、maxlength、equal-to など)を指定しています。また、data-* 属性(カスタムデータ属性)を用いて、検証ルールやエラーメッセージを個別に指定できます。

JavaScript の読み込み

最後に、</body> タグの直前でフォーム検証用の JavaScript ファイル(form-validation.js)を読み込むことで、ページ読み込み後に検証機能が有効化されます。

<script src="../assets/js/form-validation.js"></script>
form-validation.js(JavaScript)

検証用の JavaScript ファイル form-validation.js を作成します。

以下のコードをコピーして assets/js/ フォルダに form-validation.js という名前で保存します。

// バリデーションスクリプト(検証用の JavaScript )
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();
});

この検証用スクリプトを使うには HTML で form 要素に js-form-validation というクラスを指定します。そして、検証対象のフォームコントロール要素に以下のようなクラスや data-* 属性を指定します(前述の HTML ですでに指定済み)。

クラス 意味 指定が必要なカスタムデータ属性
required 必須入力 なし
maxlength 最大文字数 data-maxlength:最大文字数を指定
minlength 最小文字数 data-minlength:最小文字数を指定
pattern パターン検証 data-pattern:検証に使う正規表現パターンを指定。Email の検証は email、電話番号の検証は tel と指定すれば、デフォルトのパターンで検証。
equal-to 値が一致するかを検証 data-equal-to:値を比較する要素の id 属性を指定
showCount 入力された文字数を出力 なし

上記クラスや data-* 属性に加え、検証対象の要素の data-error-xxxx 属性(xxxx は上記クラス名)にエラーメッセージを指定することで、それぞれのコントロールで独自のエラーメッセージを表示することができます。例えば、data-error-required="お名前は必須です。" の「お名前は必須です。」の部分を変更することでエラーメッセージをカスタマイズできます。

また、最大文字数を検証する maxlength クラス及び data-maxlength 属性を指定した要素に showCount クラスを指定すれば入力文字数を表示します。この例では「お問い合わせ内容」のテキストエリアの下に入力文字数を表示しています。

検証は送信時(サーバーに送られる前)に行い、初回送信後にエラーがある場合は input イベントを使って入力された値が変更される度に検証します。検証の結果エラーがあれば、最初のエラーの位置までスクロールするようになっています。

以下は名前を入力する input 要素の例です。

クラスに required と maxlength を指定しているので、必須入力と最大文字数の検証が適用されます。

最大文字数は data-maxlength 属性に指定します(以下の場合は最大30文字)。

data-error-required 属性により、必須入力のエラー時(何も入力されていない場合)は、「お名前は必須です。」と表示されます。data-error-maxlength は指定していないので、最大文字数を超えた場合は、デフォルトの「xx文字以内で入力ください」と表示されます。

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

以下はメールアドレスを入力する input 要素の例です。

クラスに required と pattern を指定しているので、必須入力検証とパターン検証が適用されます。

data-pattern="email" と指定されているので、デフォルトの Email アドレスのパターンで検証され、data-error-xxxx 属性により、エラー時にはそれぞれ指定されたエラーメッセージが表示されます。

<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); ?>">

関連ページ(上記スクリプトの使い方の概要):セキュアなメールフォームの作り方

contact.php

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

<?php
session_start(); // セッション開始
// セッションIDを更新(セッションハイジャック対策)
session_regenerate_id(); //または session_regenerate_id( true );

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

// エスケープ処理やバリデーション、セッション補助関数などを含める
require '../includes/helpers.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']           // オプションの名前定数
);

// セッション変数から値を取得(初回表示時は空、戻ったときは以前の入力値を復元)
$name = init_session_value('name');
$email = init_session_value('email');
$email_check = init_session_value('email_check');
$tel = init_session_value('tel');
$subject = init_session_value('subject');
$body = init_session_value('body');
$error = init_session_value('error', true);

// 毎回新しい CSRF 対策トークンを生成してセッションに保存
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// トークンを変数に代入(フォームの hidden フィールドに出力するため)
$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>
    <!-- 送信エラーがあった場合 -->
    <?php
    if (!empty($_SESSION['send_error'])) {
      echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['send_error']) . '</div>';
      unset($_SESSION['send_error']);
    }
    ?>
    <!-- novalidate → HTML5のバリデーションは無効にしてJSで制御 -->
    <form class="js-form-validation contact-form" method="post" action="confirm.php" novalidate>
      <!-- 以下の隠し要素にトークンを埋め込む -->
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php print_error($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 print_error($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 print_error($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 print_error($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 print_error($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 print_error($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="confirm" type="submit" class="form-button">確認</button>
    </form>
  </div>
  <!--  検証用の JavaScript の読み込み -->
  <script src="../assets/js/form-validation.js"></script>
</body>

</html>

確認ページ

確認ページは、ユーザーが入力ページでフォームに値を入力し、「確認画面へ」ボタンをクリックした際に表示されます。この時、JavaScriptによるクライアント側の簡易チェックと、PHPによるサーバー側でのバリデーションが行われ、問題がなければ確認ページが表示されます。

この確認ページは、PHP と HTML の2つの要素で構成されており、それぞれの役割は以下の通りです。

  • PHP 部分:
    入力ページから POST メソッドで送信されたフォームデータを受け取り、CSRFトークンの検証および入力値の検証を行います。バリデーションに問題がなければセッションに保存し、確認画面を表示します。エラーがあった場合は入力ページにリダイレクトします。
  • HTML 部分:
    PHPで検証を通過した入力内容を画面に表示し、確認後に送信するための「送信」ボタンと、内容を修正するために入力画面に戻る「戻る」ボタンを提供します。
PHP

以下が confirm.php の PHP の部分です。

contact.php 同様、セッションを開始し、session_regenerate_id() でセッションIDを更新(セッションハイジャック対策)し、X-Frame-Options と Content-Security-Policy ヘッダーにより、クリックジャッキング攻撃を防ぎます。

CSRFトークンの検証では、トークンが POSTで送信され、かつセッションに保存済みであるかを確認し、hash_equals() により、トークンの一致を厳密に比較します。検証後は unset() でトークンを無効化してワンタイム利用を保証します。

checkInput() 関数で 全体のエスケープ文字や制御文字などの危険な要素を除去し、その後、init_post_value() で個別に変数へ代入しています(これらの関数は helpers.php で定義)。

バリデーション(検証)処理で、不正な入力があれば $error[] 配列にエラーメッセージを格納します。

そして、後続の complete.php で使用するため、入力内容をすべてセッションに格納し、エラー情報もセッションに一時保存して、入力画面に戻した際に表示できるようにします。

バリデーションエラーがある場合は入力画面(contact.php)にリダイレクトします。

最後に complete.php(送信完了処理)用のワンタイムトークンを再生成して変数に代入しています。

<?php
session_start(); // ユーザーごとのセッション開始
session_regenerate_id(); // セッションIDを更新

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

// 共通関数や設定ファイルを読み込み
require '../includes/helpers.php';       // h() や checkInput() などの共通関数
require '../includes/mail_config.php';   // メール送信に必要な定数

// トークンを確認(CSRF対策)
if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  $csrf_token = $_POST['csrf_token'];
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    // トークン不一致:入力フォームにリダイレクト
    redirect_to_contact_input(); // または redirect_to_page()
  }
  // トークンを一度使ったら無効化
  unset($_SESSION['csrf_token']);
} else {
  // トークン未送信:直接アクセスなどトークンが存在しない場合は処理を中止(エラーにする)
  die('Access Denied(直接このページにはアクセスできません)');
}

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

// init_session_value() を使って POST データから変数に代入(この時点で $_POST は検査済み)
$name    = init_post_value('name');
$email   = init_post_value('email');
$email_check   = init_post_value('email_check');
$tel     = init_post_value('tel');
$subject = init_post_value('subject');
$body    = init_post_value('body');

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

//値の検証(入力内容が条件を満たさない場合はエラーメッセージを配列 $error に設定)
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,100}\z/u', $subject)) {
  $error['subject'] = '*件名は100文字以内でお願いします。';
}

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

//POSTされたデータとエラーの配列をセッション変数に保存
$_SESSION['name'] = $name;
$_SESSION['email'] = $email;
$_SESSION['email_check'] = $email_check;
$_SESSION['tel'] = $tel;
$_SESSION['subject'] = $subject;
$_SESSION['body'] = $body;
$_SESSION['error'] = $error;

//チェックの結果にエラーがある場合は入力フォームに戻す
if (count($error) > 0) {
  redirect_to_contact_input();
}

// 最後でワンタイムトークンを新たに生成・保存
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$csrf_token = $_SESSION['csrf_token'];
?>

CSRF トークンの検証

このコードでは、CSRF(クロスサイトリクエストフォージェリ)対策としてワンタイムトークン(使い捨てのCSRFトークン)を用いた検証を行っています。

  • トークンが $_POST と $_SESSION の両方に存在している場合に、hash_equals() を使って安全に比較します(タイミング攻撃対策)。
  • トークンが一致しない場合(例:再読み込みによるトークンの再送信、または不正な改ざん)は、入力ページにリダイレクトさせて再入力を促します。
  • トークンが送信されていない($_POST か $_SESSION のどちらかが欠けている)場合は、直接アクセスや不正アクセスとみなして処理を即時中断します。
  • トークンは検証後すぐに unset() によって破棄されるため、1回のリクエスト限りの使い捨て(ワンタイムトークン)となっており、セキュリティ上安全な実装になっています。

トークン不一致時に即時中断してしまうと、ユーザビリティに悪影響を与える可能性があります。

セキュリティの観点では、トークンが一致しない=不正なリクエストの可能性があるので処理を止めるのが正しいですが、現実のユーザー行動では、ブラウザの「戻る」ボタンや「再読み込み」で確認画面を再表示するなど正当な操作でもトークンが不一致になることがあります。

そのたびに 「Access Denied」や「403」 として処理を中断すると、ユーザーは混乱してしまうので、この例ではトークン不一致時は入力画面にリダイレクトするようにしています。

リダイレクトは、helpers.php に定義した関数 redirect_to_contact_input() を使用しています。

HTML

HTML 部分は、「お問い合わせフォームの確認画面」を構成しています。ユーザーが入力した内容を一覧表示し、送信前に最終確認するためのインターフェースを提供しています。

ページのタイトルとユーザーへの案内文を表示し、入力した各項目を確認用に表示しています。

入力された値の出力は h() 関数で HTMLエスケープし、お問い合わせ内容の出力では nl2br() で改行を反映しています。

入力画面に戻るためのフォームと入力内容を complete.php に送信するフォームを配置します。

  • 「戻る」ボタン:入力画面に戻るためのフォーム
  • 「送信する」ボタン:入力内容を complete.php に送信
    • CSRF 対策のためのトークンを hiddenフィールドに設定
<!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>
    <div class="confirm-table-wrapper">
      <table class="confirm-table">
        <caption>ご入力内容</caption>
        <tr>
          <th>お名前</th>
          <td><?php echo h($name); ?></td>
        </tr>
        <tr>
          <th>Email</th>
          <td><?php echo h($email); ?></td>
        </tr>
        <tr>
          <th>お電話番号</th>
          <td><?php echo h($tel); ?></td>
        </tr>
        <tr>
          <th>件名</th>
          <td><?php echo h($subject); ?></td>
        </tr>
        <tr>
          <th>お問い合わせ内容</th>
          <td><?php echo nl2br(h($body)); ?></td>
        </tr>
      </table>
    </div>
    <div class="confirm-forms">
      <form action="contact.php" method="post" class="confirm back">
        <button type="submit" class="form-button">戻る</button>
      </form>
      <form action="complete.php" method="post" class="confirm send">
        <!-- 完了画面(complete.php)用のCSRFトークンの隠しフィールド -->
        <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
        <button type="submit" class="form-button">送信する</button>
      </form>
    </div>
  </div>
</body>
</html>
confirm.php

以下は confirm.php の全文です。

<?php
session_start();
session_regenerate_id();

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

require '../includes/helpers.php';
require '../includes/mail_config.php';

// トークンを確認(CSRF対策)
if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  $csrf_token = $_POST['csrf_token'];
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    // トークン不一致:入力フォームにリダイレクト
    redirect_to_contact_input(); // または redirect_to_page()
  }
  // トークンを一度使ったら無効化
  unset($_SESSION['csrf_token']);
} else {
  // トークン未送信:直接アクセスなどトークンが存在しない場合は処理を中止(エラーにする)
  die('Access Denied(直接このページにはアクセスできません)');
}

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

// init_session_value() を使って POST データから変数に代入(この時点で $_POST は検査済み)
$name    = init_post_value('name');
$email   = init_post_value('email');
$email_check   = init_post_value('email_check');
$tel     = init_post_value('tel');
$subject = init_post_value('subject');
$body    = init_post_value('body');

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

//値の検証(入力内容が条件を満たさない場合はエラーメッセージを配列 $error に設定)
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,100}\z/u', $subject)) {
  $error['subject'] = '*件名は100文字以内でお願いします。';
}

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

//POSTされたデータとエラーの配列をセッション変数に保存
$_SESSION['name'] = $name;
$_SESSION['email'] = $email;
$_SESSION['email_check'] = $email_check;
$_SESSION['tel'] = $tel;
$_SESSION['subject'] = $subject;
$_SESSION['body'] = $body;
$_SESSION['error'] = $error;

//チェックの結果にエラーがある場合は入力フォームに戻す
if (count($error) > 0) {
  redirect_to_contact_input();
}

// 最後でワンタイムトークンを新たに生成・保存
$_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>以下の内容でよろしければ「送信する」をクリックしてください。<br>
      内容を変更する場合は「戻る」をクリックして入力画面にお戻りください。</p>
    <div class="confirm-table-wrapper">
      <table class="confirm-table">
        <caption>ご入力内容</caption>
        <tr>
          <th>お名前</th>
          <td><?php echo h($name); ?></td>
        </tr>
        <tr>
          <th>Email</th>
          <td><?php echo h($email); ?></td>
        </tr>
        <tr>
          <th>お電話番号</th>
          <td><?php echo h($tel); ?></td>
        </tr>
        <tr>
          <th>件名</th>
          <td><?php echo h($subject); ?></td>
        </tr>
        <tr>
          <th>お問い合わせ内容</th>
          <td><?php echo nl2br(h($body)); ?></td>
        </tr>
      </table>
    </div>
    <div class="confirm-forms">
      <form action="contact.php" method="post" class="confirm back">
        <button type="submit" class="form-button">戻る</button>
      </form>
      <form action="complete.php" method="post" class="confirm send">
        <!-- 完了画面(complete.php)用のCSRFトークンの隠しフィールド -->
        <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
        <button type="submit" class="form-button">送信する</button>
      </form>
    </div>
  </div>
</body>

</html>

完了ページ

完了ページでは、お問い合わせフォームから送信された内容を処理し、ユーザーと管理者の双方にメールを送信した後、完了メッセージを表示するためのページで、以下が主な責務です。

  1. フォームから送られたデータの正当性チェック(CSRF対策)
  2. 管理者・ユーザーへのメール送信(+自動返信機能)
  3. セッションと状態のクリーンアップ(再送防止)
PHP

以下が確認ページ(complete.php)の PHP 部分です。

セキュリティ対策

  1. クリックジャッキング防止:
    • 他のファイル同様、X-Frame-Options と Content-Security-Policy ヘッダーで、他ドメインからの iframe 埋め込みを防止。
  2. キャッシュ防止:
    • ユーザーが再読み込みや「戻る→進む」をしたときに、古い POST データで再送信が起きないようにするため、Cache-Control、Pragma、Expires ヘッダーを指定し、完了ページがブラウザキャッシュに残らないようにしています。
    • また、POST でないアクセスは拒否することで、GET やキャッシュ経由のアクセスを事前にシャットアウトしています。
  3. CSRF 対策:
    • POST で送信された CSRF トークンが、セッション内のトークンと一致しているかを検証。
    • 不一致または未送信の場合は送信を中断または入力ページにリダイレクト。
  4. セッションの破棄:
    • メール送信後に $_SESSION をクリアし、セッションクッキーも無効化して、セッション情報の再利用を防止。
<?php
session_start();  // ユーザーごとのセッション開始(CSRFトークンや入力情報を保持)
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");

// 共通関数や設定ファイルを読み込み
require '../includes/helpers.php';      // h() や checkInput() などの共通関数
require '../includes/mail_config.php';   // メール送信に必要な定数

// POST アクセス以外を拒否(再読み込みや直アクセス対策)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  redirect_to_contact_input(); // 入力画面へ戻す
  exit;
}

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

// CSRF トークンの存在と一致をチェック
if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  // トークンの厳密比較(タイミング攻撃対策のため)
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']))  {
    // トークン不一致: 攻撃や不正アクセスを即終了
    die('Access denied');
  }
} else {
  // トークンがない or セッションに無い:正規のフローではないためリダイレクト
  redirect_to_contact_input(); // または redirect_to_page()
}

// セッションから入力値を取得(h()でサニタイズ)変数に代入
$name = h($_SESSION['name']);
$email = h($_SESSION['email']);
$tel =  h($_SESSION['tel']);
$subject = h($_SESSION['subject']);
$body = h($_SESSION['body']);

//お問い合わせ日時を日本時間に
date_default_timezone_set('Asia/Tokyo');

// メールの文字エンコーディングを設定
mb_language('uni');
mb_internal_encoding('UTF-8');

// 送信先、ヘッダーの準備(From, Reply-Toなど)
$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";

// オプションで CC や BCC を設定可能
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);

// safe_mode環境でのメール送信の互換対応
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) {
  // 自動返信が有効か確認(設定ファイルのフラグ)
  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;

    // safe_mode対応
    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);
    }
  }
} else {
  // メール送信に失敗した場合
  $_SESSION['send_error'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
  // 入力ページにリダイレクト
  redirect_to_contact_input();
  exit;
}

// --- セッションの後処理(完全削除)
$_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();
?>

メール送信処理

  1. 管理者宛メール:
    • ユーザーの入力情報(名前、メール、電話番号、件名、本文)を含めた内容を管理者に送信。
    • From, Reply-To, Cc, Bcc ヘッダーも指定可能。

    関連ページ:メールの組み立てと送信処理

  2. 自動返信メール(オプション):
    • AUTO_REPLY_ENABLED が true であれば、ユーザーにも自動返信メールを送信。
    • 問い合わせ日時・内容を明記した返信文が送られる。
    • 成否によって画面に成功または失敗メッセージを表示。

何らかの理由でメール送信に失敗した場合は、セッションにエラーメッセージを保存して入力画面にリダイレクトし、エラーメッセージを表示します。

HTML

メールの送信処理の結果(完了メッセージ)をユーザに表示します。

ブラウザの戻るボタン対策

完了ページで「戻る」「更新」したときに、二重送信が発生しないように、window.history.replaceState() を使ってブラウザの履歴を上書きします(ページの再読み込みや戻る操作による誤送信の再実行を防止)。

<!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>
    <p>ありがとうございました。</p>
    <!-- 自動返信の成否を表示(ARIA属性付きでアクセシビリティ対応) -->
    <?php if ($reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$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>
complete.php

以下は complete.php の全文です。

<?php
session_start();  // ユーザーごとのセッション開始(CSRFトークンや入力情報を保持)
// クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
header("X-Frame-Options: SAMEORIGIN");
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");

// 共通関数や設定ファイルを読み込み
require '../includes/helpers.php';      // h() や checkInput() などの共通関数
require '../includes/mail_config.php';   // メール送信に必要な定数

// POST アクセス以外を拒否(再読み込みや直アクセス対策)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  redirect_to_contact_input(); // 入力画面へ戻す
  exit;
}

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

// CSRF トークンの存在と一致をチェック
if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  // トークンの厳密比較(タイミング攻撃対策のため)
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']))  {
    // トークン不一致: 攻撃や不正アクセスを即終了
    die('Access denied');
  }
} else {
  // トークンがない or セッションに無い:正規のフローではないためリダイレクト
  redirect_to_contact_input(); // または redirect_to_page()
}

// セッションから入力値を取得(h()でサニタイズ)変数に代入
$name = h($_SESSION['name']);
$email = h($_SESSION['email']);
$tel =  h($_SESSION['tel']);
$subject = h($_SESSION['subject']);
$body = h($_SESSION['body']);

//お問い合わせ日時を日本時間に
date_default_timezone_set('Asia/Tokyo');

// メールの文字エンコーディングを設定
mb_language('uni');
mb_internal_encoding('UTF-8');

// 送信先、ヘッダーの準備(From, Reply-Toなど)
$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";

// オプションで CC や BCC を設定可能
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);

// safe_mode環境でのメール送信の互換対応
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) {
  // 自動返信が有効か確認(設定ファイルのフラグ)
  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;

    // safe_mode対応
    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);
    }
  }
} else {
  // メール送信に失敗した場合
  $_SESSION['send_error'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
  // 入力ページにリダイレクト
  redirect_to_contact_input();
  exit;
}

// --- セッションの後処理(完全削除)
$_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>
    <p>ありがとうございました。</p>
    <!-- 自動返信の成否を表示(ARIA属性付きでアクセシビリティ対応) -->
    <?php if ($reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$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>

使い方

※このフォームは 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

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

reCAPTCHA v3 の実装

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

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

reCAPTCHA v3 を使ったフォーム送信の実装の流れ(概要)

以下は実装のおおまかな流れです。

  1. 確認ページ(confirm.php)のフロントエンドで「送信」ボタンがクリックされたら、reCAPTCHA v3 のトークンを取得し、フォームに埋め込む(以下の処理は別途定義する JavaScript に記述)
    • grecaptcha.execute() を使って非同期にトークンを取得
    • <input type="hidden" name="g-recaptcha-response" value="..."> としてフォームに追加
    • 同時に <input type="hidden" name="action" value="contact"> なども追加しておく
  2. ユーザーの送信時にトークン付きの POST リクエストが完了ページ(complete.php)に送信される
  3. 完了ページ(complete.php)で、トークンや action を受け取り、Google の reCAPTCHA 検証 API にサーバー側から送信(この処理は別途定義する PHP 関数に記述)
  4. Google から返された検証結果(success, action, score)を確認する
    • score >= 0.7(など任意の閾値)かつ action が意図通りであるかをチェック
  5. 検証に成功した場合は、メール送信などの処理を実行
  6. 検証に失敗した場合は、入力ページ(contact.php)にリダイレクトし、再送信を促す

以下は reCAPTCHA v3 を実装する場合のファイル構成です。

これまでの構成に reCAPTCHA v3 の実装に使用する JavaScript ファイルを追加します。また、以下の「変更あり」のコメントのついたファイルの内容を一部変更します。

├── assets
│   ├── css
│   │   └── contact-style.css
│   └── js
│       ├── form-validation.js
│       └── recaptcha-v3-handler.js  # reCAPTCHA V3 のトークンを送信する JavaScript を追加
├── contact
│   ├── contact.php  # 変更あり
│   ├── confirm.php  # 変更あり
│   └── complete.php  # 変更あり
└── includes
    ├── .htaccess
    ├── helpers.php  # 変更あり
    ├── index.php
    └── mail_config.php  # 変更あり   

以下はそれぞれのファイルの変更の概要です。

  • mail_config.php:reCAPTCHA のサイトキーとシークレットキーの定義を追加
  • helpers.php:reCAPTCHA 検証に使用する PHP 関数の定義を追加
  • contact.php:各キーの定義チェック、エラー時の表示を追加
  • confirm.php:サイトキーや reCAPTCHA API、追加した JavaScript の読み込みなどを追加
  • complete.php:各キーの読み込み、PHP 関数を使っての検証、失敗時のリダイレクトなどを追加

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

recaptcha-v3-handler.js(JavaScript)

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

この JavaScript コードは、Google reCAPTCHA v3 をフォーム送信時に組み込む実装です。confirm.php でこのファイルと reCAPTCHA API スクリプトを読み込みます。

フォーム送信前(検証の後)に 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の認証に失敗しました。再度お試しください。");
        });
    });
  });
});

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

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);

verify_recaptcha_v3()

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

この関数 verify_recaptcha_v3() は、complete.php で呼び出され、confirm.php の JavaScript によって取得された reCAPTCHA v3 トークン(g-recaptcha-response)を検証するための関数です。

トークンとアクション名を Google の verify API(https://www.google.com/recaptcha/api/siteverify)に送信し、スコア付きのレスポンスを元に「人間によるアクセスかどうか」を判定します。スコアが指定されたしきい値($threshold、デフォルトは 0.5)以上であれば「人間と判断」、それ未満であれば「ボットの可能性が高い」と判断します。

主な処理の流れ

  • 入力チェック(トークンとアクション名)
    • どちらかが空であれば即座に 'status' => false とエラーメッセージを返します。
  • Google API への送信(cURL 使用)
    • secret, response, remoteip を POST データとして siteverify API に送信します。
  • 通信・レスポンスエラーの検出
    • curl_exec() の結果が false の場合や、HTTP ステータスコードが 200 以外の場合に適切なエラーメッセージを返します。
  • レスポンスの解析と検証
    • JSON を json_decode() し、以下の3条件すべてを満たした場合に 'status' => true として「成功」と判定します:
      1. success === true
      2. action === $response_action
      3. score >= $threshold
  • 失敗時の詳細メッセージ出力
    • いずれかの条件を満たさない場合、原因に応じたメッセージを $result['message'] に格納して返します。エラーコードが含まれていれば、それもメッセージに追加されます。
  • 戻り値
    • 戻り値は連想配列で、status が true(成功)または false(失敗)を示します。message には詳細な説明(もしあれば)が格納されます。
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 の意味が薄れる(非推奨)。

関連ページ:reCAPTCHA v3 PHP を使った検証

helpers.php

以下は verify_recaptcha_v3() を追加した helpers.php のコード全体です。

<?php
if (!function_exists('h')) {
  /**
   * エスケープ処理を行う関数
   *
   * @param string|array|null $var チェックする文字列または配列(nullも可)
   * @return string|array エスケープされた文字列または再帰的に処理された配列
   */
  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 チェックする文字列または配列
   * @return string|array 入力が正しい場合はそのまま返す。不正な場合はスクリプトを終了する
   */
  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('init_session_value')) {
  /**
   * SESSION 値の初期化を行う関数
   * 未定義の場合は空文字列または空の配列を返す
   *
   * @param string  $key セッションキー名($_SESSION 変数のキー)
   * @param boolean $is_array 配列かどうか(デフォルトは false)
   * @return mixed セッション値、または空文字列・空配列
   */
  function init_session_value($key, $is_array = false) {
    if ($is_array) {
      return $_SESSION[$key] ?? [];
    }
    return $_SESSION[$key] ?? '';
  }
}

if (!function_exists('init_post_value')) {
  /**
   * POST 値の初期化を行う関数
   * 未定義の場合は空文字列を返す
   *
   * @param string  $key キー名($_POST 変数のキー)
   * @return string 前後の空白を除去した POST された値、または空文字列
   */
  function init_post_value($key) {
    return trim($_POST[$key] ?? '');
  }
}

if (!function_exists('print_error')) {
  /**
   * エラーメッセージを表示する関数
   *
   * @param array  $errors エラー配列(例: $_SESSION['error'] や $error)
   * @param string $key    対象のフォーム項目のキー
   * @return void          エラーメッセージがあればエスケープして出力
   */
  function print_error(array $errors, string $key): void {
    if (isset($errors[$key])) {
      echo h($errors[$key]);
    }
  }
}

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   その他必須の定数(空文字でないことをチェック)
   * @return void エラーがあれば HTML で表示してスクリプトを終了する。問題がなければ何も返さない。
   */
  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('redirect_to_contact_input')) {
  /**
   * 入力フォーム(contact.php)へリダイレクトする関数。
   *
   * バリデーションエラー処理やCSRFトークンの失敗時など、「入力画面に戻す」際に使用
   * 再送信を防ぐため HTTP 303 ステータスコードを用いて contact.php へリダイレクトを行う。
   *
   * セッションのロックを明示的に解除することで、
   * リダイレクト先でもセッションの読み書きがブロックされないように配慮している。
   *
   * @return void
   */
  function redirect_to_contact_input() {
    // セッションのロックを解除して他のリクエストがセッションにアクセスできるようにする
    session_write_close();
    // HTTPステータスコード303を送信(POST後のリダイレクトで再送信を防ぐため)
    header('HTTP/1.1 303 See Other');
    // Locationヘッダーでリダイレクト先を指定
    header('Location: contact.php');
    // 以降の処理を終了(リダイレクト実行)
    exit;
  }
}

if (!function_exists('redirect_to_page')) {
  /**
   * 指定されたページへ 303 See Other リダイレクトを行う。
   * redirect_to_contact_input() が機能しない環境で使用することを想定
   *
   * - 現在のスクリプトのディレクトリに対して相対的なパスでページにリダイレクトする。
   * - HTTPS またはリバースプロキシ経由の HTTPS 判定にも対応。
   * - セッションロックを早期に解放してから Location ヘッダーでリダイレクトを行う。
   *
   * @param string $filename リダイレクト先のファイル名(デフォルトは 'contact.php')。
   *               先頭のスラッシュは自動的に除去される。
   *
   * @return void この関数はリダイレクト後にスクリプトを終了するため、戻り値はない。
   */
  function redirect_to_page($filename = 'contact.php') {
    // 現在実行中のスクリプトのパスからディレクトリ名を取得(例: /contact → "/")
    $dirname = dirname($_SERVER['SCRIPT_NAME']);
    // ルートディレクトリ ("/") の場合は空文字に置き換える(URL整形のため)
    $dirname = $dirname === DIRECTORY_SEPARATOR ? '' : $dirname;
    // HTTPS接続かどうかを判定($_SERVER['HTTPS']が空または'off'でなければHTTPS)
    // または、リバースプロキシ環境で 'HTTP_X_FORWARDED_PROTO' が 'https' ならHTTPSとみなす
    $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
      (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
    // 使用するスキーム(http または https)を設定
    $scheme = $https ? 'https://' : 'http://';
    // 渡されたファイル名の先頭スラッシュを削除(例: "/contact.php" → "contact.php")
    $filename = ltrim($filename, '/');
    // リダイレクト先URLを構築(例: https://example.com/contact.php)
    $url = $scheme . $_SERVER['SERVER_NAME'] . $dirname . '/' . $filename;
    // HTTPステータスコード303を送信(POST後のリダイレクトで再送信を防ぐため)
    header('HTTP/1.1 303 See Other');
    // セッションのロックを解除して他のリクエストがセッションにアクセスできるようにする
    session_write_close();
    // Locationヘッダーでリダイレクト先を指定
    header('Location: ' . $url);
    // 以降の処理を終了(リダイレクト実行)
    exit;
  }
}

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; // 検証結果を返す
  }
}

contact.php

以下が reCAPTCHA v3 を実装する場合の contact.php です。

contact.php では、メール送信に必要な設定項目(メールアドレスなど)とともに、reCAPTCHA V3 のサイトキーとシークレットキーが mail_config.php に定義されているかを validate_mail_config() 関数でチェックします。これにより、設定ミスによる動作不良を未然に防ぎます。

complete.php 側で reCAPTCHA の検証処理を行い、トークン検証に失敗した場合は、エラーメッセージを $_SESSION['recaptcha_error'] に格納して contact.php にリダイレクトします。

contact.php のHTML 部分ではそのセッション変数を確認し、エラーがあればフォーム上に表示する仕組みになっています(送信エラーと同様)。

<?php
session_start();
session_regenerate_id();
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 サイトキーとシークレットキー
);

$name = init_session_value('name');
$email = init_session_value('email');
$email_check = init_session_value('email_check');
$tel = init_session_value('tel');
$subject = init_session_value('subject');
$body = init_session_value('body');
$error = init_session_value('error', true);

$_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>お問い合わせフォーム(確認画面あり)rc3</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <?php
      // reCAPTCHA エラーがあった場合を追加
      if (!empty($_SESSION['recaptcha_error'])) {
        echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['recaptcha_error']) . '</div>';
        unset($_SESSION['recaptcha_error']);
      }
      if (!empty($_SESSION['send_error'])) {
        echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['send_error']) . '</div>';
        unset($_SESSION['send_error']);
      }
    ?>
    <form class="js-form-validation contact-form" method="post" action="confirm.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php print_error($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 print_error($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 print_error($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 print_error($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 print_error($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 print_error($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="confirm" type="submit" class="form-button">確認</button>
    </form>
  </div>
  <script src="../assets/js/form-validation.js"></script>
</body>

</html>

confirm.php

以下が reCAPTCHA v3 を実装する場合の confirm.php です。

PHP 部分では、reCAPTCHA V3 のサイトキー(V3_SITEKEY)を mail_config.php から取得し、変数 $v3_site_key に代入します。このキーはフロントエンドの JavaScript に渡され、reCAPTCHA の認証処理に使用されます。

HTML では、送信ボタンを含むフォームに rcv3 クラスを付与し、recaptcha-v3-handler.js でこのフォームを対象として認識させます。また、JavaScript 側でサイトキーを利用できるように、グローバル変数 window.recaptchaV3SiteKey に $v3_site_key の値を設定しています。

Google reCAPTCHA API のスクリプトおよびカスタムスクリプト recaptcha-v3-handler.js を読み込み、ユーザーの行動に応じてトークンを取得し、フォーム送信時に自動的にそのトークンが付加されるようにします。

これにより、reCAPTCHA V3 のバッジが確認ページに表示され、ユーザーが「送信する」をクリックすると、reCAPTCHA トークンが付与された POST リクエストが完了ページ(complete.php)に送信されます。最終的なトークン検証は complete.php 側で行う必要があります。

<?php
session_start();
session_regenerate_id();
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 サイトキーの取得(mail_config.php で定義)
$v3_site_key   = V3_SITEKEY;

if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
  $csrf_token = $_POST['csrf_token'];
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    redirect_to_contact_input();
  }
  unset($_SESSION['csrf_token']);
} else {
  die('Access Denied(直接このページにはアクセスできません)');
}

$_POST = checkInput($_POST);

$name    = init_post_value('name');
$email   = init_post_value('email');
$email_check   = init_post_value('email_check');
$tel     = init_post_value('tel');
$subject = init_post_value('subject');
$body    = init_post_value('body');

$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,100}\z/u', $subject)) {
  $error['subject'] = '*件名は100文字以内でお願いします。';
}

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

$_SESSION['name'] = $name;
$_SESSION['email'] = $email;
$_SESSION['email_check'] = $email_check;
$_SESSION['tel'] = $tel;
$_SESSION['subject'] = $subject;
$_SESSION['body'] = $body;
$_SESSION['error'] = $error;

if (count($error) > 0) {
  redirect_to_contact_input();
}

$_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>以下の内容でよろしければ「送信する」をクリックしてください。<br>
      内容を変更する場合は「戻る」をクリックして入力画面にお戻りください。</p>
    <div class="confirm-table-wrapper">
      <table class="confirm-table">
        <caption>ご入力内容</caption>
        <tr>
          <th>お名前</th>
          <td><?php echo h($name); ?></td>
        </tr>
        <tr>
          <th>Email</th>
          <td><?php echo h($email); ?></td>
        </tr>
        <tr>
          <th>お電話番号</th>
          <td><?php echo h($tel); ?></td>
        </tr>
        <tr>
          <th>件名</th>
          <td><?php echo h($subject); ?></td>
        </tr>
        <tr>
          <th>お問い合わせ内容</th>
          <td><?php echo nl2br(h($body)); ?></td>
        </tr>
      </table>
    </div>
    <div class="confirm-forms">
      <form action="contact.php" method="post" class="confirm back">
        <button type="submit" class="form-button">戻る</button>
      </form>
      <!-- 送信ボタンのフォーム要素に rcv3 クラスを追加 -->
      <form action="complete.php" method="post" class="rcv3 confirm send">
        <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
        <button type="submit" class="form-button">送信する</button>
      </form>
    </div>
  </div>
  <!-- reCAPTCHA サイトキーを JavaScript(recaptcha-v3-handler.js)に渡す -->
  <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

以下が reCAPTCHA v3 を実装する場合の complete.php です。

mail_config.php から reCAPTCHA v3 のサイトキーとシークレットキーを取得して、それぞれ $v3_site_key と $v3_secret_key に代入します。また、reCAPTCHA のスコアや結果を完了画面に表示するかどうかを制御するフラグ SHOW_RECAPTCHA_V3_RESULT の値を取得し、$show_recaptcha_v3_result に代入します。

フォーム送信時に $_POST['g-recaptcha-response'](reCAPTCHAトークン)が設定されている場合は、まず CSRFトークンの整合性を確認したうえで、helpers.php に定義された verify_recaptcha_v3() 関数を使って reCAPTCHA v3 の検証を行います。

検証に成功した場合は、セッションに保持された入力情報をもとにメール送信処理を行い、自動返信メールも条件付きで送信します。検証が失敗した場合には、セッションにエラーメッセージを設定し、エラーログを出力した後、入力画面にリダイレクトします。

また、reCAPTCHAトークン自体が未送信または空である場合も同様に、不正アクセスとして扱い、エラーログ出力後に入力画面へリダイレクトします。

HTML部分では、$show_recaptcha_v3_result が true に設定されていれば、reCAPTCHAの検証結果(success, action, score)を画面に表示します。これは主に開発・検証用の機能です。

<?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");
require '../includes/helpers.php';
require '../includes/mail_config.php';

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

// reCAPTCHA 検証結果を完了画面に表示するかどうか(mail_config.php で定義)
$show_recaptcha_v3_result = (defined('SHOW_RECAPTCHA_V3_RESULT') && SHOW_RECAPTCHA_V3_RESULT) ? true : false;

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  redirect_to_contact_input();
  exit;
}

$_POST = checkInput($_POST);

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

  // CSRF トークンの存在と一致をチェック
  if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
      die('Access denied');
    }
  } else {
    redirect_to_contact_input();
  }

  // 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']) {
    // reCAPTCHA 検証結果(セッションから取得)
    $success  = $recaptcha_v3_result['success'] ?? '';
    $action   = $recaptcha_v3_result['action'] ?? '';
    $score    = $recaptcha_v3_result['score'] ?? '';

    $name = h($_SESSION['name']);
    $email = h($_SESSION['email']);
    $tel =  h($_SESSION['tel']);
    $subject = h($_SESSION['subject']);
    $body = h($_SESSION['body']);

    date_default_timezone_set('Asia/Tokyo');
    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) {
      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);
        }
      }
    } else {
      // メール送信に失敗した場合
      $_SESSION['send_error'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
      // 入力ページにリダイレクト
      redirect_to_contact_input();
      exit;
    }

    $_SESSION = [];

    if (ini_get("session.use_cookies")) {
      $params = session_get_cookie_params();
      setcookie(
        session_name(),
        '',
        time() - 42000,
        $params["path"],
        $params["domain"],
        $params["secure"],
        $params["httponly"]
      );
    }

    session_destroy();

  } else {
    // reCAPTCHAエラー(検証失敗)の場合
    $_SESSION['recaptcha_error'] = 'スパムと判定されました。もう一度お試しください。';
    // ログ用
    error_log('reCAPTCHAエラー: ' . ($recaptcha_v3_result['message'] ?? 'エラー不明') . " IP=" . $_SERVER['REMOTE_ADDR']);
    // 入力ページに戻す
    redirect_to_contact_input();
  }
} else {
  // reCAPTCHA トークンが空または存在しない場合
  $_SESSION['recaptcha_error'] = '不正なアクセスが検出されました。もう一度お試しください。';
  // ログ用
  error_log('reCAPTCHAトークン未送信: IP=' . $_SERVER['REMOTE_ADDR']);
  // 入力ページにリダイレクト
  redirect_to_contact_input();
  exit;
}
?>
<!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>
    <p>ありがとうございました。</p>
    <!-- reCAPTCHA の判定結果出力(SHOW_RECAPTCHA_V3_RESULT が true のときのみ表示) -->
    <?php if ($show_recaptcha_v3_result && $recaptcha_v3_result['status']) : ?>
      <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 ($reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$reply_result): ?>
      <p class="fail" role="alert">確認の自動返信メールが送信できませんでした。</p>
    <?php endif; ?>
  </div>
  <script>
    if (window.history.replaceState) {
      window.history.replaceState(null, null, window.location.href);
    }
  </script>
</body>

</html>

PHPMailer を使う

mb_send_mail() の代わりに PHP のライブラリ PHPMailer を使ってメールを送信する例です。前項の reCAPTCHA v3 の実装のサンプルコードをもとに書き換えます。

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

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

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

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

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

PHPMailer のインストール

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

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

% composer require phpmailer/phpmailer

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

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

├── assets
│   ├── css
│   │   └── contact-style.css
│   └── js
│       ├── form-validation.js
│       └── recaptcha-v3-handler.js
├── contact
│   ├── contact.php
│   ├── confirm.php
│   └── complete.php
└── includes
    ├── .htaccess
    ├── composer.json   # Composer により生成されるファイル
    ├── composer.lock   # Composer により生成されるファイル
    ├── helpers.php
    ├── index.php
    ├── mail_config.php
    └── vendor/   # Composer により生成されるディレクトリ
        ├── 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 の使い方

概要

この例の場合、 PHPMailer で使用する情報を mail_config.php に追加し、それらの値が定義されているかを contact.php で確認します。

そして complete.php の mb_send_mail() によるメールの送信処理部分を 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

contact.php では、メール送信に必要な設定項目(メールアドレスなど)とともに、PHPMailer で使用する情報(SMTP サーバー、SMTP ユーザ名、パスワード)が前項の mail_config.php に定義されているかを validate_mail_config() 関数でチェックします。

その他の変更はありません。

<?php
session_start();
session_regenerate_id();
header("X-Frame-Options: SAMEORIGIN");  // .htaccess 側でも設定している場合は不要
header("Content-Security-Policy: frame-ancestors 'self';");  // .htaccess 側でも設定している場合は不要
require '../includes/helpers.php';
require '../includes/mail_config.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'],
  ['V3_SITEKEY', 'V3_SECRETKEY', 'MAIL_HOST', 'MAIL_USER', 'MAIL_PASSWORD'], // PHPMailer で使用する定数を追加
);

$name = init_session_value('name');
$email = init_session_value('email');
$email_check = init_session_value('email_check');
$tel = init_session_value('tel');
$subject = init_session_value('subject');
$body = init_session_value('body');
$error = init_session_value('error', true);

$_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>お問い合わせフォーム(確認画面あり)PHPMailer</h2>
    <p>以下のフォームからお問い合わせください。</p>
    <?php
    if (!empty($_SESSION['recaptcha_error'])) {
      echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['recaptcha_error']) . '</div>';
      unset($_SESSION['recaptcha_error']);
    }
    if (!empty($_SESSION['send_error'])) {
      echo '<div class="error-php" role="alert" aria-live="polite">' . h($_SESSION['send_error']) . '</div>';
      unset($_SESSION['send_error']);
    }
    ?>
    <form class="js-form-validation contact-form" method="post" action="confirm.php" novalidate>
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="name">お名前(必須)
          <span class="error-php"><?php print_error($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 print_error($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 print_error($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 print_error($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 print_error($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 print_error($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="confirm" type="submit" class="form-button">確認</button>
    </form>
  </div>
  <script src="../assets/js/form-validation.js"></script>
</body>

</html>

complete.php

use PHPMailer\PHPMailer\PHPMailer; は、PHPMailer\PHPMailer 名前空間にある PHPMailer クラスを、スクリプト内で短く PHPMailer として使用できるようにするための宣言です。

同様に use PHPMailer\PHPMailer\Exception; によって、PHPMailer 独自の Exception クラスを Exception という名前で使えるようになります。

require_once '../includes/vendor/autoload.php'; は、Composer が生成するオートローダーを読み込むもので、PHPMailer を含む Composer 経由でインストールしたすべてのライブラリを自動的に読み込めるようになります(autoload.php を読み込むことで、use 宣言したクラスを手動で読み込む必要がなくなります)。

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

<?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");
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';

$v3_site_key   = V3_SITEKEY;
$v3_secret_key = V3_SECRETKEY;

$show_recaptcha_v3_result = (defined('SHOW_RECAPTCHA_V3_RESULT') && SHOW_RECAPTCHA_V3_RESULT) ? true : false;

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  redirect_to_contact_input();
  exit;
}

$_POST = checkInput($_POST);

if (!empty($_POST['g-recaptcha-response'])) {

  if (isset($_POST['csrf_token'], $_SESSION['csrf_token'])) {
    if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
      die('Access denied');
    }
  } else {
    redirect_to_contact_input();
  }

  $recaptcha_v3_result = verify_recaptcha_v3(
    $v3_secret_key,
    $_POST['g-recaptcha-response'] ?? '',
    $_POST['action'] ?? '',
    $_SERVER['REMOTE_ADDR'],
    0.7
  );

  if ($recaptcha_v3_result['status']) {
    $success  = $recaptcha_v3_result['success'] ?? '';
    $action   = $recaptcha_v3_result['action'] ?? '';
    $score    = $recaptcha_v3_result['score'] ?? '';

    $name = h($_SESSION['name']);
    $email = h($_SESSION['email']);
    $tel =  h($_SESSION['tel']);
    $subject = h($_SESSION['subject']);
    $body = h($_SESSION['body']);

    date_default_timezone_set('Asia/Tokyo');
    mb_language("Japanese");
    mb_internal_encoding("UTF-8");

    /* PHPMailer 設定ここから */

    // 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) {
      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;
        }
      }
      /* PHPMailer 設定ここまで */

    } else {
      // メール送信に失敗した場合
      $_SESSION['send_error'] = 'メール送信に失敗しました。時間をおいて再度お試しください。';
      // 入力ページにリダイレクト
      redirect_to_contact_input();
      exit;
    }

    $_SESSION = [];

    if (ini_get("session.use_cookies")) {
      $params = session_get_cookie_params();
      setcookie(
        session_name(),
        '',
        time() - 42000,
        $params["path"],
        $params["domain"],
        $params["secure"],
        $params["httponly"]
      );
    }

    session_destroy();

  } else {
    $_SESSION['recaptcha_error'] = 'スパムと判定されました。もう一度お試しください。';
    error_log('reCAPTCHAエラー: ' . ($recaptcha_v3_result['message'] ?? 'エラー不明') . " IP=" . $_SERVER['REMOTE_ADDR']);
    redirect_to_contact_input();
  }
} else {
  $_SESSION['recaptcha_error'] = '不正なアクセスが検出されました。もう一度お試しください。';
  error_log('reCAPTCHAトークン未送信: IP=' . $_SERVER['REMOTE_ADDR']);
  redirect_to_contact_input();
  exit;
}
?>
<!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="送信完了メッセージ">送信が完了しました。PHPMailer</h1>
    <p>ありがとうございました。</p>
    <?php if ($show_recaptcha_v3_result && $recaptcha_v3_result['status']) : ?>
      <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 ($reply_result): ?>
      <p class="success" role="status">確認の自動返信メールをお送りいたしました。</p>
    <?php elseif (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && !$reply_result): ?>
      <p class="fail" role="alert">確認の自動返信メールが送信できませんでした。</p>
    <?php endif; ?>
  </div>
  <script>
    if (window.history.replaceState) {
      window.history.replaceState(null, null, window.location.href);
    }
  </script>
</body>

</html>

その他のファイルの変更はありません。